@rancher/shell 3.0.8-rc.9 → 3.0.8

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 (146) hide show
  1. package/apis/impl/apis.ts +61 -0
  2. package/apis/index.ts +40 -0
  3. package/apis/intf/modal.ts +90 -0
  4. package/apis/intf/shell.ts +36 -0
  5. package/apis/intf/slide-in.ts +98 -0
  6. package/apis/intf/system.ts +41 -0
  7. package/apis/shell/__tests__/modal.test.ts +80 -0
  8. package/apis/shell/__tests__/notifications.test.ts +71 -0
  9. package/apis/shell/__tests__/slide-in.test.ts +54 -0
  10. package/apis/shell/__tests__/system.test.ts +129 -0
  11. package/apis/shell/index.ts +38 -0
  12. package/apis/shell/modal.ts +41 -0
  13. package/apis/shell/notifications.ts +65 -0
  14. package/apis/shell/slide-in.ts +33 -0
  15. package/apis/shell/system.ts +65 -0
  16. package/apis/vue-shim.d.ts +11 -0
  17. package/assets/styles/global/_tooltip.scss +6 -1
  18. package/assets/translations/en-us.yaml +5 -0
  19. package/components/ActionMenuShell.vue +3 -1
  20. package/components/CruResource.vue +8 -1
  21. package/components/Drawer/ResourceDetailDrawer/__tests__/composables.test.ts +50 -1
  22. package/components/Drawer/ResourceDetailDrawer/composables.ts +19 -0
  23. package/components/Drawer/ResourceDetailDrawer/index.vue +3 -1
  24. package/components/LocaleSelector.vue +2 -2
  25. package/components/ModalManager.vue +11 -1
  26. package/components/Questions/__tests__/Yaml.test.ts +1 -1
  27. package/components/RelatedResources.vue +5 -0
  28. package/components/Resource/Detail/ResourcePopover/index.vue +5 -1
  29. package/components/ResourceDetail/Masthead/latest.vue +23 -21
  30. package/components/ResourceDetail/index.vue +3 -0
  31. package/components/ResourceTable.vue +54 -21
  32. package/components/SlideInPanelManager.vue +16 -11
  33. package/components/SortableTable/THead.vue +2 -1
  34. package/components/SortableTable/index.vue +20 -2
  35. package/components/Tabbed/index.vue +37 -2
  36. package/components/__tests__/NamespaceFilter.test.ts +49 -0
  37. package/components/auth/SelectPrincipal.vue +4 -0
  38. package/components/auth/login/ldap.vue +3 -3
  39. package/components/fleet/FleetSecretSelector.vue +1 -1
  40. package/components/form/KeyValue.vue +1 -1
  41. package/components/form/NameNsDescription.vue +1 -1
  42. package/components/form/NodeScheduling.vue +2 -2
  43. package/components/form/ResourceTabs/composable.ts +2 -2
  44. package/components/form/ResourceTabs/index.vue +0 -2
  45. package/components/form/__tests__/NameNsDescription.test.ts +42 -0
  46. package/components/formatter/LinkName.vue +5 -0
  47. package/components/nav/Group.vue +25 -7
  48. package/components/nav/Header.vue +1 -1
  49. package/components/nav/NamespaceFilter.vue +1 -0
  50. package/components/nav/Type.vue +17 -6
  51. package/components/nav/WindowManager/panels/TabBodyContainer.vue +1 -1
  52. package/components/nav/__tests__/Type.test.ts +59 -0
  53. package/composables/cruResource.ts +27 -0
  54. package/composables/focusTrap.ts +3 -1
  55. package/composables/resourceDetail.ts +15 -0
  56. package/composables/useLabeledFormElement.ts +3 -4
  57. package/config/product/fleet.js +1 -1
  58. package/config/router/navigation-guards/clusters.js +3 -3
  59. package/config/router/navigation-guards/products.js +1 -1
  60. package/config/router/routes.js +1 -5
  61. package/core/__tests__/extension-manager-impl.test.js +437 -0
  62. package/core/extension-manager-impl.js +6 -27
  63. package/core/plugin-helpers.ts +2 -2
  64. package/core/plugin.ts +9 -1
  65. package/core/plugins-loader.js +2 -2
  66. package/core/types-provisioning.ts +4 -0
  67. package/core/types.ts +35 -0
  68. package/detail/provisioning.cattle.io.cluster.vue +8 -6
  69. package/dialog/DeveloperLoadExtensionDialog.vue +1 -1
  70. package/dialog/MoveNamespaceDialog.vue +20 -4
  71. package/dialog/SearchDialog.vue +1 -0
  72. package/dialog/__tests__/MoveNamespaceDialog.test.ts +249 -0
  73. package/directives/__tests__/clean-tooltip.test.ts +298 -0
  74. package/directives/clean-tooltip.ts +234 -0
  75. package/edit/__tests__/fleet.cattle.io.gitrepo.test.ts +2 -2
  76. package/edit/__tests__/fleet.cattle.io.helmop.test.ts +98 -1
  77. package/edit/fleet.cattle.io.helmop.vue +5 -0
  78. package/edit/provisioning.cattle.io.cluster/__tests__/rke2.test.ts +21 -21
  79. package/edit/provisioning.cattle.io.cluster/index.vue +5 -5
  80. package/edit/provisioning.cattle.io.cluster/rke2.vue +8 -8
  81. package/edit/resources.cattle.io.restore.vue +1 -1
  82. package/edit/workload/Job.vue +2 -2
  83. package/edit/workload/index.vue +1 -1
  84. package/initialize/install-plugins.js +4 -5
  85. package/machine-config/azure.vue +1 -1
  86. package/machine-config/components/GCEImage.vue +1 -1
  87. package/models/__tests__/provisioning.cattle.io.cluster.test.ts +16 -0
  88. package/models/chart.js +70 -74
  89. package/models/management.cattle.io.cluster.js +1 -1
  90. package/models/provisioning.cattle.io.cluster.js +11 -3
  91. package/package.json +7 -7
  92. package/pages/auth/login.vue +3 -3
  93. package/pages/auth/setup.vue +1 -1
  94. package/pages/auth/verify.vue +3 -3
  95. package/pages/c/_cluster/apps/charts/index.vue +122 -24
  96. package/pages/c/_cluster/apps/charts/install.vue +33 -0
  97. package/pages/c/_cluster/explorer/__tests__/index.test.ts +1 -1
  98. package/pages/c/_cluster/fleet/index.vue +4 -7
  99. package/pages/c/_cluster/settings/index.vue +5 -0
  100. package/pkg/auto-import.js +3 -3
  101. package/pkg/dynamic-importer.lib.js +1 -1
  102. package/pkg/import.js +1 -1
  103. package/plugins/__tests__/mutations.tests.ts +179 -0
  104. package/plugins/dashboard-store/getters.js +1 -1
  105. package/plugins/dashboard-store/model-loader.js +1 -1
  106. package/plugins/dashboard-store/mutations.js +23 -2
  107. package/plugins/dashboard-store/resource-class.js +8 -3
  108. package/plugins/plugin.js +2 -2
  109. package/plugins/steve/__tests__/steve-pagination-utils.test.ts +301 -128
  110. package/plugins/steve/steve-class.js +1 -1
  111. package/plugins/steve/steve-pagination-utils.ts +108 -43
  112. package/rancher-components/Form/Checkbox/Checkbox.vue +1 -1
  113. package/rancher-components/Form/LabeledInput/LabeledInput.vue +1 -1
  114. package/rancher-components/RcDropdown/useDropdownContext.ts +2 -4
  115. package/rancher-components/RcItemCard/RcItemCard.vue +1 -1
  116. package/scripts/publish-shell.sh +25 -0
  117. package/store/__tests__/catalog.test.ts +1 -1
  118. package/store/__tests__/type-map.test.ts +164 -2
  119. package/store/auth.js +23 -11
  120. package/store/i18n.js +3 -3
  121. package/store/index.js +5 -3
  122. package/store/notifications.ts +2 -0
  123. package/store/prefs.js +2 -2
  124. package/store/type-map.js +17 -7
  125. package/types/internal-api/shell/modal.d.ts +6 -6
  126. package/types/notifications/index.ts +126 -15
  127. package/types/rancher/index.d.ts +9 -0
  128. package/types/shell/index.d.ts +16 -1
  129. package/types/vue-shim.d.ts +5 -4
  130. package/utils/__tests__/router.test.js +238 -0
  131. package/utils/cluster.js +4 -1
  132. package/utils/fleet.ts +8 -1
  133. package/utils/pagination-utils.ts +2 -2
  134. package/utils/pagination-wrapper.ts +1 -1
  135. package/utils/router.js +50 -0
  136. package/utils/unit-tests/pagination-utils.spec.ts +8 -8
  137. package/vue.config.js +3 -3
  138. package/composables/useExtensionManager.ts +0 -17
  139. package/core/__test__/extension-manager-impl.test.js +0 -236
  140. package/core/plugins.js +0 -38
  141. package/directives/clean-tooltip.js +0 -32
  142. package/plugins/internal-api/index.ts +0 -37
  143. package/plugins/internal-api/shared/base-api.ts +0 -13
  144. package/plugins/internal-api/shell/shell.api.ts +0 -108
  145. package/types/internal-api/shell/growl.d.ts +0 -25
  146. package/types/internal-api/shell/slideIn.d.ts +0 -15
