@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.
- package/assets/styles/global/_button.scss +1 -1
- package/assets/translations/en-us.yaml +39 -10
- package/components/ActionDropdownShell.vue +5 -3
- package/components/ButtonGroup.vue +26 -1
- package/components/CruResource.vue +51 -2
- package/components/PromptRestore.vue +93 -32
- package/components/Questions/index.vue +1 -0
- package/components/ResourceTable.vue +1 -0
- package/components/SortableTable/index.vue +4 -3
- package/components/Wizard.vue +14 -1
- package/components/__tests__/ButtonGroup.test.ts +56 -0
- package/components/__tests__/PromptRestore.test.ts +169 -19
- package/components/fleet/GitRepoAdvancedTab.vue +1 -0
- package/components/fleet/GitRepoMetadataTab.vue +5 -0
- package/components/fleet/HelmOpAppCoConfigTab.vue +4 -0
- package/components/fleet/HelmOpMetadataTab.vue +5 -0
- package/components/form/FileSelector.vue +39 -1
- package/components/form/PrivateRegistry.constants.ts +7 -0
- package/components/form/PrivateRegistry.vue +253 -18
- package/components/form/SelectOrCreateAuthSecret.vue +140 -17
- package/components/form/__tests__/FileSelector.test.ts +23 -0
- package/components/form/__tests__/PrivateRegistry.test.ts +463 -73
- package/components/form/__tests__/SelectOrCreateAuthSecret.test.ts +122 -0
- package/components/formatter/EtcdSnapshotName.vue +73 -0
- package/components/nav/Header.vue +8 -1
- package/components/templates/default.vue +7 -0
- package/config/features.js +1 -0
- package/config/labels-annotations.js +2 -0
- package/config/product/manager.js +6 -0
- package/config/secret.ts +10 -0
- package/config/settings.ts +6 -2
- package/config/types.js +7 -0
- package/detail/provisioning.cattle.io.cluster.vue +79 -3
- package/dialog/RotateEncryptionKeyDialog.vue +33 -9
- package/dialog/__tests__/RotateEncryptionKeyDialog.test.ts +78 -0
- package/edit/__tests__/fleet.cattle.io.gitrepo.test.ts +92 -0
- package/edit/__tests__/fleet.cattle.io.helmop.test.ts +101 -0
- package/edit/__tests__/management.cattle.io.setting.test.ts +2 -1
- package/edit/compliance.cattle.io.clusterscanprofile.vue +39 -41
- package/edit/fleet.cattle.io.gitrepo.vue +70 -16
- package/edit/fleet.cattle.io.helmop.vue +51 -5
- package/edit/helm.cattle.io.projecthelmchart.vue +1 -0
- package/edit/{management.cattle.io.setting.vue → management.cattle.io.setting/index.vue} +32 -9
- package/edit/management.cattle.io.setting/system-default-registry-pull-secrets.vue +81 -0
- package/edit/provisioning.cattle.io.cluster/SelectCredential.vue +3 -12
- package/edit/provisioning.cattle.io.cluster/__tests__/rke2.test.ts +18 -0
- package/edit/provisioning.cattle.io.cluster/rke2.vue +5 -1
- package/edit/provisioning.cattle.io.cluster/tabs/etcd/index.vue +0 -1
- package/edit/provisioning.cattle.io.cluster/tabs/registries/index.vue +14 -55
- package/models/__tests__/provisioning.cattle.io.cluster.test.ts +156 -0
- package/models/__tests__/secret.test.ts +68 -1
- package/models/management.cattle.io.cluster.js +21 -3
- package/models/pod.js +13 -2
- package/models/provisioning.cattle.io.cluster.js +59 -9
- package/models/rke.cattle.io.etcdsnapshot.js +17 -9
- package/models/secret.js +19 -0
- package/models/workload.js +12 -7
- package/package.json +1 -1
- package/pages/c/_cluster/apps/charts/__tests__/install.test.ts +485 -107
- package/pages/c/_cluster/apps/charts/install.vue +114 -28
- package/pkg/require-asset.lib.js +25 -0
- package/pkg/vue.config.js +7 -0
- package/plugins/dashboard-store/__tests__/resource-class.test.ts +84 -0
- package/plugins/dashboard-store/getters.js +0 -1
- package/plugins/dashboard-store/resource-class.js +52 -12
- package/rancher-components/Form/TextArea/TextAreaAutoGrow.vue +30 -0
- package/rancher-components/Form/TextArea/__tests__/TextAreaAutoGrow.test.ts +95 -0
- package/rancher-components/RcButton/index.ts +1 -1
- package/rancher-components/RcDropdown/RcDropdownTrigger.vue +6 -1
- package/store/__tests__/features.test.ts +131 -0
- package/store/__tests__/growl.test.ts +374 -0
- package/store/__tests__/modal.test.ts +131 -0
- package/store/__tests__/slideInPanel.test.ts +88 -0
- package/store/__tests__/type-map.utils.test.ts +433 -0
- package/store/features.js +4 -0
- package/types/shell/index.d.ts +62 -0
- package/utils/__tests__/operation-cr.test.ts +34 -0
- package/utils/operation-cr.js +19 -0
- package/utils/require-asset.ts +7 -0
- package/utils/validators/__tests__/private-registry.test.ts +27 -15
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
<
|
|
1841
|
+
<PrivateRegistry
|
|
1757
1842
|
v-if="showCustomRegistry"
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
:
|
|
1762
|
-
:
|
|
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
|
});
|
|
@@ -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
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
>
|