@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.
- package/apis/impl/apis.ts +61 -0
- package/apis/index.ts +40 -0
- package/apis/intf/modal.ts +90 -0
- package/apis/intf/shell.ts +36 -0
- package/apis/intf/slide-in.ts +98 -0
- package/apis/intf/system.ts +41 -0
- package/apis/shell/__tests__/modal.test.ts +80 -0
- package/apis/shell/__tests__/notifications.test.ts +71 -0
- package/apis/shell/__tests__/slide-in.test.ts +54 -0
- package/apis/shell/__tests__/system.test.ts +129 -0
- package/apis/shell/index.ts +38 -0
- package/apis/shell/modal.ts +41 -0
- package/apis/shell/notifications.ts +65 -0
- package/apis/shell/slide-in.ts +33 -0
- package/apis/shell/system.ts +65 -0
- package/apis/vue-shim.d.ts +11 -0
- package/assets/styles/global/_tooltip.scss +6 -1
- package/assets/translations/en-us.yaml +5 -0
- package/components/ActionMenuShell.vue +3 -1
- package/components/CruResource.vue +8 -1
- package/components/Drawer/ResourceDetailDrawer/__tests__/composables.test.ts +50 -1
- package/components/Drawer/ResourceDetailDrawer/composables.ts +19 -0
- package/components/Drawer/ResourceDetailDrawer/index.vue +3 -1
- package/components/LocaleSelector.vue +2 -2
- package/components/ModalManager.vue +11 -1
- package/components/Questions/__tests__/Yaml.test.ts +1 -1
- package/components/RelatedResources.vue +5 -0
- package/components/Resource/Detail/ResourcePopover/index.vue +5 -1
- package/components/ResourceDetail/Masthead/latest.vue +23 -21
- package/components/ResourceDetail/index.vue +3 -0
- package/components/ResourceTable.vue +54 -21
- package/components/SlideInPanelManager.vue +16 -11
- package/components/SortableTable/THead.vue +2 -1
- package/components/SortableTable/index.vue +20 -2
- package/components/Tabbed/index.vue +37 -2
- package/components/__tests__/NamespaceFilter.test.ts +49 -0
- package/components/auth/SelectPrincipal.vue +4 -0
- package/components/auth/login/ldap.vue +3 -3
- package/components/fleet/FleetSecretSelector.vue +1 -1
- package/components/form/KeyValue.vue +1 -1
- package/components/form/NameNsDescription.vue +1 -1
- package/components/form/NodeScheduling.vue +2 -2
- package/components/form/ResourceTabs/composable.ts +2 -2
- package/components/form/ResourceTabs/index.vue +0 -2
- package/components/form/__tests__/NameNsDescription.test.ts +42 -0
- package/components/formatter/LinkName.vue +5 -0
- package/components/nav/Group.vue +25 -7
- package/components/nav/Header.vue +1 -1
- package/components/nav/NamespaceFilter.vue +1 -0
- package/components/nav/Type.vue +17 -6
- package/components/nav/WindowManager/panels/TabBodyContainer.vue +1 -1
- package/components/nav/__tests__/Type.test.ts +59 -0
- package/composables/cruResource.ts +27 -0
- package/composables/focusTrap.ts +3 -1
- package/composables/resourceDetail.ts +15 -0
- package/composables/useLabeledFormElement.ts +3 -4
- package/config/product/fleet.js +1 -1
- package/config/router/navigation-guards/clusters.js +3 -3
- package/config/router/navigation-guards/products.js +1 -1
- package/config/router/routes.js +1 -5
- package/core/__tests__/extension-manager-impl.test.js +437 -0
- package/core/extension-manager-impl.js +6 -27
- package/core/plugin-helpers.ts +2 -2
- package/core/plugin.ts +9 -1
- package/core/plugins-loader.js +2 -2
- package/core/types-provisioning.ts +4 -0
- package/core/types.ts +35 -0
- package/detail/provisioning.cattle.io.cluster.vue +8 -6
- package/dialog/DeveloperLoadExtensionDialog.vue +1 -1
- package/dialog/MoveNamespaceDialog.vue +20 -4
- package/dialog/SearchDialog.vue +1 -0
- package/dialog/__tests__/MoveNamespaceDialog.test.ts +249 -0
- package/directives/__tests__/clean-tooltip.test.ts +298 -0
- package/directives/clean-tooltip.ts +234 -0
- package/edit/__tests__/fleet.cattle.io.gitrepo.test.ts +2 -2
- package/edit/__tests__/fleet.cattle.io.helmop.test.ts +98 -1
- package/edit/fleet.cattle.io.helmop.vue +5 -0
- package/edit/provisioning.cattle.io.cluster/__tests__/rke2.test.ts +21 -21
- package/edit/provisioning.cattle.io.cluster/index.vue +5 -5
- package/edit/provisioning.cattle.io.cluster/rke2.vue +8 -8
- package/edit/resources.cattle.io.restore.vue +1 -1
- package/edit/workload/Job.vue +2 -2
- package/edit/workload/index.vue +1 -1
- package/initialize/install-plugins.js +4 -5
- package/machine-config/azure.vue +1 -1
- package/machine-config/components/GCEImage.vue +1 -1
- package/models/__tests__/provisioning.cattle.io.cluster.test.ts +16 -0
- package/models/chart.js +70 -74
- package/models/management.cattle.io.cluster.js +1 -1
- package/models/provisioning.cattle.io.cluster.js +11 -3
- package/package.json +7 -7
- package/pages/auth/login.vue +3 -3
- package/pages/auth/setup.vue +1 -1
- package/pages/auth/verify.vue +3 -3
- package/pages/c/_cluster/apps/charts/index.vue +122 -24
- package/pages/c/_cluster/apps/charts/install.vue +33 -0
- package/pages/c/_cluster/explorer/__tests__/index.test.ts +1 -1
- package/pages/c/_cluster/fleet/index.vue +4 -7
- package/pages/c/_cluster/settings/index.vue +5 -0
- package/pkg/auto-import.js +3 -3
- package/pkg/dynamic-importer.lib.js +1 -1
- package/pkg/import.js +1 -1
- package/plugins/__tests__/mutations.tests.ts +179 -0
- package/plugins/dashboard-store/getters.js +1 -1
- package/plugins/dashboard-store/model-loader.js +1 -1
- package/plugins/dashboard-store/mutations.js +23 -2
- package/plugins/dashboard-store/resource-class.js +8 -3
- package/plugins/plugin.js +2 -2
- package/plugins/steve/__tests__/steve-pagination-utils.test.ts +301 -128
- package/plugins/steve/steve-class.js +1 -1
- package/plugins/steve/steve-pagination-utils.ts +108 -43
- package/rancher-components/Form/Checkbox/Checkbox.vue +1 -1
- package/rancher-components/Form/LabeledInput/LabeledInput.vue +1 -1
- package/rancher-components/RcDropdown/useDropdownContext.ts +2 -4
- package/rancher-components/RcItemCard/RcItemCard.vue +1 -1
- package/scripts/publish-shell.sh +25 -0
- package/store/__tests__/catalog.test.ts +1 -1
- package/store/__tests__/type-map.test.ts +164 -2
- package/store/auth.js +23 -11
- package/store/i18n.js +3 -3
- package/store/index.js +5 -3
- package/store/notifications.ts +2 -0
- package/store/prefs.js +2 -2
- package/store/type-map.js +17 -7
- package/types/internal-api/shell/modal.d.ts +6 -6
- package/types/notifications/index.ts +126 -15
- package/types/rancher/index.d.ts +9 -0
- package/types/shell/index.d.ts +16 -1
- package/types/vue-shim.d.ts +5 -4
- package/utils/__tests__/router.test.js +238 -0
- package/utils/cluster.js +4 -1
- package/utils/fleet.ts +8 -1
- package/utils/pagination-utils.ts +2 -2
- package/utils/pagination-wrapper.ts +1 -1
- package/utils/router.js +50 -0
- package/utils/unit-tests/pagination-utils.spec.ts +8 -8
- package/vue.config.js +3 -3
- package/composables/useExtensionManager.ts +0 -17
- package/core/__test__/extension-manager-impl.test.js +0 -236
- package/core/plugins.js +0 -38
- package/directives/clean-tooltip.js +0 -32
- package/plugins/internal-api/index.ts +0 -37
- package/plugins/internal-api/shared/base-api.ts +0 -13
- package/plugins/internal-api/shell/shell.api.ts +0 -108
- package/types/internal-api/shell/growl.d.ts +0 -25
- 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
|
-
|
|
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($
|
|
36
|
+
function instantiateModelExtension($extension, clz) {
|
|
39
37
|
const context = {
|
|
40
|
-
dispatch:
|
|
41
|
-
getters:
|
|
42
|
-
t:
|
|
38
|
+
dispatch: store.dispatch,
|
|
39
|
+
getters: store.getters,
|
|
40
|
+
t: store.getters['i18n/t'],
|
|
43
41
|
$axios,
|
|
44
|
-
$extension
|
|
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;
|
package/core/plugin-helpers.ts
CHANGED
|
@@ -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.$
|
|
151
|
-
const actions = pluginCtx.$
|
|
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',
|
package/core/plugins-loader.js
CHANGED
|
@@ -13,10 +13,10 @@ export default function({
|
|
|
13
13
|
store,
|
|
14
14
|
$axios,
|
|
15
15
|
redirect,
|
|
16
|
-
$
|
|
16
|
+
$extension,
|
|
17
17
|
}, inject) {
|
|
18
18
|
if (dynamicLoader) {
|
|
19
|
-
dynamicLoader.default($
|
|
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
|
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.$
|
|
99
|
+
const extClass = this.$extension.getDynamic('provisioner', this.value.machineProvider);
|
|
100
100
|
|
|
101
101
|
if (extClass) {
|
|
102
102
|
this.extProvider = new extClass({
|
|
103
|
-
dispatch:
|
|
104
|
-
getters:
|
|
105
|
-
axios:
|
|
106
|
-
$
|
|
107
|
-
$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" />
|