@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.
Files changed (116) hide show
  1. package/apis/intf/resources-api/resource-base.ts +42 -2
  2. package/apis/intf/resources-api/resource-instance.ts +101 -0
  3. package/apis/intf/resources-api/resources-api.ts +177 -23
  4. package/apis/intf/resources.ts +8 -6
  5. package/apis/intf/shell-api/slide-in.ts +46 -17
  6. package/apis/resources/__tests__/resources-api-class.test.ts +309 -28
  7. package/apis/resources/resources-api-class.ts +232 -43
  8. package/apis/shell/__tests__/slide-in.test.ts +83 -2
  9. package/apis/shell/slide-in.ts +20 -0
  10. package/assets/images/providers/traefik.png +0 -0
  11. package/assets/translations/en-us.yaml +10 -4
  12. package/chart/__tests__/rancher-monitoring-dashboards-index.test.ts +269 -0
  13. package/chart/rancher-monitoring-dashboards/index.vue +155 -0
  14. package/cloud-credential/__tests__/generic.test.ts +132 -0
  15. package/cloud-credential/generic.vue +33 -3
  16. package/components/EtcdInfoBanner.vue +2 -12
  17. package/components/ExplorerProjectsNamespaces.vue +2 -2
  18. package/components/GrafanaDashboard.vue +2 -8
  19. package/components/Resource/Detail/Masthead/__tests__/index.test.ts +68 -14
  20. package/components/Resource/Detail/Masthead/index.vue +8 -3
  21. package/components/Resource/Detail/Metadata/IdentifyingInformation/__tests__/identifying-fields.test.ts +9 -0
  22. package/components/Resource/Detail/Metadata/IdentifyingInformation/identifying-fields.ts +5 -1
  23. package/components/SlideInPanelManager.vue +103 -25
  24. package/components/__tests__/SlideInPanelManager.spec.ts +153 -10
  25. package/components/auth/AuthBanner.vue +1 -1
  26. package/components/form/Security.vue +1 -1
  27. package/components/formatter/Translate.vue +22 -1
  28. package/components/nav/Group.vue +5 -10
  29. package/components/nav/TopLevelMenu.vue +1 -30
  30. package/components/nav/__tests__/Group.test.ts +61 -0
  31. package/config/labels-annotations.js +1 -0
  32. package/config/table-headers.js +5 -2
  33. package/core/__tests__/plugin-products-apply.test.ts +787 -0
  34. package/core/__tests__/plugin-products-extend.test.ts +189 -0
  35. package/core/__tests__/plugin-products-helpers.test.ts +81 -78
  36. package/core/__tests__/plugin-products-new.test.ts +389 -0
  37. package/core/__tests__/plugin-products-scenarios.test.ts +980 -0
  38. package/core/__tests__/plugin-products-side-menu.test.ts +1935 -0
  39. package/core/plugin-products-base.ts +201 -93
  40. package/core/plugin-products-extending.ts +1 -1
  41. package/core/plugin-products-external.ts +380 -0
  42. package/core/plugin-products-helpers.ts +28 -24
  43. package/core/plugin-products-internal.ts +207 -0
  44. package/core/plugin-products-top-level.ts +10 -5
  45. package/core/plugin-products-type-guards.ts +17 -6
  46. package/core/plugin-products.ts +5 -7
  47. package/core/plugin-types.ts +1 -389
  48. package/core/plugin.ts +10 -11
  49. package/core/plugins-loader.js +2 -0
  50. package/core/types.ts +6 -202
  51. package/detail/__tests__/provisioning.cattle.io.cluster.test.ts +52 -0
  52. package/detail/pod.vue +1 -1
  53. package/detail/provisioning.cattle.io.cluster.vue +0 -2
  54. package/edit/auth/AuthProviderWarningBanners.vue +12 -0
  55. package/edit/auth/__tests__/AuthProviderWarningBanners.test.ts +10 -1
  56. package/edit/auth/__tests__/azuread.test.ts +1 -0
  57. package/edit/auth/__tests__/github.test.ts +1 -0
  58. package/edit/auth/__tests__/oidc.test.ts +1 -0
  59. package/edit/auth/__tests__/saml.test.ts +1 -0
  60. package/edit/auth/ldap/__tests__/index.test.ts +1 -0
  61. package/edit/provisioning.cattle.io.cluster/index.vue +1 -1
  62. package/edit/provisioning.cattle.io.cluster/rke2.vue +1 -1
  63. package/edit/provisioning.cattle.io.cluster/tabs/networking/index.vue +2 -2
  64. package/list/management.cattle.io.user.vue +24 -5
  65. package/list/utils/management.cattle.io.cluster.utils.ts +1 -1
  66. package/machine-config/__tests__/generic.test.ts +163 -0
  67. package/machine-config/components/EC2Networking.vue +31 -7
  68. package/machine-config/components/__tests__/EC2Networking.test.ts +38 -15
  69. package/machine-config/generic.vue +32 -5
  70. package/mixins/resource-fetch.js +1 -1
  71. package/models/__tests__/namespace.test.ts +133 -8
  72. package/models/__tests__/provisioning.cattle.io.cluster.test.ts +57 -1
  73. package/models/__tests__/rke-machine.cattle.io.vmwarevspheremachinetemplate.test.ts +22 -0
  74. package/models/namespace.js +34 -2
  75. package/models/pod.js +12 -10
  76. package/models/provisioning.cattle.io.cluster.js +20 -13
  77. package/models/rke-machine.cattle.io.vmwarevspheremachinetemplate.js +9 -0
  78. package/package.json +1 -1
  79. package/pages/auth/login.vue +1 -1
  80. package/pages/c/_cluster/apps/charts/__tests__/install.test.ts +110 -0
  81. package/pages/c/_cluster/apps/charts/install.vue +29 -0
  82. package/pages/c/_cluster/auth/config/index.vue +11 -4
  83. package/pages/c/_cluster/explorer/workload-dashboard/__tests__/composable.test.ts +136 -1
  84. package/pages/c/_cluster/explorer/workload-dashboard/composable.ts +69 -1
  85. package/pages/c/_cluster/fleet/index.vue +5 -9
  86. package/pages/c/_cluster/monitoring/index.vue +6 -1
  87. package/pkg/vue.config.js +4 -3
  88. package/plugins/codemirror.js +2 -0
  89. package/plugins/dashboard-store/resource-class.js +13 -9
  90. package/plugins/steve/__tests__/steve-class-resource-api.test.ts +147 -0
  91. package/plugins/steve/steve-class.js +43 -0
  92. package/plugins/steve/subscribe.js +11 -0
  93. package/rancher-components/Form/LabeledInput/LabeledInput.test.ts +115 -0
  94. package/rancher-components/Form/LabeledInput/LabeledInput.vue +83 -3
  95. package/store/__tests__/action-menu.test.ts +622 -0
  96. package/store/__tests__/auth.test.ts +18 -0
  97. package/store/__tests__/notifications.test.ts +466 -1
  98. package/store/__tests__/slideInPanel.test.ts +143 -43
  99. package/store/__tests__/ui-context.test.ts +255 -0
  100. package/store/__tests__/wm.test.ts +503 -0
  101. package/store/auth.js +11 -1
  102. package/store/aws.js +19 -4
  103. package/store/features.js +1 -0
  104. package/store/plugins.js +6 -0
  105. package/store/slideInPanel.ts +15 -3
  106. package/types/rancher/steve.api.ts +2 -2
  107. package/types/shell/index.d.ts +84 -2
  108. package/types/store/dashboard-store.types.ts +2 -2
  109. package/types/store/type-map.ts +262 -1
  110. package/utils/__tests__/fleet-appco.test.ts +23 -0
  111. package/utils/async.ts +2 -0
  112. package/utils/fleet-appco.ts +6 -1
  113. package/utils/grafana.js +2 -17
  114. package/utils/monitoring.js +38 -1
  115. package/utils/sort.js +1 -1
  116. 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
- showHeader: false,
326
- props: {
325
+ props: {
327
326
  value,
328
327
  statePanel,
329
328
  workspace
330
329
  },
331
- width: '73%',
332
- // We want this to be full viewport height top to bottom
333
- height: '100vh',
334
- top: '0',
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
- const rancherMonitoring = !isEmpty(res.apps) ? findBy(res.apps, 'id', 'cattle-monitoring-system/rancher-monitoring') : '';
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: '__jszip',
96
- 'js-yaml': '__jsyaml'
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
@@ -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: '73%',
936
- // We want this to be full viewport height top to bottom
937
- height: '100vh',
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
- const errors = this.validationErrors(this, opt);
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.data = this.toSave() || JSON.parse(JSON.stringify(this));
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
- opt.data = this.cleanForSave(opt.data, forNew);
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';