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

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 (81) hide show
  1. package/assets/styles/global/_button.scss +1 -1
  2. package/assets/translations/en-us.yaml +39 -10
  3. package/components/ActionDropdownShell.vue +5 -3
  4. package/components/ButtonGroup.vue +26 -1
  5. package/components/CruResource.vue +51 -2
  6. package/components/PromptRestore.vue +93 -32
  7. package/components/Questions/index.vue +1 -0
  8. package/components/ResourceTable.vue +1 -0
  9. package/components/SortableTable/index.vue +4 -3
  10. package/components/Wizard.vue +14 -1
  11. package/components/__tests__/ButtonGroup.test.ts +56 -0
  12. package/components/__tests__/PromptRestore.test.ts +169 -19
  13. package/components/fleet/GitRepoAdvancedTab.vue +1 -0
  14. package/components/fleet/GitRepoMetadataTab.vue +5 -0
  15. package/components/fleet/HelmOpAppCoConfigTab.vue +4 -0
  16. package/components/fleet/HelmOpMetadataTab.vue +5 -0
  17. package/components/form/FileSelector.vue +39 -1
  18. package/components/form/PrivateRegistry.constants.ts +7 -0
  19. package/components/form/PrivateRegistry.vue +253 -18
  20. package/components/form/SelectOrCreateAuthSecret.vue +140 -17
  21. package/components/form/__tests__/FileSelector.test.ts +23 -0
  22. package/components/form/__tests__/PrivateRegistry.test.ts +463 -73
  23. package/components/form/__tests__/SelectOrCreateAuthSecret.test.ts +122 -0
  24. package/components/formatter/EtcdSnapshotName.vue +73 -0
  25. package/components/nav/Header.vue +8 -1
  26. package/components/templates/default.vue +7 -0
  27. package/config/features.js +1 -0
  28. package/config/labels-annotations.js +2 -0
  29. package/config/product/manager.js +6 -0
  30. package/config/secret.ts +10 -0
  31. package/config/settings.ts +6 -2
  32. package/config/types.js +7 -0
  33. package/detail/provisioning.cattle.io.cluster.vue +79 -3
  34. package/dialog/RotateEncryptionKeyDialog.vue +33 -9
  35. package/dialog/__tests__/RotateEncryptionKeyDialog.test.ts +78 -0
  36. package/edit/__tests__/fleet.cattle.io.gitrepo.test.ts +92 -0
  37. package/edit/__tests__/fleet.cattle.io.helmop.test.ts +101 -0
  38. package/edit/__tests__/management.cattle.io.setting.test.ts +2 -1
  39. package/edit/compliance.cattle.io.clusterscanprofile.vue +39 -41
  40. package/edit/fleet.cattle.io.gitrepo.vue +70 -16
  41. package/edit/fleet.cattle.io.helmop.vue +51 -5
  42. package/edit/helm.cattle.io.projecthelmchart.vue +1 -0
  43. package/edit/{management.cattle.io.setting.vue → management.cattle.io.setting/index.vue} +32 -9
  44. package/edit/management.cattle.io.setting/system-default-registry-pull-secrets.vue +81 -0
  45. package/edit/provisioning.cattle.io.cluster/SelectCredential.vue +3 -12
  46. package/edit/provisioning.cattle.io.cluster/__tests__/rke2.test.ts +18 -0
  47. package/edit/provisioning.cattle.io.cluster/rke2.vue +5 -1
  48. package/edit/provisioning.cattle.io.cluster/tabs/etcd/index.vue +0 -1
  49. package/edit/provisioning.cattle.io.cluster/tabs/registries/index.vue +14 -55
  50. package/models/__tests__/provisioning.cattle.io.cluster.test.ts +156 -0
  51. package/models/__tests__/secret.test.ts +68 -1
  52. package/models/management.cattle.io.cluster.js +21 -3
  53. package/models/pod.js +13 -2
  54. package/models/provisioning.cattle.io.cluster.js +59 -9
  55. package/models/rke.cattle.io.etcdsnapshot.js +17 -9
  56. package/models/secret.js +19 -0
  57. package/models/workload.js +12 -7
  58. package/package.json +1 -1
  59. package/pages/c/_cluster/apps/charts/__tests__/install.test.ts +485 -107
  60. package/pages/c/_cluster/apps/charts/install.vue +114 -28
  61. package/pkg/require-asset.lib.js +25 -0
  62. package/pkg/vue.config.js +7 -0
  63. package/plugins/dashboard-store/__tests__/resource-class.test.ts +84 -0
  64. package/plugins/dashboard-store/getters.js +0 -1
  65. package/plugins/dashboard-store/resource-class.js +52 -12
  66. package/rancher-components/Form/TextArea/TextAreaAutoGrow.vue +30 -0
  67. package/rancher-components/Form/TextArea/__tests__/TextAreaAutoGrow.test.ts +95 -0
  68. package/rancher-components/RcButton/index.ts +1 -1
  69. package/rancher-components/RcDropdown/RcDropdownTrigger.vue +6 -1
  70. package/store/__tests__/features.test.ts +131 -0
  71. package/store/__tests__/growl.test.ts +374 -0
  72. package/store/__tests__/modal.test.ts +131 -0
  73. package/store/__tests__/slideInPanel.test.ts +88 -0
  74. package/store/__tests__/type-map.utils.test.ts +433 -0
  75. package/store/features.js +4 -0
  76. package/types/shell/index.d.ts +62 -0
  77. package/utils/__tests__/operation-cr.test.ts +34 -0
  78. package/utils/operation-cr.js +19 -0
  79. package/utils/require-asset.ts +7 -0
  80. package/utils/validators/__tests__/private-registry.test.ts +27 -15
  81. package/utils/validators/private-registry.ts +15 -4
