@rancher/shell 3.0.12-rc.5 → 3.0.12-rc.7
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/intf/resources-api/resource-base.ts +42 -2
- package/apis/intf/resources-api/resource-instance.ts +101 -0
- package/apis/intf/resources-api/resources-api.ts +177 -23
- package/apis/intf/resources.ts +8 -6
- package/apis/intf/shell-api/slide-in.ts +46 -17
- package/apis/resources/__tests__/resources-api-class.test.ts +309 -28
- package/apis/resources/resources-api-class.ts +232 -43
- package/apis/shell/__tests__/slide-in.test.ts +83 -2
- package/apis/shell/slide-in.ts +20 -0
- package/assets/images/providers/traefik.png +0 -0
- package/assets/translations/en-us.yaml +10 -4
- package/chart/__tests__/rancher-monitoring-dashboards-index.test.ts +269 -0
- package/chart/rancher-monitoring-dashboards/index.vue +155 -0
- package/cloud-credential/__tests__/generic.test.ts +132 -0
- package/cloud-credential/generic.vue +33 -3
- package/components/EtcdInfoBanner.vue +2 -12
- package/components/ExplorerProjectsNamespaces.vue +2 -2
- package/components/GrafanaDashboard.vue +2 -8
- package/components/Resource/Detail/Masthead/__tests__/index.test.ts +68 -14
- package/components/Resource/Detail/Masthead/index.vue +8 -3
- package/components/Resource/Detail/Metadata/IdentifyingInformation/__tests__/identifying-fields.test.ts +9 -0
- package/components/Resource/Detail/Metadata/IdentifyingInformation/identifying-fields.ts +5 -1
- package/components/SlideInPanelManager.vue +103 -25
- package/components/__tests__/SlideInPanelManager.spec.ts +153 -10
- package/components/auth/AuthBanner.vue +1 -1
- package/components/form/Security.vue +1 -1
- package/components/formatter/Translate.vue +22 -1
- package/components/nav/Group.vue +5 -10
- package/components/nav/TopLevelMenu.vue +1 -30
- package/components/nav/__tests__/Group.test.ts +61 -0
- package/config/labels-annotations.js +1 -0
- package/config/table-headers.js +5 -2
- package/core/__tests__/plugin-products-apply.test.ts +787 -0
- package/core/__tests__/plugin-products-extend.test.ts +189 -0
- package/core/__tests__/plugin-products-helpers.test.ts +81 -78
- package/core/__tests__/plugin-products-new.test.ts +389 -0
- package/core/__tests__/plugin-products-scenarios.test.ts +980 -0
- package/core/__tests__/plugin-products-side-menu.test.ts +1935 -0
- package/core/plugin-products-base.ts +201 -93
- package/core/plugin-products-extending.ts +1 -1
- package/core/plugin-products-external.ts +380 -0
- package/core/plugin-products-helpers.ts +28 -24
- package/core/plugin-products-internal.ts +207 -0
- package/core/plugin-products-top-level.ts +10 -5
- package/core/plugin-products-type-guards.ts +17 -6
- package/core/plugin-products.ts +5 -7
- package/core/plugin-types.ts +1 -389
- package/core/plugin.ts +10 -11
- package/core/plugins-loader.js +2 -0
- package/core/types.ts +6 -202
- package/detail/__tests__/provisioning.cattle.io.cluster.test.ts +52 -0
- package/detail/pod.vue +1 -1
- package/detail/provisioning.cattle.io.cluster.vue +0 -2
- package/edit/auth/AuthProviderWarningBanners.vue +12 -0
- package/edit/auth/__tests__/AuthProviderWarningBanners.test.ts +10 -1
- package/edit/auth/__tests__/azuread.test.ts +1 -0
- package/edit/auth/__tests__/github.test.ts +1 -0
- package/edit/auth/__tests__/oidc.test.ts +1 -0
- package/edit/auth/__tests__/saml.test.ts +1 -0
- package/edit/auth/ldap/__tests__/index.test.ts +1 -0
- package/edit/provisioning.cattle.io.cluster/index.vue +1 -1
- package/edit/provisioning.cattle.io.cluster/rke2.vue +1 -1
- package/edit/provisioning.cattle.io.cluster/tabs/networking/index.vue +2 -2
- package/list/management.cattle.io.user.vue +24 -5
- package/list/utils/management.cattle.io.cluster.utils.ts +1 -1
- package/machine-config/__tests__/generic.test.ts +163 -0
- package/machine-config/components/EC2Networking.vue +31 -7
- package/machine-config/components/__tests__/EC2Networking.test.ts +38 -15
- package/machine-config/generic.vue +32 -5
- package/mixins/resource-fetch.js +1 -1
- package/models/__tests__/namespace.test.ts +133 -8
- package/models/__tests__/provisioning.cattle.io.cluster.test.ts +57 -1
- package/models/__tests__/rke-machine.cattle.io.vmwarevspheremachinetemplate.test.ts +22 -0
- package/models/namespace.js +34 -2
- package/models/pod.js +12 -10
- package/models/provisioning.cattle.io.cluster.js +20 -13
- package/models/rke-machine.cattle.io.vmwarevspheremachinetemplate.js +9 -0
- package/package.json +1 -1
- package/pages/auth/login.vue +1 -1
- package/pages/c/_cluster/apps/charts/__tests__/install.test.ts +110 -0
- package/pages/c/_cluster/apps/charts/install.vue +29 -0
- package/pages/c/_cluster/auth/config/index.vue +11 -4
- package/pages/c/_cluster/explorer/workload-dashboard/__tests__/composable.test.ts +136 -1
- package/pages/c/_cluster/explorer/workload-dashboard/composable.ts +69 -1
- package/pages/c/_cluster/fleet/index.vue +5 -9
- package/pages/c/_cluster/monitoring/index.vue +6 -1
- package/pkg/vue.config.js +4 -3
- package/plugins/codemirror.js +2 -0
- package/plugins/dashboard-store/resource-class.js +13 -9
- package/plugins/steve/__tests__/steve-class-resource-api.test.ts +147 -0
- package/plugins/steve/steve-class.js +43 -0
- package/plugins/steve/subscribe.js +11 -0
- package/rancher-components/Form/LabeledInput/LabeledInput.test.ts +115 -0
- package/rancher-components/Form/LabeledInput/LabeledInput.vue +83 -3
- package/store/__tests__/action-menu.test.ts +622 -0
- package/store/__tests__/auth.test.ts +18 -0
- package/store/__tests__/notifications.test.ts +466 -1
- package/store/__tests__/slideInPanel.test.ts +143 -43
- package/store/__tests__/ui-context.test.ts +255 -0
- package/store/__tests__/wm.test.ts +503 -0
- package/store/auth.js +11 -1
- package/store/aws.js +19 -4
- package/store/features.js +1 -0
- package/store/plugins.js +6 -0
- package/store/slideInPanel.ts +15 -3
- package/types/rancher/steve.api.ts +2 -2
- package/types/shell/index.d.ts +84 -2
- package/types/store/dashboard-store.types.ts +2 -2
- package/types/store/type-map.ts +262 -1
- package/utils/__tests__/fleet-appco.test.ts +23 -0
- package/utils/async.ts +2 -0
- package/utils/fleet-appco.ts +6 -1
- package/utils/grafana.js +2 -17
- package/utils/monitoring.js +38 -1
- package/utils/sort.js +1 -1
- package/core/__tests__/plugin-products.test.ts +0 -4694
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
} from 'vue';
|
|
4
4
|
import { useStore } from 'vuex';
|
|
5
5
|
import { useRouter, type RouteLocationRaw } from 'vue-router';
|
|
6
|
-
import { NAMESPACE } from '@shell/config/types';
|
|
6
|
+
import { NAMESPACE, WORKLOAD_TYPES } from '@shell/config/types';
|
|
7
7
|
import type { StateColor } from '@shell/utils/style';
|
|
8
8
|
import { useI18n } from '@shell/composables/useI18n';
|
|
9
9
|
import { useStateColor } from '@shell/composables/useStateColor';
|
|
@@ -26,6 +26,15 @@ import {
|
|
|
26
26
|
type WorkloadDashboardByNamespaceCard,
|
|
27
27
|
} from './types';
|
|
28
28
|
|
|
29
|
+
const WORKLOAD_DASHBOARD_ROUTE = 'c-cluster-explorer-workload-dashboard';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Singleton (module-level) cache of cluster ids whose workload summary responses
|
|
33
|
+
* have been found to be malformed. Once a cluster is flagged we skip re-fetching
|
|
34
|
+
* and redirect straight away. Held in memory only, so it is cleared on reload.
|
|
35
|
+
*/
|
|
36
|
+
const invalidDataClusters = new Set<string>();
|
|
37
|
+
|
|
29
38
|
export function useWorkloadDashboard() {
|
|
30
39
|
const store = useStore();
|
|
31
40
|
const router = useRouter();
|
|
@@ -344,12 +353,57 @@ export function useWorkloadDashboard() {
|
|
|
344
353
|
|
|
345
354
|
// ── Fetching & polling ──
|
|
346
355
|
|
|
356
|
+
/**
|
|
357
|
+
* Validates the shape of a successfully fetched response.
|
|
358
|
+
* - An empty/absent summary is only valid when there is also no `data`
|
|
359
|
+
* (a populated `data` with no summary means we got the wrong response).
|
|
360
|
+
* - When a summary is present, every state entry under `counts` must
|
|
361
|
+
* include a `total`; a count key without `total` is invalid.
|
|
362
|
+
*/
|
|
363
|
+
function isValidResponse(res: { summary?: WorkloadDashboardSummaryEntry['summary']; data?: unknown }): boolean {
|
|
364
|
+
const summary = res?.summary;
|
|
365
|
+
|
|
366
|
+
if (!Array.isArray(summary) || summary.length === 0) {
|
|
367
|
+
// With no summary, the response is only valid when there is also no data.
|
|
368
|
+
return Array.isArray(res?.data) ? res.data.length === 0 : !res?.data;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return summary.every((item) => Object.values(item.counts || {})
|
|
372
|
+
.every((detail) => typeof detail?.total === 'number'));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function redirectToDeployment(): void {
|
|
376
|
+
// The redirect can fire from an async fetch or poll that resolves after the
|
|
377
|
+
// user has already navigated away — only redirect if still on the dashboard.
|
|
378
|
+
if (router.currentRoute.value.name !== WORKLOAD_DASHBOARD_ROUTE) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Logged to help triage "where's my workload summary page?" style questions:
|
|
383
|
+
// the summary response was malformed, so we fall back to the deployments list.
|
|
384
|
+
console.info(`Workload dashboard: unexpected summary response for cluster "${ clusterId.value }", redirecting to the deployments list.`); // eslint-disable-line no-console
|
|
385
|
+
|
|
386
|
+
stopPollTimer();
|
|
387
|
+
router.push(resourceRoute(WORKLOAD_TYPES.DEPLOYMENT));
|
|
388
|
+
}
|
|
389
|
+
|
|
347
390
|
async function fetchSummaries(): Promise<void> {
|
|
391
|
+
// If this cluster's data was already found to be malformed, skip the requests
|
|
392
|
+
// and redirect straight away (cleared on reload via the in-memory singleton).
|
|
393
|
+
if (invalidDataClusters.has(clusterId.value)) {
|
|
394
|
+
redirectToDeployment();
|
|
395
|
+
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
348
399
|
try {
|
|
349
400
|
const accessibleTypes = WORKLOAD_RESOURCE_TYPES.filter(
|
|
350
401
|
(type) => store.getters['cluster/canList'](type)
|
|
351
402
|
);
|
|
352
403
|
|
|
404
|
+
// Set when a successfully fetched response has an unexpected shape.
|
|
405
|
+
let hasInvalidResponse = false;
|
|
406
|
+
|
|
353
407
|
const workloadPromises = accessibleTypes.map(async(type): Promise<WorkloadDashboardSummaryEntry> => {
|
|
354
408
|
try {
|
|
355
409
|
let url = `${ store.getters['cluster/urlFor'](type) }`;
|
|
@@ -361,6 +415,10 @@ export function useWorkloadDashboard() {
|
|
|
361
415
|
|
|
362
416
|
const res = await store.dispatch('cluster/request', { url });
|
|
363
417
|
|
|
418
|
+
if (!hasInvalidResponse && !isValidResponse(res)) {
|
|
419
|
+
hasInvalidResponse = true;
|
|
420
|
+
}
|
|
421
|
+
|
|
364
422
|
return {
|
|
365
423
|
type, summary: res.summary || [], error: null
|
|
366
424
|
};
|
|
@@ -375,6 +433,16 @@ export function useWorkloadDashboard() {
|
|
|
375
433
|
|
|
376
434
|
const results = await Promise.all(workloadPromises);
|
|
377
435
|
|
|
436
|
+
// Only successful responses are validated (errors are caught above). If any
|
|
437
|
+
// had an unexpected shape, remember it so we skip future requests, then
|
|
438
|
+
// stop polling and redirect to the deployment list page.
|
|
439
|
+
if (hasInvalidResponse) {
|
|
440
|
+
invalidDataClusters.add(clusterId.value);
|
|
441
|
+
redirectToDeployment();
|
|
442
|
+
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
378
446
|
await resolveStateColors(results);
|
|
379
447
|
summaries.value = results;
|
|
380
448
|
fetchError.value = null;
|
|
@@ -322,19 +322,15 @@ export default {
|
|
|
322
322
|
this.selectedCard = selected;
|
|
323
323
|
|
|
324
324
|
this.$shell.slideIn.open(ResourceDetails, {
|
|
325
|
-
|
|
326
|
-
props: {
|
|
325
|
+
props: {
|
|
327
326
|
value,
|
|
328
327
|
statePanel,
|
|
329
328
|
workspace
|
|
330
329
|
},
|
|
331
|
-
width: '
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
'z-index': 101, // We want this to be above the main side menu
|
|
336
|
-
closeOnRouteChange: ['name', 'params', 'query'], // We want to ignore hash changes, tables in extensions can trigger the drawer to close while opening
|
|
337
|
-
triggerFocusTrap: false,
|
|
330
|
+
width: 'wide',
|
|
331
|
+
height: 'full',
|
|
332
|
+
closeOnRouteChange: ['name', 'params', 'query'],
|
|
333
|
+
disableFocusTrap: true,
|
|
338
334
|
});
|
|
339
335
|
},
|
|
340
336
|
|
|
@@ -106,7 +106,12 @@ export default {
|
|
|
106
106
|
const grafanaMatch = findBy(externalLinks, 'group', 'grafana');
|
|
107
107
|
// Generate Grafana link
|
|
108
108
|
const currentCluster = this.$store.getters['currentCluster'];
|
|
109
|
-
|
|
109
|
+
let rancherMonitoring = !isEmpty(res.apps) ? findBy(res.apps, 'id', 'cattle-monitoring-system/rancher-monitoring-dashboards') : '';
|
|
110
|
+
|
|
111
|
+
if (!rancherMonitoring) {
|
|
112
|
+
rancherMonitoring = !isEmpty(res.apps) ? findBy(res.apps, 'id', 'cattle-monitoring-system/rancher-monitoring') : '';
|
|
113
|
+
}
|
|
114
|
+
|
|
110
115
|
const clusterPrefix = getClusterPrefix(rancherMonitoring?.currentVersion || '', currentCluster.id);
|
|
111
116
|
|
|
112
117
|
grafanaMatch.link = `${ clusterPrefix }/api/v1/namespaces/cattle-monitoring-system/services/http:rancher-monitoring-grafana:80/proxy/`;
|
package/pkg/vue.config.js
CHANGED
|
@@ -91,9 +91,10 @@ module.exports = function(dir) {
|
|
|
91
91
|
// These modules will be externalised and not included with the build of a package library
|
|
92
92
|
// This helps reduce the package size, but these dependencies must be provided by the hosting application
|
|
93
93
|
config.externals = {
|
|
94
|
-
jquery:
|
|
95
|
-
jszip:
|
|
96
|
-
'js-yaml':
|
|
94
|
+
jquery: '$',
|
|
95
|
+
jszip: '__jszip',
|
|
96
|
+
'js-yaml': '__jsyaml',
|
|
97
|
+
'vue-router': '__vueRouter'
|
|
97
98
|
};
|
|
98
99
|
|
|
99
100
|
// Prevent warning in log with the md files in the content folder
|
package/plugins/codemirror.js
CHANGED
|
@@ -27,6 +27,8 @@ import 'codemirror/addon/hint/show-hint.css';
|
|
|
27
27
|
import 'codemirror/addon/hint/show-hint.js';
|
|
28
28
|
import 'codemirror/addon/hint/anyword-hint.js';
|
|
29
29
|
|
|
30
|
+
import 'codemirror/addon/comment/comment.js';
|
|
31
|
+
|
|
30
32
|
import { strPad } from '@shell/utils/string';
|
|
31
33
|
|
|
32
34
|
function isLineComment(cm, lineNo) {
|
|
@@ -932,12 +932,9 @@ export default class Resource {
|
|
|
932
932
|
componentProps: {
|
|
933
933
|
resource: this,
|
|
934
934
|
onClose,
|
|
935
|
-
width: '
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
top: '0',
|
|
939
|
-
'z-index': 101, // We want this to be above the main side menu
|
|
940
|
-
closeOnRouteChange: ['name', 'params', 'query'], // We want to ignore hash changes, tables in extensions can trigger the drawer to close while opening
|
|
935
|
+
width: 'wide',
|
|
936
|
+
height: 'full',
|
|
937
|
+
closeOnRouteChange: ['name', 'params', 'query'],
|
|
941
938
|
triggerFocusTrap: true,
|
|
942
939
|
returnFocusSelector,
|
|
943
940
|
defaultTab
|
|
@@ -1253,8 +1250,11 @@ export default class Resource {
|
|
|
1253
1250
|
|
|
1254
1251
|
async _save(opt = { }) {
|
|
1255
1252
|
const forNew = !this.id;
|
|
1253
|
+
let errors;
|
|
1256
1254
|
|
|
1257
|
-
|
|
1255
|
+
if (!opt.skipUIValidation) {
|
|
1256
|
+
errors = this.validationErrors(this, opt);
|
|
1257
|
+
}
|
|
1258
1258
|
|
|
1259
1259
|
if (!isEmpty(errors)) {
|
|
1260
1260
|
return Promise.reject(errors);
|
|
@@ -1294,7 +1294,9 @@ export default class Resource {
|
|
|
1294
1294
|
// @TODO remove this once the API maps steve _type <-> k8s type in both directions
|
|
1295
1295
|
// `JSON.parse(JSON.stringify` - Completely disconnect the object we're going to send and `this`. This ensures that properties
|
|
1296
1296
|
// removed from opt.data before sending (as part of cleanForSave) are not stripped from where they're still needed (`this`)
|
|
1297
|
-
opt.
|
|
1297
|
+
if (!(opt.method === 'patch')) {
|
|
1298
|
+
opt.data = this.toSave() || JSON.parse(JSON.stringify(this));
|
|
1299
|
+
}
|
|
1298
1300
|
|
|
1299
1301
|
if (opt.data._type) {
|
|
1300
1302
|
opt.data.type = opt.data._type;
|
|
@@ -1312,7 +1314,9 @@ export default class Resource {
|
|
|
1312
1314
|
opt.data.annotations = opt.data._annotations;
|
|
1313
1315
|
}
|
|
1314
1316
|
|
|
1315
|
-
|
|
1317
|
+
if (!(opt.method === 'patch')) {
|
|
1318
|
+
opt.data = this.cleanForSave(opt.data, forNew);
|
|
1319
|
+
}
|
|
1316
1320
|
|
|
1317
1321
|
// handle "replace" opt as a query param _replace=true for norman PUT requests
|
|
1318
1322
|
if (opt?.replace && opt.method === 'put') {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe, it, expect, jest, beforeEach, afterEach
|
|
3
|
+
} from '@jest/globals';
|
|
4
|
+
import Steve from '@shell/plugins/steve/steve-class.js';
|
|
5
|
+
|
|
6
|
+
describe('class: Steve — ResourceInstanceApi methods', () => {
|
|
7
|
+
let consoleErrorSpy: any;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
consoleErrorSpy.mockRestore();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function createSteveModel(overrides: Record<string, any> = {}) {
|
|
18
|
+
const dispatch = jest.fn<any, any[]>();
|
|
19
|
+
|
|
20
|
+
dispatch.mockResolvedValue(undefined);
|
|
21
|
+
|
|
22
|
+
// @ts-expect-error - JS class without TS declarations
|
|
23
|
+
const model = new Steve({
|
|
24
|
+
type: 'configmap',
|
|
25
|
+
id: 'default/my-config',
|
|
26
|
+
metadata: { name: 'my-config', namespace: 'default' },
|
|
27
|
+
links: { update: 'https://rancher/v1/configmaps/default/my-config', self: 'https://rancher/v1/configmaps/default/my-config' },
|
|
28
|
+
...overrides,
|
|
29
|
+
}, {
|
|
30
|
+
getters: {
|
|
31
|
+
schemaFor: () => ({ linkFor: jest.fn() }),
|
|
32
|
+
keyFieldForType: () => 'id',
|
|
33
|
+
},
|
|
34
|
+
dispatch,
|
|
35
|
+
rootGetters: {
|
|
36
|
+
'i18n/t': jest.fn(),
|
|
37
|
+
'type-map/optionsFor': () => ({
|
|
38
|
+
isEditable: true, isRemovable: true, isCreatable: true
|
|
39
|
+
}),
|
|
40
|
+
'type-map/hasCustomEdit': () => true,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return { model: model as any, dispatch };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('update', () => {
|
|
48
|
+
it('should send a merge-patch request and load the response into the store', async() => {
|
|
49
|
+
const patchResponse = {
|
|
50
|
+
type: 'configmap', id: 'default/my-config', kind: 'ConfigMap', data: { key: 'patched' }
|
|
51
|
+
};
|
|
52
|
+
const { model, dispatch } = createSteveModel();
|
|
53
|
+
|
|
54
|
+
dispatch
|
|
55
|
+
.mockResolvedValueOnce(patchResponse)
|
|
56
|
+
.mockResolvedValueOnce(undefined);
|
|
57
|
+
|
|
58
|
+
const result = await model.update({ data: { key: 'patched' } });
|
|
59
|
+
|
|
60
|
+
expect(result).toStrictEqual(model);
|
|
61
|
+
expect(dispatch).toHaveBeenCalledWith('request', {
|
|
62
|
+
opt: expect.objectContaining({
|
|
63
|
+
url: 'https://rancher/v1/configmaps/default/my-config',
|
|
64
|
+
method: 'patch',
|
|
65
|
+
headers: expect.objectContaining({ 'content-type': 'application/strategic-merge-patch+json' }),
|
|
66
|
+
data: { data: { key: 'patched' } },
|
|
67
|
+
}),
|
|
68
|
+
type: 'configmap'
|
|
69
|
+
});
|
|
70
|
+
expect(dispatch).toHaveBeenCalledWith('load', expect.objectContaining({
|
|
71
|
+
data: patchResponse,
|
|
72
|
+
invalidatePageCache: false,
|
|
73
|
+
}));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should not call load when response is a Table', async() => {
|
|
77
|
+
const tableResponse = { kind: 'Table', rows: [] };
|
|
78
|
+
const { model, dispatch } = createSteveModel();
|
|
79
|
+
|
|
80
|
+
dispatch.mockResolvedValueOnce(tableResponse);
|
|
81
|
+
|
|
82
|
+
await model.update({ data: { key: 'value' } });
|
|
83
|
+
|
|
84
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
85
|
+
expect(dispatch).not.toHaveBeenCalledWith('load', expect.anything());
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should throw when canEdit is false', async() => {
|
|
89
|
+
const { model, dispatch } = createSteveModel({ links: {} });
|
|
90
|
+
|
|
91
|
+
await expect(model.update({ data: {} })).rejects.toThrow(
|
|
92
|
+
'ResourceInstance API error - configmap/default/my-config - Cannot patch: permission denied'
|
|
93
|
+
);
|
|
94
|
+
expect(dispatch).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('replace', () => {
|
|
99
|
+
it('should call save() and return the instance', async() => {
|
|
100
|
+
const { model, dispatch } = createSteveModel();
|
|
101
|
+
|
|
102
|
+
dispatch.mockResolvedValueOnce(undefined);
|
|
103
|
+
|
|
104
|
+
const result = await model.replace();
|
|
105
|
+
|
|
106
|
+
expect(result).toStrictEqual(model);
|
|
107
|
+
expect(dispatch).toHaveBeenCalledWith('request', expect.objectContaining({ opt: expect.objectContaining({ method: 'put' }) }));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should throw when canEdit is false', async() => {
|
|
111
|
+
const { model, dispatch } = createSteveModel({ links: {} });
|
|
112
|
+
|
|
113
|
+
await expect(model.replace()).rejects.toThrow(
|
|
114
|
+
'ResourceInstance API error - configmap/default/my-config - Cannot update: permission denied'
|
|
115
|
+
);
|
|
116
|
+
expect(dispatch).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('delete', () => {
|
|
121
|
+
it('should call remove()', async() => {
|
|
122
|
+
const { model, dispatch } = createSteveModel({
|
|
123
|
+
links: {
|
|
124
|
+
update: 'https://rancher/v1/configmaps/default/my-config',
|
|
125
|
+
self: 'https://rancher/v1/configmaps/default/my-config',
|
|
126
|
+
remove: 'https://rancher/v1/configmaps/default/my-config',
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
dispatch.mockResolvedValueOnce({ _status: 204 });
|
|
131
|
+
dispatch.mockResolvedValueOnce(undefined);
|
|
132
|
+
|
|
133
|
+
await model.delete();
|
|
134
|
+
|
|
135
|
+
expect(dispatch).toHaveBeenCalledWith('request', expect.objectContaining({ opt: expect.objectContaining({ method: 'delete' }) }));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should throw when canDelete is false', async() => {
|
|
139
|
+
const { model, dispatch } = createSteveModel({ links: {} });
|
|
140
|
+
|
|
141
|
+
await expect(model.delete()).rejects.toThrow(
|
|
142
|
+
'ResourceInstance API error - configmap/default/my-config - Cannot delete: permission denied'
|
|
143
|
+
);
|
|
144
|
+
expect(dispatch).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -91,4 +91,47 @@ export default class SteveModel extends HybridModel {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* RESOURCES API - ResourceInstance update method to send a PATCH request
|
|
97
|
+
*/
|
|
98
|
+
async update(data) {
|
|
99
|
+
if (!this.canEdit) {
|
|
100
|
+
throw new Error(`ResourceInstance API error - ${ this.type }/${ this.id } - Cannot patch: permission denied`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.error('Updating instance with data:', data); // eslint-disable-line no-console
|
|
104
|
+
|
|
105
|
+
await this.save({
|
|
106
|
+
data,
|
|
107
|
+
method: 'patch',
|
|
108
|
+
headers: { 'content-type': 'application/strategic-merge-patch+json' }
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return this;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* RESOURCES API - ResourceInstance update method to send a PUT request
|
|
116
|
+
*/
|
|
117
|
+
async replace() {
|
|
118
|
+
if (!this.canEdit) {
|
|
119
|
+
throw new Error(`ResourceInstance API error - ${ this.type }/${ this.id } - Cannot update: permission denied`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await this.save();
|
|
123
|
+
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* RESOURCES API - ResourceInstance delete method to send a DELETE request
|
|
129
|
+
*/
|
|
130
|
+
async delete() {
|
|
131
|
+
if (!this.canDelete) {
|
|
132
|
+
throw new Error(`ResourceInstance API error - ${ this.type }/${ this.id } - Cannot delete: permission denied`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await this.remove();
|
|
136
|
+
}
|
|
94
137
|
}
|
|
@@ -834,6 +834,17 @@ const defaultActions = {
|
|
|
834
834
|
state.debugSocket && console.debug(`Subscribe Flush [${ getters.storeName }]`, queue.length, 'items'); // eslint-disable-line no-console
|
|
835
835
|
|
|
836
836
|
for ( const { action, event, body } of queue ) {
|
|
837
|
+
const havePage = getters['havePage'](body?.type);
|
|
838
|
+
|
|
839
|
+
if (havePage) {
|
|
840
|
+
// eslint-disable-next-line no-console
|
|
841
|
+
console.warn(`Prevented watch \`flush\` data from polluting the cache for type "${ body?.type }" (currently represents a page)`, {
|
|
842
|
+
action, event, body
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
|
|
837
848
|
if ( action === 'dispatch' && event === 'load' ) {
|
|
838
849
|
// Group loads into one loadMulti when possible
|
|
839
850
|
toLoad.push(body);
|
|
@@ -57,6 +57,121 @@ describe('component: LabeledInput', () => {
|
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
describe('type "integer"', () => {
|
|
61
|
+
const i18nMock = { $store: { getters: { 'i18n/t': jest.fn() } } };
|
|
62
|
+
|
|
63
|
+
it('should render a text input with numeric inputmode', () => {
|
|
64
|
+
const wrapper = mount(LabeledInput, {
|
|
65
|
+
propsData: { type: 'integer', value: '' },
|
|
66
|
+
mocks: i18nMock
|
|
67
|
+
});
|
|
68
|
+
const input = wrapper.find('input');
|
|
69
|
+
|
|
70
|
+
expect(input.attributes('type')).toBe('text');
|
|
71
|
+
expect(input.attributes('inputmode')).toBe('numeric');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it.each([
|
|
75
|
+
'e', 'E', '.', '+',
|
|
76
|
+
])('should prevent non-integer key "%s"', (key) => {
|
|
77
|
+
const wrapper = mount(LabeledInput, {
|
|
78
|
+
propsData: { type: 'integer', value: '' },
|
|
79
|
+
mocks: i18nMock
|
|
80
|
+
});
|
|
81
|
+
const input = wrapper.find('input');
|
|
82
|
+
const event = new KeyboardEvent('keydown', { key, cancelable: true });
|
|
83
|
+
|
|
84
|
+
input.element.dispatchEvent(event);
|
|
85
|
+
|
|
86
|
+
expect(event.defaultPrevented).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it.each([
|
|
90
|
+
'0', '1', '9', '-', 'Backspace', 'Tab', 'ArrowLeft',
|
|
91
|
+
])('should allow key "%s"', (key) => {
|
|
92
|
+
const wrapper = mount(LabeledInput, {
|
|
93
|
+
propsData: { type: 'integer', value: '' },
|
|
94
|
+
mocks: i18nMock
|
|
95
|
+
});
|
|
96
|
+
const input = wrapper.find('input');
|
|
97
|
+
const event = new KeyboardEvent('keydown', { key, cancelable: true });
|
|
98
|
+
|
|
99
|
+
input.element.dispatchEvent(event);
|
|
100
|
+
|
|
101
|
+
expect(event.defaultPrevented).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should also block "-" when min="0"', () => {
|
|
105
|
+
const wrapper = mount(LabeledInput, {
|
|
106
|
+
propsData: { type: 'integer', value: '' },
|
|
107
|
+
attrs: { min: '0' },
|
|
108
|
+
mocks: i18nMock
|
|
109
|
+
});
|
|
110
|
+
const input = wrapper.find('input');
|
|
111
|
+
const event = new KeyboardEvent('keydown', { key: '-', cancelable: true });
|
|
112
|
+
|
|
113
|
+
input.element.dispatchEvent(event);
|
|
114
|
+
|
|
115
|
+
expect(event.defaultPrevented).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
function createPasteEvent(text: string): Event {
|
|
119
|
+
const event = new Event('paste', { cancelable: true });
|
|
120
|
+
|
|
121
|
+
(event as Event & { clipboardData: Pick<DataTransfer, 'getData'> }).clipboardData = { getData: () => text };
|
|
122
|
+
|
|
123
|
+
return event;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
it.each([
|
|
127
|
+
'123',
|
|
128
|
+
'-5',
|
|
129
|
+
])('should allow pasting valid integer "%s"', (text) => {
|
|
130
|
+
const wrapper = mount(LabeledInput, {
|
|
131
|
+
propsData: { type: 'integer', value: '' },
|
|
132
|
+
mocks: i18nMock
|
|
133
|
+
});
|
|
134
|
+
const input = wrapper.find('input');
|
|
135
|
+
const event = createPasteEvent(text);
|
|
136
|
+
|
|
137
|
+
input.element.dispatchEvent(event);
|
|
138
|
+
|
|
139
|
+
expect(event.defaultPrevented).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it.each([
|
|
143
|
+
'-e10',
|
|
144
|
+
'1.5',
|
|
145
|
+
'12abc',
|
|
146
|
+
'1e5',
|
|
147
|
+
])('should block pasting invalid integer "%s"', (text) => {
|
|
148
|
+
const wrapper = mount(LabeledInput, {
|
|
149
|
+
propsData: { type: 'integer', value: '' },
|
|
150
|
+
mocks: i18nMock
|
|
151
|
+
});
|
|
152
|
+
const input = wrapper.find('input');
|
|
153
|
+
const event = createPasteEvent(text);
|
|
154
|
+
|
|
155
|
+
input.element.dispatchEvent(event);
|
|
156
|
+
|
|
157
|
+
expect(event.defaultPrevented).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should block pasting negative integer when min="0"', () => {
|
|
161
|
+
const wrapper = mount(LabeledInput, {
|
|
162
|
+
propsData: { type: 'integer', value: '' },
|
|
163
|
+
attrs: { min: '0' },
|
|
164
|
+
mocks: i18nMock
|
|
165
|
+
});
|
|
166
|
+
const input = wrapper.find('input');
|
|
167
|
+
const event = createPasteEvent('-5');
|
|
168
|
+
|
|
169
|
+
input.element.dispatchEvent(event);
|
|
170
|
+
|
|
171
|
+
expect(event.defaultPrevented).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
60
175
|
describe('a11y: adding ARIA props', () => {
|
|
61
176
|
const ariaLabelVal = 'some-aria-label';
|
|
62
177
|
const subLabelVal = 'some-sublabel';
|