@@ -0,0 +1,437 @@
1
+ import { DEVELOPER_LOAD_NAME_SUFFIX, createExtensionManager } from '@shell/core/extension-manager-impl';
2
+
3
+ // Mock external dependencies
4
+ jest.mock('@shell/store/type-map', () => ({ productsLoaded: jest.fn().mockReturnValue(true) }));
5
+
6
+ jest.mock('@shell/plugins/dashboard-store/model-loader', () => ({ clearModelCache: jest.fn() }));
7
+
8
+ jest.mock('@shell/config/uiplugins', () => ({ UI_PLUGIN_BASE_URL: '/api/v1/uiplugins' }));
9
+
10
+ jest.mock('@shell/plugins/clean-html', () => ({
11
+ addLinkInterceptor: jest.fn(),
12
+ removeLinkInterceptor: jest.fn(),
13
+ }));
14
+
15
+ // Mock the Plugin class
16
+ jest.mock('@shell/core/plugin', () => {
17
+ return {
18
+ Plugin: jest.fn().mockImplementation((id) => ({
19
+ id,
20
+ name: id,
21
+ builtin: false,
22
+ types: {},
23
+ uiConfig: {},
24
+ l10n: {},
25
+ modelExtensions: {},
26
+ stores: [],
27
+ locales: [],
28
+ routes: [],
29
+ validators: {},
30
+ uninstallHooks: [],
31
+ productNames: [],
32
+ products: [],
33
+ })),
34
+ EXT_IDS: {
35
+ MODELS: 'models',
36
+ MODEL_EXTENSION: 'model-extension'
37
+ }
38
+ };
39
+ });
40
+
41
+ // Mock ExtensionPoint
42
+ jest.mock('@shell/core/types', () => ({ ExtensionPoint: { EDIT_YAML: 'edit-yaml' } }));
43
+
44
+ // Mock PluginRoutes
45
+ jest.mock('@shell/core/plugin-routes', () => {
46
+ return { PluginRoutes: jest.fn().mockImplementation(() => ({ addRoutes: jest.fn() })) };
47
+ });
48
+
49
+ describe('extension Manager', () => {
50
+ let mockStore;
51
+ let mockApp;
52
+ let context;
53
+ let manager;
54
+
55
+ beforeEach(() => {
56
+ jest.clearAllMocks();
57
+
58
+ // Setup Mock Context
59
+ mockStore = {
60
+ getters: { 'i18n/t': jest.fn() },
61
+ dispatch: jest.fn(),
62
+ commit: jest.fn(),
63
+ };
64
+
65
+ mockApp = { router: {} };
66
+
67
+ context = {
68
+ app: mockApp,
69
+ store: mockStore,
70
+ $axios: {},
71
+ redirect: jest.fn(),
72
+ };
73
+
74
+ // Clean up DOM from previous tests
75
+ document.head.innerHTML = '';
76
+ document.body.innerHTML = '';
77
+
78
+ // Create a fresh extension manager for each test
79
+ manager = createExtensionManager(context);
80
+ });
81
+
82
+ describe('factory Pattern', () => {
83
+ it('creates independent manager instances', () => {
84
+ const instance1 = createExtensionManager(context);
85
+ const instance2 = createExtensionManager(context);
86
+
87
+ expect(instance1).toBeDefined();
88
+ expect(instance2).toBeDefined();
89
+ expect(instance1).not.toBe(instance2);
90
+ });
91
+
92
+ it('provides access to internal context', () => {
93
+ const internal = manager.internal();
94
+
95
+ expect(internal).toBeDefined();
96
+ expect(internal.app).toBe(mockApp);
97
+ expect(internal.store).toBe(mockStore);
98
+ expect(internal.$axios).toBeDefined();
99
+ expect(internal.redirect).toBeDefined();
100
+ expect(internal.plugins).toBe(manager);
101
+ });
102
+ });
103
+
104
+ describe('registration (Dynamic)', () => {
105
+ it('registers and retrieves a dynamic component', () => {
106
+ const mockFn = jest.fn();
107
+
108
+ manager.register('component', 'my-component', mockFn);
109
+
110
+ const retrieved = manager.getDynamic('component', 'my-component');
111
+
112
+ expect(retrieved).toBe(mockFn);
113
+ });
114
+
115
+ it('unregisters a dynamic component', () => {
116
+ const mockFn = jest.fn();
117
+
118
+ manager.register('component', 'my-component', mockFn);
119
+ manager.unregister('component', 'my-component');
120
+
121
+ const retrieved = manager.getDynamic('component', 'my-component');
122
+
123
+ expect(retrieved).toBeUndefined();
124
+ });
125
+
126
+ it('accumulates l10n resources', () => {
127
+ const mockFn1 = jest.fn();
128
+ const mockFn2 = jest.fn();
129
+
130
+ manager.register('l10n', 'en-us', mockFn1);
131
+ manager.register('l10n', 'en-us', mockFn2);
132
+
133
+ const retrieved = manager.getDynamic('l10n', 'en-us');
134
+
135
+ expect(Array.isArray(retrieved)).toBe(true);
136
+ expect(retrieved).toHaveLength(2);
137
+ expect(retrieved).toContain(mockFn1);
138
+ expect(retrieved).toContain(mockFn2);
139
+ });
140
+
141
+ it('lists dynamic registrations by type', () => {
142
+ manager.register('component', 'comp1', jest.fn());
143
+ manager.register('component', 'comp2', jest.fn());
144
+ manager.register('edit', 'edit1', jest.fn());
145
+
146
+ const components = manager.listDynamic('component');
147
+ const edits = manager.listDynamic('edit');
148
+
149
+ expect(components).toHaveLength(2);
150
+ expect(components).toContain('comp1');
151
+ expect(components).toContain('comp2');
152
+ expect(edits).toHaveLength(1);
153
+ expect(edits).toContain('edit1');
154
+ });
155
+ });
156
+
157
+ describe('loadPluginAsync (URL Generation)', () => {
158
+ beforeEach(() => {
159
+ // Mock the internal loadAsync so we only test URL generation here
160
+ jest.spyOn(manager, 'loadAsync').mockImplementation().mockResolvedValue();
161
+ });
162
+
163
+ it('generates correct URL for standard plugin', async() => {
164
+ const pluginData = { name: 'elemental', version: '1.0.0' };
165
+ const expectedId = 'elemental-1.0.0';
166
+ const expectedUrl = `/api/v1/uiplugins/elemental/1.0.0/plugin/elemental-1.0.0.umd.min.js`;
167
+
168
+ await manager.loadPluginAsync(pluginData);
169
+
170
+ expect(manager.loadAsync).toHaveBeenCalledWith(expectedId, expectedUrl);
171
+ });
172
+
173
+ it('handles custom main file from metadata', async() => {
174
+ const pluginData = {
175
+ name: 'custom-plugin',
176
+ version: '2.0.0',
177
+ metadata: { main: 'custom.js' }
178
+ };
179
+ const expectedUrl = `/api/v1/uiplugins/custom-plugin/2.0.0/plugin/custom.js`;
180
+
181
+ await manager.loadPluginAsync(pluginData);
182
+
183
+ expect(manager.loadAsync).toHaveBeenCalledWith('custom-plugin-2.0.0', expectedUrl);
184
+ });
185
+
186
+ it('handles "direct" metadata plugins', async() => {
187
+ const pluginData = {
188
+ name: 'direct-plugin',
189
+ version: '1.0.0',
190
+ endpoint: 'http://localhost:8000/plugin.js',
191
+ metadata: { direct: 'true' }
192
+ };
193
+
194
+ await manager.loadPluginAsync(pluginData);
195
+
196
+ expect(manager.loadAsync).toHaveBeenCalledWith('direct-plugin-1.0.0', 'http://localhost:8000/plugin.js');
197
+ });
198
+
199
+ it('removes developer suffix from ID', async() => {
200
+ const pluginData = {
201
+ name: `my-plugin${ DEVELOPER_LOAD_NAME_SUFFIX }`,
202
+ version: '1.0.0'
203
+ };
204
+
205
+ await manager.loadPluginAsync(pluginData);
206
+
207
+ const expectedIdWithoutSuffix = 'my-plugin-1.0.0';
208
+
209
+ expect(manager.loadAsync).toHaveBeenCalledWith(
210
+ expectedIdWithoutSuffix,
211
+ expect.any(String)
212
+ );
213
+ });
214
+ });
215
+
216
+ describe('loadAsync (Script Injection)', () => {
217
+ it('resolves immediately if element already exists', async() => {
218
+ const id = 'existing-plugin';
219
+ const script = document.createElement('script');
220
+
221
+ script.id = id;
222
+ document.body.appendChild(script);
223
+
224
+ await expect(manager.loadAsync(id, 'url.js')).resolves.toBeUndefined();
225
+
226
+ document.body.removeChild(script);
227
+ });
228
+
229
+ it('injects script tag and initializes plugin on load', async() => {
230
+ const pluginId = 'test-plugin';
231
+ const pluginUrl = 'http://test.com/plugin.js';
232
+
233
+ // Mock the window object to simulate the plugin loading into global scope
234
+ const mockPluginInit = jest.fn();
235
+
236
+ window[pluginId] = { default: mockPluginInit };
237
+
238
+ // Start the load
239
+ const loadPromise = manager.loadAsync(pluginId, pluginUrl);
240
+
241
+ // Find the injected script tag in the DOM
242
+ const script = document.head.querySelector(`script[id="${ pluginId }"]`);
243
+
244
+ expect(script).toBeTruthy();
245
+ expect(script.src).toBe(pluginUrl);
246
+ expect(script.dataset.purpose).toBe('extension');
247
+
248
+ // Manually trigger the onload event
249
+ script.onload();
250
+
251
+ // Await the promise
252
+ await loadPromise;
253
+
254
+ // Assertions
255
+ expect(mockPluginInit).toHaveBeenCalledWith(
256
+ expect.objectContaining({ id: pluginId }),
257
+ expect.objectContaining({
258
+ app: mockApp,
259
+ store: mockStore,
260
+ $axios: {},
261
+ redirect: expect.any(Function),
262
+ plugins: manager
263
+ })
264
+ );
265
+ expect(mockStore.dispatch).toHaveBeenCalledWith('uiplugins/addPlugin', expect.objectContaining({ id: pluginId }));
266
+
267
+ // Cleanup
268
+ delete window[pluginId];
269
+ });
270
+
271
+ it('rejects if plugin code is not available', async() => {
272
+ const pluginId = 'missing-plugin';
273
+ const loadPromise = manager.loadAsync(pluginId, 'test.js');
274
+
275
+ const script = document.head.querySelector(`script[id="${ pluginId }"]`);
276
+
277
+ // Don't set window[pluginId], simulate missing plugin
278
+ script.onload();
279
+
280
+ await expect(loadPromise).rejects.toThrow('Could not load plugin code');
281
+ });
282
+
283
+ it('rejects if plugin initialization fails', async() => {
284
+ const pluginId = 'error-plugin';
285
+ const mockPluginInit = jest.fn().mockImplementation(() => {
286
+ throw new Error('Init error');
287
+ });
288
+
289
+ window[pluginId] = { default: mockPluginInit };
290
+
291
+ const loadPromise = manager.loadAsync(pluginId, 'test.js');
292
+
293
+ const script = document.head.querySelector(`script[id="${ pluginId }"]`);
294
+
295
+ script.onload();
296
+
297
+ await expect(loadPromise).rejects.toThrow('Could not initialize plugin');
298
+
299
+ delete window[pluginId];
300
+ });
301
+
302
+ it('rejects if script load fails', async() => {
303
+ const pluginId = 'fail-plugin';
304
+ const loadPromise = manager.loadAsync(pluginId, 'bad-url.js');
305
+
306
+ const script = document.head.querySelector(`script[id="${ pluginId }"]`);
307
+
308
+ // Trigger error
309
+ script.onerror({ target: { src: 'bad-url.js' } });
310
+
311
+ await expect(loadPromise).rejects.toThrow('Failed to load script');
312
+ });
313
+ });
314
+
315
+ describe('builtin extensions', () => {
316
+ it('registers and loads builtin extensions', () => {
317
+ const mockModule = {
318
+ default: jest.fn().mockImplementation((plugin) => {
319
+ plugin.types.models = { TestModel: jest.fn() };
320
+ })
321
+ };
322
+
323
+ manager.registerBuiltinExtension('builtin-test', mockModule);
324
+ manager.loadBuiltinExtensions();
325
+
326
+ expect(mockModule.default).toHaveBeenCalledWith(expect.objectContaining({ builtin: true }), expect.any(Object));
327
+ expect(mockStore.dispatch).toHaveBeenCalledWith('uiplugins/addPlugin', expect.objectContaining({
328
+ id: 'builtin-test',
329
+ builtin: true
330
+ }));
331
+ });
332
+
333
+ it('handles builtin extension that returns false', () => {
334
+ const mockModule = { default: jest.fn().mockReturnValue(false) };
335
+
336
+ manager.registerBuiltinExtension('skip-test', mockModule);
337
+ manager.loadBuiltinExtensions();
338
+
339
+ expect(mockModule.default).toHaveBeenCalledWith(expect.objectContaining({ builtin: true }), expect.any(Object));
340
+ expect(mockStore.dispatch).not.toHaveBeenCalledWith('uiplugins/addPlugin', expect.anything());
341
+ });
342
+
343
+ it('handles errors in builtin extension initialization', () => {
344
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
345
+ const mockModule = {
346
+ default: jest.fn().mockImplementation(() => {
347
+ throw new Error('Init failed');
348
+ })
349
+ };
350
+
351
+ manager.registerBuiltinExtension('error-builtin', mockModule);
352
+ manager.loadBuiltinExtensions();
353
+
354
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.objectContaining({}));
355
+ expect(mockStore.dispatch).not.toHaveBeenCalledWith('uiplugins/addPlugin', expect.anything());
356
+
357
+ consoleErrorSpy.mockRestore();
358
+ });
359
+ });
360
+
361
+ describe('getValidator', () => {
362
+ it('retrieves registered validator', () => {
363
+ const mockValidator = jest.fn();
364
+
365
+ manager.register('validator', 'test-validator', mockValidator);
366
+
367
+ // Validators are stored separately, simulate applyPlugin behavior
368
+ const plugin = {
369
+ types: {},
370
+ uiConfig: {},
371
+ l10n: {},
372
+ modelExtensions: {},
373
+ stores: [],
374
+ locales: [],
375
+ routes: [],
376
+ validators: { 'test-validator': mockValidator },
377
+ productNames: [],
378
+ };
379
+
380
+ manager.applyPlugin(plugin);
381
+
382
+ const validator = manager.getValidator('test-validator');
383
+
384
+ expect(validator).toBe(mockValidator);
385
+ });
386
+ });
387
+
388
+ describe('lastLoad timestamp', () => {
389
+ it('updates lastLoad after plugin load', async() => {
390
+ const initialLastLoad = manager.lastLoad;
391
+ const pluginId = 'timestamp-test';
392
+
393
+ window[pluginId] = { default: jest.fn() };
394
+
395
+ const loadPromise = manager.loadAsync(pluginId, 'test.js');
396
+ const script = document.head.querySelector(`script[id="${ pluginId }"]`);
397
+
398
+ script.onload();
399
+ await loadPromise;
400
+
401
+ expect(manager.lastLoad).toBeGreaterThan(initialLastLoad);
402
+
403
+ delete window[pluginId];
404
+ });
405
+ });
406
+
407
+ describe('getUIConfig', () => {
408
+ it('returns UI config for specific type and area', () => {
409
+ const mockAction = { label: 'Test Action' };
410
+ const plugin = {
411
+ types: {},
412
+ uiConfig: { 'edit-yaml': { header: [mockAction] } },
413
+ l10n: {},
414
+ modelExtensions: {},
415
+ stores: [],
416
+ locales: [],
417
+ routes: [],
418
+ validators: {},
419
+ productNames: [],
420
+ };
421
+
422
+ manager.applyPlugin(plugin);
423
+
424
+ const config = manager.getUIConfig('edit-yaml', 'header');
425
+
426
+ expect(config).toHaveLength(1);
427
+ expect(config[0]).toBe(mockAction);
428
+ });
429
+
430
+ it('returns empty array for non-existent area in valid type', () => {
431
+ // Use a valid ExtensionPoint type that exists in uiConfig
432
+ const config = manager.getUIConfig('edit-yaml', 'non-existent-area');
433
+
434
+ expect(config).toStrictEqual([]);
435
+ });
436
+ });
437
+ });
@@ -8,9 +8,7 @@ import { addLinkInterceptor, removeLinkInterceptor } from '@shell/plugins/clean-
8
8
 