@@ -40,9 +40,12 @@ import {
40
40
  import { ignoreVariables } from './install.helpers';
41
41
  import { findBy, insertAt } from '@shell/utils/array';
42
42
  import { saferDump } from '@shell/utils/create-yaml';
43
+ import { addParam } from '@shell/utils/url';
43
44
  import { WINDOWS } from '@shell/store/catalog';
44
45
  import { SETTING } from '@shell/config/settings';
45
46
  import SelectOrCreateAuthSecret from '@shell/components/form/SelectOrCreateAuthSecret.vue';
47
+ import PrivateRegistry from '@shell/components/form/PrivateRegistry.vue';
48
+ import { PRIVATE_REGISTRY_CONTEXT } from '@shell/components/form/PrivateRegistry.constants';
46
49
  import { generateRandomAlphaString } from '@shell/utils/string';
47
50
 
48
51
  const VALUES_STATE = {
@@ -95,7 +98,8 @@ export default {
95
98
  UnitInput,
96
99
  YamlEditor,
97
100
  Wizard,
98
- SelectOrCreateAuthSecret
101
+ SelectOrCreateAuthSecret,
102
+ PrivateRegistry
99
103
  },
100
104
 
101
105
  mixins: [
@@ -357,6 +361,15 @@ export default {
357
361
  this.showCustomRegistryInput = !!this.customRegistrySetting;
358
362
  }
359
363
 
364
+ // On upgrade, pre-select a single existing image pull secret in the dropdown
365
+ if (this.existing && this.showRegistryPullSecrets) {
366
+ const existingPullSecrets = this.chartValues?.global?.imagePullSecrets;
367
+
368
+ if (Array.isArray(existingPullSecrets) && existingPullSecrets.length === 1) {
369
+ this.registryPullSecret = existingPullSecrets[0];
370
+ }
371
+ }
372
+
360
373
  /* Serializes an object as a YAML document */
361
374
  this.valuesYaml = saferDump(this.chartValues);
362
375
 
@@ -453,6 +466,9 @@ export default {
453
466
  appCoDataFetched: false,
454
467
  AUTH_TYPE,
455
468
  CLUSTER_REPO_APPCO_AUTH_GENERATE_NAME,
469
+ PRIVATE_REGISTRY_CONTEXT,
470
+ skipPullSecrets: false,
471
+ registryPullSecret: null,
456
472
  stepBasic: {
457
473
  name: 'basics',
458
474
  label: this.t('catalog.install.steps.basics.label'),
@@ -783,6 +799,29 @@ export default {
783
799
  return global.systemDefaultRegistry !== undefined || global.cattle?.systemDefaultRegistry !== undefined;
784
800
  },
785
801
 
802
+ showRegistryPullSecrets() {
803
+ return !!this.repo?.spec?.defaultImagePullSecrets?.length;
804
+ },
805
+
806
+ existingValuesPullSecrets() {
807
+ if (!this.existing) {
808
+ return [];
809
+ }
810
+
811
+ const pullSecrets = this.chartValues?.global?.imagePullSecrets;
812
+
813
+ return Array.isArray(pullSecrets) ? pullSecrets.filter(Boolean) : [];
814
+ },
815
+
816
+ /**
817
+ * if the system-default-pull-image-secrets global setting is set OR the current cluster has system default registry pull secrets configured
818
+ * the Rancher cluster repo will automatically be populated with
819
+ * copies of the secrets referenced in the global setting
820
+ */
821
+ repoDefaultPullSecretNames() {
822
+ return (this.repo?.spec?.defaultImagePullSecrets || []).map((s) => s.name).filter(Boolean);
823
+ },
824
+
786
825
  setImagePullSecretDataTrigger() {
787
826
  return `
788
827
  ${ this.defaultImagePullSecret?.name }
@@ -974,11 +1013,30 @@ export default {
974
1013
  }
975
1014
  }
976
1015
  },
1016
+
977
1017
  async getClusterRegistry() {
1018
+ const mgmCluster = this.$store.getters['currentCluster'];
1019
+
1020
+ // For local, imported, and hosted (AKS, EKS, GKE, ALI) clusters,
1021
+ // the cluster-scoped private registry is on the norman cluster's importedConfig.
1022
+ if (mgmCluster?.isLocal || mgmCluster?.isImported || mgmCluster?.isHostedKubernetesProvider) {
1023
+ try {
1024
+ const normanCluster = await mgmCluster.findNormanCluster();
1025
+ const importedRegistryURL = normanCluster?.importedConfig?.privateRegistryURL;
1026
+
1027
+ if (importedRegistryURL) {
1028
+ return importedRegistryURL;
1029
+ }
1030
+ } catch (e) {
1031
+ console.warn('Unable to fetch norman cluster for registry lookup: ', e); // eslint-disable-line no-console
1032
+ }
1033
+
1034
+ return;
1035
+ }
1036
+
978
1037
  const hasPermissionToSeeProvCluster = this.$store.getters[`management/schemaFor`](CAPI.RANCHER_CLUSTER);
979
1038
 
980
1039
  if (hasPermissionToSeeProvCluster) {
981
- const mgmCluster = this.$store.getters['currentCluster'];
982
1040
  const provClusterId = mgmCluster?.provClusterId;
983
1041
  let provCluster;
984
1042
 
@@ -1148,14 +1206,22 @@ export default {
1148
1206
  const isUpgrade = !!this.existing;
1149
1207
 
1150
1208
  this.errors = [];
1209
+ // Create namespace if it doesn't exist
1210
+ // this is done before save hooks so that image pull secrets can be created in the target namespace
1211
+ await this.createNamespaceIfNeeded();
1151
1212
 
1152
- // Create namespace if it doesn't exist (before hooks run)
1153
- // And only if it is SUSE APP Collection, overall should just do the same flow
1154
- if (!isUpgrade && this.isNamespaceNew && this.repo?.isSuseAppCollection) {
1155
- await this.createNamespaceIfNeeded();
1156
- }
1213
+ const hookResults = await this.applyHooks(BEFORE_SAVE_HOOKS);
1157
1214
 
1158
- await this.applyHooks(BEFORE_SAVE_HOOKS);
1215
+ // When a new pull secret is created by SelectOrCreateAuthSecret inside
1216
+ // PrivateRegistry, the emit chain does not propagate the secret name
1217
+ // back to registryPullSecret in time. Read it from the hook result.
1218
+ if (this.showRegistryPullSecrets && !this.skipPullSecrets && !this.registryPullSecret) {
1219
+ const createdSecret = hookResults?.registerAuthSecret;
1220
+
1221
+ if (createdSecret?.metadata?.name) {
1222
+ this.registryPullSecret = createdSecret.metadata.name;
1223
+ }
1224
+ }
1159
1225
 
1160
1226
  const { errors, input } = this.actionInput(isUpgrade);
1161
1227
 
@@ -1166,7 +1232,16 @@ export default {
1166
1232
  return;
1167
1233
  }
1168
1234
 
1169
- const res = await this.repo.doAction((isUpgrade ? 'upgrade' : 'install'), input);
1235
+ const actionName = isUpgrade ? 'upgrade' : 'install';
1236
+ const actionOpt = {};
1237
+
1238
+ if (this.skipPullSecrets) {
1239
+ const baseUrl = this.repo.actionLinkFor(actionName);
1240
+
1241
+ actionOpt.url = addParam(baseUrl, 'skipPullSecrets', 'true');
1242
+ }
1243
+
1244
+ const res = await this.repo.doAction(actionName, input, actionOpt);
1170
1245
  const operationId = `${ res.operationNamespace }/${ res.operationName }`;
1171
1246
 
1172
1247
  // Non-admins without a cluster won't be able to fetch operations immediately
@@ -1227,6 +1302,15 @@ export default {
1227
1302
  set(global, 'systemDefaultRegistry', this.customRegistrySetting);
1228
1303
  }
1229
1304
 
1305
+ if (this.showRegistryPullSecrets && this.registryPullSecret) {
1306
+ // User explicitly selected or created a pull secret
1307
+ set(global, 'imagePullSecrets', [this.registryPullSecret]);
1308
+ } else if (this.showRegistryPullSecrets) {
1309
+ // User chose "skip" or "use default" — remove explicit imagePullSecrets
1310
+ // so the backend falls back to the repo/global defaults
1311
+ delete global.imagePullSecrets;
1312
+ }
1313
+
1230
1314
  setIfNotSet(global, 'cattle.systemProjectId', systemProjectId);
1231
1315
  setIfNotSet(cattle, 'url', serverUrl);
1232
1316
  setIfNotSet(cattle, 'rkePathPrefix', pathPrefix);
@@ -1329,7 +1413,6 @@ export default {
1329
1413
  */
1330
1414
 
1331
1415
  this.addGlobalValuesTo(values);
1332
-
1333
1416
  const form = JSON.parse(JSON.stringify(this.value));
1334
1417
 
1335
1418
  /*
@@ -1488,6 +1571,8 @@ export default {
1488
1571
  }
1489
1572
  },
1490
1573
 
1574
+ // not the same as PrivateRegistry pull secrets which are created based off the global/cluster system default registry hostname value not the repo url directly
1575
+ // those secrets will be created in a beforeSaveHook managed by SelectOrCreateAuthSecret
1491
1576
  async createImagePullSecret() {
1492
1577
  if (!this.repo?.isSuseAppCollection) {
1493
1578
  return;
@@ -1753,26 +1838,27 @@ export default {
1753
1838
  :label="t('catalog.install.steps.helmCli.checkbox', { action: action.name, existing: !!existing })"
1754
1839
  />
1755
1840
 
1756
- <Checkbox
1841
+ <PrivateRegistry
1757
1842
  v-if="showCustomRegistry"
1758
- v-model:value="showCustomRegistryInput"
1759
- class="mb-20"
1760
- data-testid="custom-registry-checkbox"
1761
- :label="t('catalog.chart.registry.custom.checkBoxLabel')"
1762
- :tooltip="t('catalog.chart.registry.tooltip')"
1843
+ :context="PRIVATE_REGISTRY_CONTEXT.CHARTS"
1844
+ :value="customRegistrySetting"
1845
+ :enabled="showCustomRegistryInput"
1846
+ :default-registry="defaultRegistrySetting"
1847
+ :namespace="targetNamespace"
1848
+ in-store="cluster"
1849
+ :register-before-hook="registerBeforeHook"
1850
+ :show-pull-secrets="showRegistryPullSecrets"
1851
+ :repo-default-pull-secrets="repoDefaultPullSecretNames"
1852
+ :existing-values-pull-secrets="existingValuesPullSecrets"
1853
+ :pull-secret="registryPullSecret"
1854
+ :skip-pull-secrets="skipPullSecrets"
1855
+ checkbox-test-id="custom-registry-checkbox"
1856
+ input-test-id="custom-registry-input"
1857
+ @update:value="(val) => customRegistrySetting = val"
1858
+ @update:enabled="(val) => showCustomRegistryInput = val"
1859
+ @update:pull-secret="(val) => registryPullSecret = val"
1860
+ @update:skip-pull-secrets="(val) => skipPullSecrets = val"
1763
1861
  />
1764
- <div class="row">
1765
- <div class="col span-6">
1766
- <LabeledInput
1767
- v-if="showCustomRegistryInput"
1768
- v-model:value="customRegistrySetting"
1769
- data-testid="custom-registry-input"
1770
- label-key="catalog.chart.registry.custom.inputLabel"
1771
- placeholder-key="catalog.chart.registry.custom.placeholder"
1772
- :min-height="30"
1773
- />
1774
- </div>
1775
- </div>
1776
1862
  <div
1777
1863
  class="step__values__controls--spacer"
1778
1864
  style="flex:1"
@@ -0,0 +1,25 @@
1
+ // Stub for extension library builds — prevents require.context() from bundling
2
+ // all shell images into every extension. At runtime, delegates to the host
3
+ // dashboard's asset resolver (exposed on window by the real require-asset.ts).
4
+
5
+ export function toContextKey(path) {
6
+ return `./${ path.replace(/^[~@]shell\/assets\//, '') }`;
7
+ }
8
+
9
+ export function requireAsset(path) {
10
+ if (typeof window !== 'undefined' && window.__shell_requireAsset) {
11
+ return window.__shell_requireAsset(path);
12
+ }
13
+
14
+ throw new Error(`Asset context not available for: ${ path }`);
15
+ }
16
+
17
+ export function requireJson(path) {
18
+ if (typeof window !== 'undefined' && window.__shell_requireJson) {
19
+ return window.__shell_requireJson(path);
20
+ }
21
+
22
+ throw new Error(`JSON context not available for: ${ path }`);
23
+ }
24
+
25
+ export function _setContexts() {}
package/pkg/vue.config.js CHANGED
@@ -72,11 +72,18 @@ module.exports = function(dir) {
72
72
  resource.request = fs.existsSync(pkgModelLoaderRequire) ? pkgModelLoaderRequire : path.join(__dirname, fileName);
73
73
  });
74
74
 
75
+ // Prevent require.context('@shell/assets') from bundling all shell images into extensions.
76
+ // The stub delegates to the host dashboard's asset resolver at runtime via window.__shell_requireAsset.
77
+ const requireAssetOverride = new webpack.NormalModuleReplacementPlugin(/require-asset$/, (resource) => {
78
+ resource.request = path.join(__dirname, 'require-asset.lib.js');
79
+ });
80
+
75
81
  // Auto-generate module to import the types (model, detail, edit etc)
76
82
  const autoImportPlugin = new VirtualModulesPlugin({ 'node_modules/@rancher/auto-import': generateTypeImport('@pkg', dir) });
77
83
 
78
84
  config.plugins.unshift(dynamicImporterOverride);
79
85
  config.plugins.unshift(modelLoaderImporterOverride);
86
+ config.plugins.unshift(requireAssetOverride);
80
87
  config.plugins.unshift(autoImportPlugin);
81
88
  config.plugins.unshift(new NodePolyfillPlugin()); // required from Webpack 5 to polyfill node modules
82
89
  // config.plugins.unshift(debug);
@@ -678,4 +678,88 @@ describe('class: Resource', () => {
678
678
  expect(viewYaml.enabled).toBe(true);
679
679
  });
680
680
  });
681
+
682
+ describe('method: dryRunCreate', () => {
683
+ const collectionUrl = '/v1/test.resources';
684
+
685
+ it('should dispatch a request with dryRun=All query param', async() => {
686
+ const dispatch = jest.fn().mockResolvedValue({});
687
+ const resource = new Resource({
688
+ type: 'test.resource',
689
+ metadata: {
690
+ name: 'my-resource',
691
+ namespace: 'my-ns',
692
+ },
693
+ }, {
694
+ getters: {
695
+ schemaFor: () => ({
696
+ linkFor: (link: string) => (link === 'collection' ? collectionUrl : ''),
697
+ attributes: { namespaced: true },
698
+ })
699
+ },
700
+ dispatch,
701
+ rootGetters: { 'i18n/t': jest.fn() },
702
+ });
703
+
704
+ await resource.dryRunCreate();
705
+
706
+ expect(dispatch).toHaveBeenCalledWith('request', {
707
+ opt: expect.objectContaining({
708
+ method: 'post',
709
+ url: `${ collectionUrl }/my-ns?dryRun=All`,
710
+ }),
711
+ type: 'test.resource'
712
+ });
713
+ });
714
+
715
+ it('should use provided data instead of resource state when given', async() => {
716
+ const dispatch = jest.fn().mockResolvedValue({});
717
+ const resource = new Resource({
718
+ type: 'test.resource',
719
+ metadata: { name: 'original', namespace: 'ns' },
720
+ }, {
721
+ getters: {
722
+ schemaFor: () => ({
723
+ linkFor: () => collectionUrl,
724
+ attributes: { namespaced: true },
725
+ })
726
+ },
727
+ dispatch,
728
+ rootGetters: { 'i18n/t': jest.fn() },
729
+ });
730
+
731
+ const customData = {
732
+ type: 'test.resource',
733
+ metadata: { name: 'custom' },
734
+ spec: {}
735
+ };
736
+
737
+ await resource.dryRunCreate(customData);
738
+
739
+ expect(dispatch).toHaveBeenCalledWith('request', {
740
+ opt: expect.objectContaining({ data: customData }),
741
+ type: 'test.resource'
742
+ });
743
+ });
744
+
745
+ it('should propagate API errors', async() => {
746
+ const apiError = { _status: 409, message: 'already exists' };
747
+ const dispatch = jest.fn().mockRejectedValue(apiError);
748
+ const resource = new Resource({
749
+ type: 'test.resource',
750
+ metadata: { name: 'dup', namespace: 'ns' },
751
+ }, {
752
+ getters: {
753
+ schemaFor: () => ({
754
+ linkFor: () => collectionUrl,
755
+ attributes: { namespaced: true },
756
+ })
757
+ },
758
+ dispatch,
759
+ rootGetters: { 'i18n/t': jest.fn() },
760
+ });
761
+
762
+ await expect(resource.dryRunCreate()).rejects.toStrictEqual(apiError);
763
+ });
764
+ });
681
765
  });
@@ -278,7 +278,6 @@ export default {
278
278
  const schemas = state.types[SCHEMA];
279
279
 
280
280
  type = getters.normalizeType(type);
281
-
282
281
  if ( !schemas ) {
283
282
  if ( allowThrow ) {
284
283
  throw new Error("Schemas aren't loaded yet");
@@ -79,6 +79,7 @@ export const STATES_ENUM = {
79
79
  BUILDING: 'building',
80
80
  COMPLETED: 'completed',
81
81
  CORDONED: 'cordoned',
82
+ CANCELLED: 'cancelled',
82
83
  COUNT: 'count',
83
84
  CREATED: 'created',
84
85
  CREATING: 'creating',
@@ -207,6 +208,9 @@ export const STATES = {
207
208
  [STATES_ENUM.CORDONED]: {
208
209
  color: 'info', icon: 'tag', label: 'Cordoned', compoundIcon: 'info'
209
210
  },
211
+ [STATES_ENUM.CANCELLED]: {
212
+ color: 'warning', icon: 'error', label: 'Cancelled', compoundIcon: 'warning'
213
+ },
210
214
  [STATES_ENUM.COUNT]: {
211
215
  color: 'success', icon: 'dot-open', label: 'Count', compoundIcon: 'checkmark'
212
216
  },
@@ -1191,6 +1195,46 @@ export default class Resource {
1191
1195
  return this._save(...arguments);
1192
1196
  }
1193
1197
 
1198
+ _collectionUrl() {
1199
+ const schema = this.$getters['schemaFor'](this.type);
1200
+
1201
+ if ( !schema ) {
1202
+ // Schema not found - likely due to lack of permissions to view this resource type
1203
+ throw new Error(`${ this.type }: ${ this.t('validation.createResourceFailed', { type: this.typeDisplay }, true) }`);
1204
+ }
1205
+
1206
+ let url = schema.linkFor('collection');
1207
+
1208
+ if ( schema.attributes && schema.attributes.namespaced && this.metadata && this.metadata.namespace ) {
1209
+ url += `/${ this.metadata.namespace }`;
1210
+ }
1211
+
1212
+ return url;
1213
+ }
1214
+
1215
+ async dryRunCreate(data) {
1216
+ try {
1217
+ const url = this._collectionUrl();
1218
+ const separator = url.includes('?') ? '&' : '?';
1219
+ const body = data || this.cleanForSave(this.toSave() || JSON.parse(JSON.stringify(this)), true);
1220
+
1221
+ return this.$dispatch('request', {
1222
+ opt: {
1223
+ method: 'post',
1224
+ url: `${ url }${ separator }dryRun=All`,
1225
+ data: body,
1226
+ headers: {
1227
+ 'content-type': 'application/json',
1228
+ accept: 'application/json'
1229
+ }
1230
+ },
1231
+ type: this.type
1232
+ });
1233
+ } catch (e) {
1234
+ return Promise.reject(e);
1235
+ }
1236
+ }
1237
+
1194
1238
  /**
1195
1239
  * Remove any unwanted properties from the object that will be saved
1196
1240
  */
@@ -1219,20 +1263,16 @@ export default class Resource {
1219
1263
  if ( this.metadata?.resourceVersion ) {
1220
1264
  this.metadata.resourceVersion = `${ this.metadata.resourceVersion }`;
1221
1265
  }
1222
-
1223
- if ( !opt.url ) {
1224
- if ( forNew ) {
1225
- const schema = this.$getters['schemaFor'](this.type);
1226
- let url = schema.linkFor('collection');
1227
-
1228
- if ( schema.attributes && schema.attributes.namespaced && this.metadata && this.metadata.namespace ) {
1229
- url += `/${ this.metadata.namespace }`;
1266
+ try {
1267
+ if ( !opt.url ) {
1268
+ if ( forNew ) {
1269
+ opt.url = this._collectionUrl();
1270
+ } else {
1271
+ opt.url = this.linkFor('update') || this.linkFor('self');
1230
1272
  }
1231
-
1232
- opt.url = url;
1233
- } else {
1234
- opt.url = this.linkFor('update') || this.linkFor('self');
1235
1273
  }
1274
+ } catch (e) {
1275
+ return Promise.reject(e);
1236
1276
  }
1237
1277
 
1238
1278
  if ( !opt.method ) {
@@ -77,6 +77,16 @@ export default defineComponent({
77
77
  disabled: {
78
78
  type: Boolean,
79
79
  default: false
80
+ },
81
+
82
+ /**
83
+ * Recalculate the height when the value is changed programmatically (e.g.
84
+ * populated from a file) and when the window is resized, not just on user
85
+ * input. Opt-in to avoid changing the behaviour of existing usages.
86
+ */
87
+ resizeOnValueChangeAndResizeWindow: {
88
+ type: Boolean,
89
+ default: false
80
90
  }
81
91
  },
82
92
 
@@ -117,6 +127,14 @@ export default defineComponent({
117
127
  },
118
128
 
119
129
  watch: {
130
+ // Recalculate the height when the value is changed programmatically (e.g.
131
+ // populated from a file), not just on user input. Opt-in via resizeOnValueChangeAndResizeWindow.
132
+ value() {
133
+ if (this.resizeOnValueChangeAndResizeWindow) {
134
+ this.queueResize();
135
+ }
136
+ },
137
+
120
138
  $attrs: {
121
139
  deep: true,
122
140
  handler() {
@@ -134,6 +152,18 @@ export default defineComponent({
134
152
  this.$nextTick(() => {
135
153
  this.autoSize();
136
154
  });
155
+
156
+ // Width changes alter text wrapping, so the required height can change when
157
+ // the window is resized. Opt-in via resizeOnValueChangeAndResizeWindow.
158
+ if (this.resizeOnValueChangeAndResizeWindow) {
159
+ window.addEventListener('resize', this.queueResize);
160
+ }
161
+ },
162
+
163
+ beforeUnmount() {
164
+ if (this.resizeOnValueChangeAndResizeWindow) {
165
+ window.removeEventListener('resize', this.queueResize);
166
+ }
137
167
  },
138
168
 
139
169
  methods: {
@@ -0,0 +1,95 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import TextAreaAutoGrow from '@components/Form/TextArea/TextAreaAutoGrow.vue';
3
+
4
+ describe('component: TextAreaAutoGrow', () => {
5
+ it('should recalculate its height when the value changes programmatically and resizeOnValueChangeAndResizeWindow is set', async() => {
6
+ const wrapper = mount(TextAreaAutoGrow, { props: { value: 'initial', resizeOnValueChangeAndResizeWindow: true } });
7
+
8
+ // queueResize is the (debounced) entrypoint that triggers autoSize
9
+ const queueResize = jest.fn();
10
+
11
+ wrapper.vm.queueResize = queueResize;
12
+
13
+ await wrapper.setProps({ value: 'a\nmuch\nlonger\nvalue\nset\nfrom\noutside' });
14
+
15
+ expect(queueResize).toHaveBeenCalledWith();
16
+ });
17
+
18
+ it('should not recalculate its height on programmatic value change by default', async() => {
19
+ const wrapper = mount(TextAreaAutoGrow, { props: { value: 'initial' } });
20
+
21
+ const queueResize = jest.fn();
22
+
23
+ wrapper.vm.queueResize = queueResize;
24
+
25
+ await wrapper.setProps({ value: 'a\nmuch\nlonger\nvalue\nset\nfrom\noutside' });
26
+
27
+ expect(queueResize).not.toHaveBeenCalled();
28
+ });
29
+
30
+ it('should recalculate its height on user input', async() => {
31
+ const wrapper = mount(TextAreaAutoGrow, { props: { value: '' } });
32
+
33
+ const queueResize = jest.fn();
34
+
35
+ wrapper.vm.queueResize = queueResize;
36
+
37
+ await wrapper.find('textarea').setValue('typed value');
38
+
39
+ expect(queueResize).toHaveBeenCalledWith();
40
+ });
41
+
42
+ it('should register a window resize listener when resizeOnValueChangeAndResizeWindow is set', () => {
43
+ const addSpy = jest.spyOn(window, 'addEventListener');
44
+
45
+ const wrapper = mount(TextAreaAutoGrow, { props: { value: 'initial', resizeOnValueChangeAndResizeWindow: true } });
46
+
47
+ const resizeListener = addSpy.mock.calls.find(([event]) => event === 'resize')?.[1];
48
+
49
+ expect(resizeListener).toBe(wrapper.vm.queueResize);
50
+
51
+ addSpy.mockRestore();
52
+ });
53
+
54
+ it('should not register a window resize listener by default', () => {
55
+ const addSpy = jest.spyOn(window, 'addEventListener');
56
+
57
+ mount(TextAreaAutoGrow, { props: { value: 'initial' } });
58
+
59
+ const resizeListener = addSpy.mock.calls.find(([event]) => event === 'resize')?.[1];
60
+
61
+ expect(resizeListener).toBeUndefined();
62
+
63
+ addSpy.mockRestore();
64
+ });
65
+
66
+ it('should recalculate its height when a window resize fires and resizeOnValueChangeAndResizeWindow is set', () => {
67
+ jest.useFakeTimers();
68
+ const component = TextAreaAutoGrow as unknown as { methods: Record<string, () => void> };
69
+ const autoSizeSpy = jest.spyOn(component.methods, 'autoSize');
70
+
71
+ mount(TextAreaAutoGrow, { props: { value: 'initial', resizeOnValueChangeAndResizeWindow: true } });
72
+ autoSizeSpy.mockClear();
73
+
74
+ window.dispatchEvent(new Event('resize'));
75
+ jest.advanceTimersByTime(150); // queueResize is debounced (100ms)
76
+
77
+ expect(autoSizeSpy).toHaveBeenCalledWith(expect.any(Event));
78
+
79
+ autoSizeSpy.mockRestore();
80
+ jest.useRealTimers();
81
+ });
82
+
83
+ it('should remove the window resize listener when unmounted', () => {
84
+ const removeSpy = jest.spyOn(window, 'removeEventListener');
85
+
86
+ const wrapper = mount(TextAreaAutoGrow, { props: { value: 'initial', resizeOnValueChangeAndResizeWindow: true } });
87
+ const { queueResize } = wrapper.vm;
88
+
89
+ wrapper.unmount();
90
+
91
+ expect(removeSpy).toHaveBeenCalledWith('resize', queueResize);
92
+
93
+ removeSpy.mockRestore();
94
+ });
95
+ });
@@ -1,2 +1,2 @@
1
1
  export { default as RcButton } from './RcButton.vue';
2
- export type { RcButtonType } from './types';
2
+ export type { RcButtonType, ButtonSize } from './types';
@@ -3,9 +3,13 @@
3
3
  * A button that opens a menu. Used in conjunction with `RcDropdown.vue`.
4
4
  */
5
5
  import { inject, onMounted, ref } from 'vue';
6
- import { RcButton, RcButtonType } from '@components/RcButton';
6
+ import { RcButton, RcButtonType, ButtonSize } from '@components/RcButton';
7
7
  import { DropdownContext, defaultContext } from './types';
8
8
 
9
+ defineProps<{
10
+ size?: ButtonSize,
11
+ }>();
12
+
9
13
  const {
10
14
  showMenu,
11
15
  registerTrigger,
@@ -32,6 +36,7 @@ defineExpose({ focus });
32
36
  role="button"
33
37
  aria-haspopup="menu"
34
38
  :aria-expanded="isMenuOpen"
39
+ :size="size"
35
40
  @keydown.enter.space="handleKeydown"
36
41
  @click="showMenu(true)"
37
42
  >