Skip to content

Commit ebfd4cc

Browse files
Changed navlink page and improved error handling for FileImageUpload (#9411)
1 parent bac35d3 commit ebfd4cc

File tree

12 files changed

+481
-200
lines changed

12 files changed

+481
-200
lines changed

docusaurus/docs/code-base-works/forms-and-validation.md

+2
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ While individual fields are typically responsible for displaying their own valid
7070
To use the `form-validation` mixin, import the mixin and include it in `mixins` in the component like any other mixin. Once included, ensure the following:
7171
1. Set the "validation-passed" property on the `CruResource` component to "fvFormIsValid" (computed property provided by the mixin). This conditionally disables the "save" button on the form.
7272
2. Set the "errors" property on the `CruResource` component to "fvUnreportedValidationErrors" (computed property provided by the mixin) or some other value that aggregates errors not otherwise shown in the form as a fallback means of displaying error state to the user.
73+
3. Add ":rules" to the input component to bind validation rule to the field.
74+
7375

7476
The `form-validation` mixin itself includes most of the information a developer will need to use it in comments in the file itself but a high-level summary would cover the following points:
7577

jest.config.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ module.exports = {
1818
transform: {
1919
'^.+\\.js$': '<rootDir>/node_modules/babel-jest', // process js with `babel-jest`
2020
'.*\\.(vue)$': '<rootDir>/node_modules/@vue/vue2-jest', // process `*.vue` files with `vue-jest`
21-
'^.+\\.tsx?$': 'ts-jest' // process `*.ts` files with `ts-jest`
21+
'^.+\\.tsx?$': 'ts-jest', // process `*.ts` files with `ts-jest`
22+
'^.+\\.svg$': '<rootDir>/svgTransform.js' // to mock `*.svg` files
2223
},
2324
snapshotSerializers: ['<rootDir>/node_modules/jest-serializer-vue'],
2425
collectCoverage: false,

shell/assets/translations/en-us.yaml

+4-1
Original file line numberDiff line numberDiff line change
@@ -3512,10 +3512,13 @@ navLink:
35123512
label: Group name
35133513
tooltip: Assign link to a group
35143514
sideLabel:
3515-
label: Link Label
3515+
label: Link label
35163516
description:
35173517
label: Link description
3518+
groupImage:
3519+
label: Group Image
35183520
iconSrc:
3521+
tip: 'Image height should be 21 pixels with a max width of 200 pixels. Max file size is 20KB. Accepted formats: JPEG, PNG, SVG.'
35193522
label: Add image
35203523
networkpolicy:
35213524
egress:

shell/components/LazyImage.vue

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ export default {
33
props: {
44
initialSrc: {
55
type: String,
6-
default: require('~shell/assets/images/generic-catalog.svg'),
6+
default: require('@shell/assets/images/generic-catalog.svg'),
77
},
88
99
errorSrc: {
1010
type: String,
11-
default: require('~shell/assets/images/generic-catalog.svg'),
11+
default: require('@shell/assets/images/generic-catalog.svg'),
1212
},
1313
1414
src: {

shell/components/form/FileImageSelector.vue

+9
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ export default {
3131
type: Number,
3232
default: 200000
3333
},
34+
accept: {
35+
type: String,
36+
default: 'image/*'
37+
}
3438
},
3539
computed: {
3640
isView() {
@@ -44,6 +48,9 @@ export default {
4448
*/
4549
setIcon(event) {
4650
this.$emit('input', event);
51+
},
52+
setError(error) {
53+
this.$emit('error', error);
4754
}
4855
}
4956
};
@@ -58,7 +65,9 @@ export default {
5865
:read-as-data-url="true"
5966
:byte-limit="byteLimit"
6067
:label="label"
68+
:accept="accept"
6169
@selected="setIcon"
70+
@error="setError"
6271
/>
6372

6473
<div

shell/components/form/FileSelector.vue

+2-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ export default {
6363
accept: {
6464
type: String,
6565
default: '*'
66-
}
66+
},
67+
6768
},
6869
6970
computed: {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/* eslint-disable jest/no-hooks */
2+
import FileImageSelector from '@shell/components/form/FileImageSelector';
3+
import { mount } from '@vue/test-utils';
4+
import FileSelector from '@shell/components/form/FileSelector';
5+
6+
describe('component: FileImageSelector', () => {
7+
let wrapper: any;
8+
9+
beforeEach(() => {
10+
wrapper = mount(FileImageSelector, {
11+
propsData: { label: 'upload' },
12+
mocks: {},
13+
methods: {},
14+
});
15+
});
16+
17+
afterEach(() => {
18+
wrapper.destroy();
19+
});
20+
21+
it('should render', () => {
22+
const uploadButton = wrapper.find('.btn');
23+
24+
expect(wrapper.isVisible()).toBe(true);
25+
expect(uploadButton.exists()).toBeTruthy();
26+
});
27+
it('should throw error if file could not be uploaded', async() => {
28+
const fs = wrapper.findComponent(FileSelector);
29+
30+
expect(fs.exists()).toBeTruthy();
31+
await fs.vm.$emit('error');
32+
33+
expect(wrapper.emitted('error')).toHaveLength(1);
34+
});
35+
36+
it('should emit input on image upload', async() => {
37+
const fs = wrapper.findComponent(FileSelector);
38+
39+
await fs.vm.$emit('selected');
40+
expect(wrapper.emitted('input')).toHaveLength(1);
41+
});
42+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/* eslint-disable jest/no-hooks */
2+
import FileSelector from '@shell/components/form/FileSelector';
3+
import { mount } from '@vue/test-utils';
4+
5+
describe('component: FileSelector', () => {
6+
let wrapper: any;
7+
8+
beforeEach(() => {
9+
jest.restoreAllMocks();
10+
});
11+
afterEach(() => {
12+
wrapper.destroy();
13+
});
14+
15+
const binaryString = Buffer.from('/9j/4AAQSkZJRgABAQAASABIAAD/4QCARXhpZgAATU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAABIAAAAAQAAAEgAAAABAAKgAgAEAAAAAQAAADmgAwAEAAAAAQAAAFEAAAAA/+0AOFBob3Rvc2hvcCAzLjAAOEJJTQQEAAAAAAAAOEJJTQQlAAAAAAAQ1B2M2Y8AsgTpgAmY7PhCfv/iAihJQ0NfUFJPRklMRQABAQAAAhgAAAAABDAAAG1udHJSR0IgWFlaIAAAAAAAAAAAAAAAAGFjc3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD21gABAAAAANMtAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACWRlc2MAAADwAAAAdHJYWVoAAAFkAAAAFGdYWVoAAAF4AAAAFGJYWVoAAAGMAAAAFHJUUkMAAAGgAAAAKGdUUkMAAAGgAAAAKGJUUkMAAAGgAAAAKHd0cHQAAAHIAAAAFGNwcnQAAAHcAAAAPG1sdWMAAAAAAAAAAQAAAAxlblVTAAAAWAAAABwAcwBSAEcAQgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWFlaIAAAAAAAAG+iAAA49QAAA5BYWVogAAAAAAAAYpkAALeFAAAY2lhZWiAAAAAAAAAkoAAAD4QAALbPcGFyYQAAAAAABAAAAAJmZgAA8qcAAA1ZAAAT0AAAClsAAAAAAAAAAFhZWiAAAAAAAAD21gABAAAAANMtbWx1YwAAAAAAAAABAAAADGVuVVMAAAAgAAAAHABHAG8AbwBnAGwAZQAgAEkAbgBjAC4AIAAyADAAMQA2/8AAEQgAUQA5AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAEAsMDgwKEA4NDhIREBMYKBoYFhYYMSMlHSg6Mz08OTM4N0BIXE5ARFdFNzhQbVFXX2JnaGc+TXF5cGR4XGVnY//bAEMBERISGBUYLxoaL2NCOEJjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY//dAAQABP/aAAwDAQACEQMRAD8AdRRRX0B4gUUUUAFFFFABRRRQB//QdRRRX0B4gUUUUAFFFFABRRRQB//RdRRRX0B4gUUUUAFFFFABRRRQB//SdRRRX0B4gUUUUAFFFFABRRRQB//TdRRRX0B4gUUUUAFFFFAwooooA//UdRRRX0B4gUUUUAFFFFAwooooA//Z', 'base64'); // Binary data string
16+
const jpegBlobFile = new Blob([binaryString], { type: 'image/jpeg' });
17+
const obj = { hello: 'world' };
18+
const jsonBlobFile = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' });
19+
20+
it('should render', () => {
21+
wrapper = mount(FileSelector, {
22+
propsData: { label: 'upload' },
23+
mocks: {},
24+
methods: {},
25+
});
26+
27+
const uploadButton = wrapper.find('.btn');
28+
29+
expect(wrapper.isVisible()).toBe(true);
30+
expect(uploadButton.exists()).toBeTruthy();
31+
});
32+
33+
it('should succeed when loading an image', async() => {
34+
wrapper = mount(FileSelector, {
35+
propsData: { label: 'upload', accept: 'image/jpeg,image/png,image/svg+xml' },
36+
mocks: {},
37+
methods: {},
38+
});
39+
const readAsTextSpy = jest.spyOn(FileReader.prototype, 'readAsText');
40+
41+
const event = {
42+
target: {
43+
files: [
44+
jpegBlobFile
45+
]
46+
}
47+
};
48+
49+
await wrapper.vm.fileChange(event);
50+
expect(wrapper.emitted('selected')).toHaveLength(1);
51+
expect(readAsTextSpy).toHaveBeenCalledWith(jpegBlobFile);
52+
});
53+
54+
it('should fail when file is too big', async() => {
55+
wrapper = mount(FileSelector, {
56+
propsData: {
57+
label: 'upload', accept: 'image/jpeg,image/png,image/svg+xml', byteLimit: 10
58+
},
59+
mocks: {},
60+
methods: {},
61+
});
62+
const readAsTextSpy = jest.spyOn(FileReader.prototype, 'readAsText');
63+
64+
const event = {
65+
target: {
66+
files: [
67+
jpegBlobFile
68+
]
69+
}
70+
};
71+
72+
await wrapper.vm.fileChange(event);
73+
expect(wrapper.emitted('error')).toHaveLength(1);
74+
expect(readAsTextSpy).not.toHaveBeenCalledWith(jsonBlobFile);
75+
});
76+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/* eslint-disable jest/no-hooks */
2+
import { mount } from '@vue/test-utils';
3+
import Navlink from '@shell/edit/ui.cattle.io.navlink.vue';
4+
import { _CREATE } from '@shell/config/query-params';
5+
import CruResource from '@shell/components/CruResource';
6+
7+
describe('view: ui.cattle.io.navlink should', () => {
8+
const name = 'test';
9+
const url = 'http://test.com';
10+
let wrapper: any;
11+
12+
const requiredSetup = () => ({
13+
// Remove all these mocks after migration to Vue 2.7/3 due mixin logic
14+
mocks: {
15+
$store: {
16+
getters: {
17+
currentStore: () => 'current_store',
18+
'current_store/schemaFor': jest.fn(),
19+
'current_store/all': jest.fn(),
20+
'i18n/t': (val) => val,
21+
'i18n/exists': jest.fn(),
22+
}
23+
},
24+
$route: { query: { AS: '' } },
25+
$router: { applyQuery: jest.fn() },
26+
},
27+
propsData: {
28+
metadata: { namespace: 'test' },
29+
spec: { template: {} },
30+
targetInfo: { mode: 'all' },
31+
value: {},
32+
mode: _CREATE,
33+
},
34+
35+
});
36+
37+
beforeEach(() => {
38+
wrapper = mount(Navlink, { ...requiredSetup() });
39+
});
40+
41+
afterEach(() => {
42+
wrapper.destroy();
43+
});
44+
45+
it('have "Create" button disabled before fields are filled in', () => {
46+
const saveButton = wrapper.find('[data-testid="form-save"]').element as HTMLInputElement;
47+
48+
expect(saveButton.disabled).toBe(true);
49+
});
50+
it('have "Create" button disabled when Link type is URL and only name is filled in', async() => {
51+
const saveButton = wrapper.find('[data-testid="form-save"]').element as HTMLInputElement;
52+
const nameField = wrapper.find('[data-testid="Navlink-name-field"]').find('input');
53+
54+
nameField.setValue(name);
55+
56+
await wrapper.vm.$nextTick();
57+
58+
expect(saveButton.disabled).toBe(true);
59+
});
60+
it('have "Create" button enabled when Link type is URL and all required fields are filled in', async() => {
61+
const saveButton = wrapper.find('[data-testid="form-save"]').element as HTMLInputElement;
62+
const nameField = wrapper.find('[data-testid="Navlink-name-field"]').find('input');
63+
const urlField = wrapper.find('[data-testid="Navlink-url-field"]');
64+
65+
nameField.setValue(name);
66+
urlField.setValue(url);
67+
68+
await wrapper.vm.$nextTick();
69+
70+
expect(saveButton.disabled).toBe(false);
71+
});
72+
73+
it('have "Create" button disabled when Link type is Service and and only name is filled in', async() => {
74+
const saveButton = wrapper.find('[data-testid="form-save"]').element as HTMLInputElement;
75+
const nameField = wrapper.find('[data-testid="Navlink-name-field"]').find('input');
76+
const rg = wrapper.find('[data-testid="Navlink-link-radiogroup"]');
77+
78+
const serviceBttn = rg.findAll('.radio-label').at(1);
79+
80+
nameField.setValue(name);
81+
serviceBttn.trigger('click');
82+
await wrapper.vm.$nextTick();
83+
84+
expect(saveButton.disabled).toBe(true);
85+
});
86+
87+
it('have "Create" button enabled when Link type is Service and and all required fields are filled in', async() => {
88+
const nameField = wrapper.find('[data-testid="Navlink-name-field"]').find('input');
89+
const rg = wrapper.find('[data-testid="Navlink-link-radiogroup"]');
90+
91+
const serviceBttn = rg.findAll('.radio-label').at(1);
92+
93+
nameField.setValue(name);
94+
serviceBttn.trigger('click');
95+
await wrapper.vm.$nextTick();
96+
97+
const schemeField = wrapper.find('[data-testid="Navlink-scheme-field"]');
98+
const serviceField = wrapper.find('[data-testid="Navlink-currentService-field"]');
99+
100+
schemeField.find('button').trigger('click');
101+
await wrapper.trigger('keydown.down');
102+
await wrapper.trigger('keydown.enter');
103+
104+
serviceField.find('button').trigger('click');
105+
await wrapper.trigger('keydown.down');
106+
await wrapper.trigger('keydown.enter');
107+
108+
expect(CruResource.computed.canSave()).toBe(true);
109+
});
110+
});

0 commit comments

Comments
 (0)