9
9
  export const DEVELOPER_LOAD_NAME_SUFFIX = '-developer-load';
10
10
 
11
- let extensionManagerInstance;
12
-
13
- const createExtensionManager = (context) => {
11
+ export const createExtensionManager = (context) => {
14
12
  const {
15
13
  app, store, $axios, redirect
16
14
  } = context;
@@ -35,13 +33,13 @@ const createExtensionManager = (context) => {
35
33
  /**
36
34
  * When an extension adds a model extension, it provides the class - we will instantiate that class and store and use that
37
35
  */
38
- function instantiateModelExtension($plugin, clz) {
36
+ function instantiateModelExtension($extension, clz) {
39
37
  const context = {
40
- dispatch: store.dispatch,
41
- getters: store.getters,
42
- t: store.getters['i18n/t'],
38
+ dispatch: store.dispatch,
39
+ getters: store.getters,
40
+ t: store.getters['i18n/t'],
43
41
  $axios,
44
- $extension: $plugin,
42
+ $extension,
45
43
  };
46
44
 
47
45
  return new clz(context);
@@ -514,22 +512,3 @@ const createExtensionManager = (context) => {
514
512
  },
515
513
  };
516
514
  };
517
-
518
- /**
519
- * Initializes a new extension manager if one does not exist.
520
- * @param {*} context The Rancher Dashboard context object
521
- * @returns The extension manager instance
522
- */
523
- export const initExtensionManager = (context) => {
524
- if (!extensionManagerInstance) {
525
- extensionManagerInstance = createExtensionManager(context);
526
- }
527
-
528
- return extensionManagerInstance;
529
- };
530
-
531
- /**
532
- * Gets the extension manager instance.
533
- * @returns The extension manager instance
534
- */
535
- export const getExtensionManager = () => extensionManagerInstance;
@@ -147,8 +147,8 @@ export function getApplicableExtensionEnhancements<T>(
147
147
  const extensionEnhancements: T[] = [];
148
148
 
149
149
  // gate it so that we prevent errors on older versions of dashboard
150
- if (pluginCtx.$plugin?.getUIConfig) {
151
- const actions = pluginCtx.$plugin.getUIConfig(actionType, uiArea);
150
+ if (pluginCtx.$extension?.getUIConfig) {
151
+ const actions = pluginCtx.$extension.getUIConfig(actionType, uiArea);
152
152
 
153
153
  actions.forEach((action: any, i: number) => {
154
154
  if (checkExtensionRouteBinding(currRoute, action.locationConfig, context || {})) {
package/core/plugin.ts CHANGED
@@ -18,7 +18,8 @@ import {
18
18
  NavHooks, OnNavToPackage, OnNavAwayFromPackage, OnLogIn, OnLogOut,
19
19
  PaginationTableColumn,
20
20
  ExtensionEnvironment,
21
- ServerSidePaginationExtensionConfig
21
+ ServerSidePaginationExtensionConfig,
22
+ TableAction
22
23
  } from './types';
23
24
  import coreStore, { coreStoreModule, coreStoreState } from '@shell/plugins/dashboard-store';
24
25
  import { defineAsyncComponent, markRaw, Component } from 'vue';
@@ -272,6 +273,13 @@ export class Plugin implements IPlugin {
272
273
  });
273
274
  }
274
275
 
276
+ /**
277
+ * Adds an action/button to the UI
278
+ */
279
+ addTableHook(where: string, when: LocationConfig | string, action: TableAction): void {
280
+ this._addUIConfig(ExtensionPoint.TABLE, where, when, action);
281
+ }
282
+
275
283
  setHomePage(component: any) {
276
284
  this.addRoute({
277
285
  name: 'home',
@@ -13,10 +13,10 @@ export default function({
13
13
  store,
14
14
  $axios,
15
15
  redirect,
16
- $plugin,
16
+ $extension,
17
17
  }, inject) {
18
18
  if (dynamicLoader) {
19
- dynamicLoader.default($plugin);
19
+ dynamicLoader.default($extension);
20
20
  }
21
21
 
22
22
  // The libraries we build have Vue externalised, so we need to expose Vue as a global for
@@ -60,6 +60,10 @@ export interface ClusterProvisionerContext {
60
60
  * Used to make http requests
61
61
  */
62
62
  axios: any,
63
+ /**
64
+ * [Deprecated] Definition of the extension
65
+ */
66
+ $plugin: any,
63
67
  /**
64
68
  * Definition of the extension
65
69
  */
package/core/types.ts CHANGED
@@ -60,6 +60,7 @@ export enum ExtensionPoint {
60
60
  PANEL = 'Panel', // eslint-disable-line no-unused-vars
61
61
  CARD = 'Card', // eslint-disable-line no-unused-vars
62
62
  TABLE_COL = 'TableColumn', // eslint-disable-line no-unused-vars
63
+ TABLE = 'Table', // eslint-disable-line no-unused-vars
63
64
  }
64
65
 
65
66
  /** Enum regarding action locations that are extensible in the UI */
@@ -79,6 +80,11 @@ export enum PanelLocation {
79
80
  /** Enum regarding tab locations that are extensible in the UI */
80
81
  export enum TabLocation {
81
82
  RESOURCE_DETAIL = 'tab', // eslint-disable-line no-unused-vars
83
+ OTHER = 'other-tab-locations', // eslint-disable-line no-unused-vars
84
+ RESOURCE_DETAIL_PAGE = 'resource-detail-page', // eslint-disable-line no-unused-vars
85
+ RESOURCE_CREATE_PAGE = 'resource-create-page', // eslint-disable-line no-unused-vars
86
+ RESOURCE_EDIT_PAGE = 'resource-edit-page', // eslint-disable-line no-unused-vars
87
+ RESOURCE_SHOW_CONFIGURATION = 'resource-show-configuration', // eslint-disable-line no-unused-vars
82
88
  CLUSTER_CREATE_RKE2 = 'cluster-create-rke2', // eslint-disable-line no-unused-vars
83
89
  }
84
90
 
@@ -92,6 +98,16 @@ export enum TableColumnLocation {
92
98
  RESOURCE = 'resource-list', // eslint-disable-line no-unused-vars
93
99
  }
94
100
 
101
+ /** Enum regarding table locations that are extensible in the UI */
102
+ export enum TableLocation {
103
+ RESOURCE = 'resource-list', // eslint-disable-line no-unused-vars
104
+ }
105
+
106
+ /** Definition of a Table extension hook */
107
+ export type TableAction = {
108
+ tableHook: Function
109
+ };
110
+
95
111
  /** Definition of the shortcut object (keyboard shortcuts) */
96
112
  export type ShortCutKey = {
97
113
  windows?: string[];
@@ -234,6 +250,11 @@ export interface ProductOptions {
234
250
  */
235
251
  ifHaveType?: string | RegExp;
236
252
 
253
+ /**
254
+ * Hide the product if the type is present (opposite of ifHaveType)
255
+ */
256
+ ifNotHaveType?: string | RegExp;
257
+
237
258
  /**
238
259
  * The vuex store that this product should use by default i.e. 'management'
239
260
  */
@@ -283,6 +304,11 @@ export interface ProductOptions {
283
304
  * Configuration required to show a header in a ResourceTable
284
305
  */
285
306
  export interface HeaderOptions {
307
+ /**
308
+ * Order/position of the table column added by an extension
309
+ */
310
+ weight?: number;
311
+
286
312
  /**
287
313
  * Name of the header. This should be unique.
288
314
  */
@@ -654,6 +680,15 @@ export interface IPlugin {
654
680
  */
655
681
  addTableColumn(where: TableColumnLocation | string, when: LocationConfig | string, column: TableColumn, paginationColumn?: TableColumn): void;
656
682
 
683
+ /**
684
+ * Adds to Table events hook on ResourceTable
685
+ *
686
+ * @param where
687
+ * @param when
688
+ * @param action
689
+ */
690
+ addTableHook(where: TableLocation | string, when: LocationConfig | string, action: TableAction): void;
691
+
657
692
  /**
658
693
  * Set the component to use for the landing home page
659
694
  * @param component Home page component
@@ -96,15 +96,15 @@ export default {
96
96
  await this.value.waitForProvisioner();
97
97
 
98
98
  // Support for the 'provisioner' extension
99
- const extClass = this.$plugin.getDynamic('provisioner', this.value.machineProvider);
99
+ const extClass = this.$extension.getDynamic('provisioner', this.value.machineProvider);
100
100
 
101
101
  if (extClass) {
102
102
  this.extProvider = new extClass({
103
- dispatch: this.$store.dispatch,
104
- getters: this.$store.getters,
105
- axios: this.$store.$axios,
106
- $plugin: this.$store.app.$plugin,
107
- $t: this.t
103
+ dispatch: this.$store.dispatch,
104
+ getters: this.$store.getters,
105
+ axios: this.$store.$axios,
106
+ $extension: this.$store.app.$extension,
107
+ $t: this.t
108
108
  });
109
109
 
110
110
  this.extDetailTabs = {
@@ -893,6 +893,7 @@ export default {
893
893
  :disabled="!group.ref.canScaleDownPool()"
894
894
  type="button"
895
895
  class="btn btn-sm role-secondary"
896
+ data-testid="scale-down-button"
896
897
  @click="toggleScaleDownModal($event, group.ref)"
897
898
  >
898
899
  <i class="icon icon-sm icon-minus" />
@@ -902,6 +903,7 @@ export default {
902
903
  :disabled="!group.ref.canScaleUpPool()"
903
904
  type="button"
904
905
  class="btn btn-sm role-secondary ml-10"
906
+ data-testid="scale-up-button"
905
907
  @click="group.ref.scalePool(1)"
906
908
  >
907
909
  <i class="icon icon-sm icon-plus" />
@@ -145,7 +145,7 @@ export default {
145
145
  }
146
146
  }
147
147
 
148
- this.$plugin.loadAsync(name, url).then(() => {
148
+ this.$extension.loadAsync(name, url).then(() => {
149
149
  this.closeDialog(true);
150
150
 
151
151
  this.$store.dispatch('growl/success', {