@rancher/shell 3.0.12-rc.4 → 3.0.12-rc.6
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/resources/__tests__/resources-api-class.test.ts +309 -28
- package/apis/resources/resources-api-class.ts +232 -43
- package/assets/images/providers/traefik.png +0 -0
- package/assets/styles/global/_button.scss +1 -1
- package/assets/translations/en-us.yaml +49 -14
- 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/ActionDropdownShell.vue +5 -3
- package/components/ButtonGroup.vue +26 -1
- package/components/CruResource.vue +51 -2
- package/components/EtcdInfoBanner.vue +2 -12
- package/components/GrafanaDashboard.vue +2 -8
- package/components/PromptRestore.vue +93 -32
- package/components/Questions/index.vue +1 -0
- package/components/Resource/Detail/Masthead/__tests__/index.test.ts +68 -14
- package/components/Resource/Detail/Masthead/index.vue +8 -3
- 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/Security.vue +1 -1
- 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/formatter/Translate.vue +22 -1
- package/components/nav/Group.vue +5 -10
- package/components/nav/Header.vue +8 -1
- package/components/nav/TopLevelMenu.vue +1 -30
- package/components/nav/__tests__/Group.test.ts +61 -0
- package/components/templates/default.vue +7 -0
- package/config/features.js +1 -0
- package/config/labels-annotations.js +3 -0
- package/config/product/manager.js +6 -0
- package/config/secret.ts +10 -0
- package/config/settings.ts +6 -2
- package/config/table-headers.js +5 -2
- package/config/types.js +7 -0
- 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/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 +79 -5
- 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/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/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/index.vue +1 -1
- package/edit/provisioning.cattle.io.cluster/rke2.vue +6 -2
- package/edit/provisioning.cattle.io.cluster/tabs/etcd/index.vue +0 -1
- package/edit/provisioning.cattle.io.cluster/tabs/networking/index.vue +2 -2
- package/edit/provisioning.cattle.io.cluster/tabs/registries/index.vue +14 -55
- 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__/provisioning.cattle.io.cluster.test.ts +156 -0
- package/models/__tests__/rke-machine.cattle.io.vmwarevspheremachinetemplate.test.ts +22 -0
- package/models/__tests__/secret.test.ts +68 -1
- package/models/management.cattle.io.cluster.js +21 -3
- package/models/pod.js +25 -12
- package/models/provisioning.cattle.io.cluster.js +59 -9
- package/models/rke-machine.cattle.io.vmwarevspheremachinetemplate.js +9 -0
- 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/auth/login.vue +1 -1
- package/pages/c/_cluster/apps/charts/__tests__/install.test.ts +569 -81
- package/pages/c/_cluster/apps/charts/install.vue +143 -28
- package/pages/c/_cluster/auth/config/index.vue +11 -4
- package/pages/c/_cluster/monitoring/index.vue +6 -1
- 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 +62 -15
- 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/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__/auth.test.ts +18 -0
- 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__/notifications.test.ts +466 -1
- package/store/__tests__/slideInPanel.test.ts +88 -0
- package/store/__tests__/type-map.utils.test.ts +433 -0
- package/store/__tests__/ui-context.test.ts +255 -0
- package/store/auth.js +11 -1
- package/store/features.js +5 -0
- package/store/plugins.js +6 -0
- package/types/rancher/steve.api.ts +2 -2
- package/types/shell/index.d.ts +120 -0
- package/types/store/dashboard-store.types.ts +2 -2
- package/types/store/type-map.ts +262 -1
- package/utils/__tests__/operation-cr.test.ts +34 -0
- package/utils/async.ts +2 -0
- package/utils/grafana.js +2 -17
- package/utils/monitoring.js +38 -1
- 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
- package/core/__tests__/plugin-products.test.ts +0 -4694
|
@@ -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';
|
|
@@ -29,7 +29,11 @@ export default defineComponent({
|
|
|
29
29
|
...labeledFormElementProps,
|
|
30
30
|
/**
|
|
31
31
|
* The type of the Labeled Input.
|
|
32
|
-
*
|
|
32
|
+
*
|
|
33
|
+
* Any native HTML input type is passed through to the underlying input
|
|
34
|
+
* (e.g. text, password, number, email). A few custom values change the
|
|
35
|
+
* rendering or behaviour:
|
|
36
|
+
* @values cron (renders as text), multiline, multiline-password, integer (renders as text with inputmode="numeric", blocks non-integer input)
|
|
33
37
|
*/
|
|
34
38
|
type: {
|
|
35
39
|
type: String,
|
|
@@ -150,6 +154,23 @@ export default defineComponent({
|
|
|
150
154
|
});
|
|
151
155
|
|
|
152
156
|
const effectiveStatus = computed(() => props.status);
|
|
157
|
+
const isInteger = computed(() => props.type === 'integer');
|
|
158
|
+
const nativeType = computed(() => {
|
|
159
|
+
if (props.type === 'cron') {
|
|
160
|
+
return 'text';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (props.type === 'integer') {
|
|
164
|
+
return 'text';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return props.type;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Hints the mobile keyboard to show a numeric layout, since the
|
|
171
|
+
// integer type renders as type="text" to avoid browser number
|
|
172
|
+
// formatting quirks (e.g. scientific notation for large values).
|
|
173
|
+
const inputMode = computed(() => (isInteger.value ? 'numeric' : undefined));
|
|
153
174
|
|
|
154
175
|
return {
|
|
155
176
|
focused,
|
|
@@ -163,6 +184,9 @@ export default defineComponent({
|
|
|
163
184
|
veeHandleBlur,
|
|
164
185
|
veeValidate,
|
|
165
186
|
effectiveStatus,
|
|
187
|
+
isInteger,
|
|
188
|
+
nativeType,
|
|
189
|
+
inputMode,
|
|
166
190
|
};
|
|
167
191
|
},
|
|
168
192
|
|
|
@@ -325,6 +349,59 @@ export default defineComponent({
|
|
|
325
349
|
}
|
|
326
350
|
},
|
|
327
351
|
|
|
352
|
+
isValidIntegerInput(inputValue: string): boolean {
|
|
353
|
+
const integer = /^-?[0-9]*$/;
|
|
354
|
+
const nonNegativeInteger = /^[0-9]*$/;
|
|
355
|
+
const pattern = Number(this.$attrs.min) >= 0 ? nonNegativeInteger : integer;
|
|
356
|
+
|
|
357
|
+
if (!pattern.test(inputValue)) {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const numeric = Number(inputValue);
|
|
362
|
+
|
|
363
|
+
// Safe integer is primarily here to validate that the number fits in the datatype
|
|
364
|
+
// used for numbers in the browser, if we exceed this safe number the browser automatically converts the number to scientific notation.
|
|
365
|
+
if (inputValue !== '' && inputValue !== '-' && !Number.isSafeInteger(numeric)) {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return true;
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
prospectiveValue(input: HTMLInputElement, inserted: string): string {
|
|
373
|
+
const start = input.selectionStart ?? 0;
|
|
374
|
+
const end = input.selectionEnd ?? 0;
|
|
375
|
+
|
|
376
|
+
return input.value.slice(0, start) + inserted + input.value.slice(end);
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
onKeydown(event: KeyboardEvent): void {
|
|
380
|
+
// Skip named keys (Backspace, Arrow, etc.) and modifier shortcuts (Ctrl+A, Cmd+C).
|
|
381
|
+
if (!this.isInteger || event.key.length !== 1 || event.ctrlKey || event.metaKey) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const next = this.prospectiveValue(event.target as HTMLInputElement, event.key);
|
|
386
|
+
|
|
387
|
+
if (!this.isValidIntegerInput(next)) {
|
|
388
|
+
event.preventDefault();
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
onPaste(event: ClipboardEvent): void {
|
|
393
|
+
if (!this.isInteger) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const paste = event.clipboardData?.getData('text/plain') ?? '';
|
|
398
|
+
const next = this.prospectiveValue(event.target as HTMLInputElement, paste);
|
|
399
|
+
|
|
400
|
+
if (!this.isValidIntegerInput(next)) {
|
|
401
|
+
event.preventDefault();
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
|
|
328
405
|
/**
|
|
329
406
|
* Emit on input change
|
|
330
407
|
*/
|
|
@@ -435,14 +512,15 @@ export default defineComponent({
|
|
|
435
512
|
:id="inputId"
|
|
436
513
|
ref="value"
|
|
437
514
|
v-stripped-aria-label="!hasLabel && ariaLabel ? ariaLabel : undefined"
|
|
438
|
-
:role="
|
|
515
|
+
:role="nativeType === 'number' ? undefined : 'textbox'"
|
|
439
516
|
:class="{ 'no-label': !hasLabel }"
|
|
440
517
|
v-bind="$attrs"
|
|
441
518
|
:name="name || undefined"
|
|
442
519
|
:maxlength="_maxlength"
|
|
443
520
|
:disabled="isDisabled"
|
|
444
521
|
:aria-disabled="isDisabled"
|
|
445
|
-
:type="
|
|
522
|
+
:type="nativeType"
|
|
523
|
+
:inputmode="inputMode"
|
|
446
524
|
:value="value"
|
|
447
525
|
:placeholder="_placeholder"
|
|
448
526
|
autocomplete="off"
|
|
@@ -451,6 +529,8 @@ export default defineComponent({
|
|
|
451
529
|
:aria-describedby="ariaDescribedBy"
|
|
452
530
|
:aria-required="requiredField"
|
|
453
531
|
@input="onInput"
|
|
532
|
+
@keydown="onKeydown"
|
|
533
|
+
@paste="onPaste"
|
|
454
534
|
@focus="onFocus"
|
|
455
535
|
@blur="onBlur"
|
|
456
536
|
@change="onChange"
|
|
@@ -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
|
>
|
|
@@ -134,3 +134,21 @@ describe('action: test', () => {
|
|
|
134
134
|
});
|
|
135
135
|
});
|
|
136
136
|
});
|
|
137
|
+
|
|
138
|
+
describe('action: getLocalProviderEnabled', () => {
|
|
139
|
+
it('should return true if local auth provider exists', async() => {
|
|
140
|
+
const dispatch = jest.fn().mockResolvedValue({ id: 'local' });
|
|
141
|
+
const result = await actions.getLocalProviderEnabled({ dispatch } as any);
|
|
142
|
+
|
|
143
|
+
expect(dispatch).toHaveBeenCalledWith('getAuthProvider', 'local');
|
|
144
|
+
expect(result).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should return false if local auth provider does not exist', async() => {
|
|
148
|
+
const dispatch = jest.fn().mockResolvedValue(undefined);
|
|
149
|
+
const result = await actions.getLocalProviderEnabled({ dispatch } as any);
|
|
150
|
+
|
|
151
|
+
expect(dispatch).toHaveBeenCalledWith('getAuthProvider', 'local');
|
|
152
|
+
expect(result).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|