Skip to content

Commit e9db9d1

Browse files
committed
LITE-29837: Add input validation to Textfield and Select
- Create useFieldValidation composable to validate input fields against rules - Implement validation in Textfield and Select components - Other Textfield fixes: fixed focused and label styles, added hint, fixed focus behavior - Other Select fixes: fixed focused styles, added customization to option rendering, add menuProps - Emit "opened" and "closed" when Menu widget is toggled - Add new stories for Textfield and select
1 parent 4025cdd commit e9db9d1

14 files changed

+559
-27
lines changed

.storybook/main.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ module.exports = {
5656
'~core': path.resolve(__dirname, '../components/src/core'),
5757
'~widgets': path.resolve(__dirname, '../components/src/widgets'),
5858
'~constants': path.resolve(__dirname, '../components/src/constants'),
59+
'~composables': path.resolve(__dirname, '../components/src/composables'),
5960
};
6061

6162
return config;

components/jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ module.exports = {
1717
'^~widgets/(.*)$': '<rootDir>./src/widgets/$1',
1818
'^~core/(.*)$': '<rootDir>./src/core/$1',
1919
'^~constants/(.*)$': '<rootDir>./src/constants/$1',
20+
'^~composables/(.*)$': '<rootDir>./src/composables/$1',
2021
// This replaces import of files from @cloudblueconnect/material-svg in .spec.js files to optimize the run time of all unit tests
2122
'^.+\\.svg$': '<rootDir>/test/helpers/svgMock.js',
2223
},
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { computed, ref, watch } from 'vue';
2+
3+
export const useFieldValidation = (model, rules) => {
4+
const isValid = ref(true);
5+
const errorMessages = ref([]);
6+
7+
const errorMessagesString = computed(() => {
8+
if (errorMessages.value.length) return `${errorMessages.value.join('. ')}.`;
9+
10+
return '';
11+
});
12+
13+
const validateField = (value) => {
14+
const results = rules.map((rule) => rule(value));
15+
16+
if (results.every((result) => result === true)) {
17+
errorMessages.value = [];
18+
isValid.value = true;
19+
} else {
20+
errorMessages.value = results.filter((result) => typeof result === 'string');
21+
isValid.value = false;
22+
}
23+
};
24+
25+
watch(model, validateField);
26+
27+
return { isValid, errorMessages, errorMessagesString, validateField };
28+
};
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { ref, nextTick } from 'vue';
2+
3+
import { useFieldValidation } from './validation';
4+
5+
describe('validation composables', () => {
6+
describe('useFieldValidation', () => {
7+
let model;
8+
let rule;
9+
let rules;
10+
let instance;
11+
12+
beforeEach(() => {
13+
model = ref('');
14+
rule = jest.fn().mockReturnValue(true);
15+
rules = [rule];
16+
});
17+
18+
it('returns the required properties', () => {
19+
const { isValid, errorMessages, errorMessagesString, validateField } = useFieldValidation(
20+
model,
21+
rules,
22+
);
23+
24+
expect(isValid.value).toEqual(true);
25+
expect(errorMessages.value).toEqual([]);
26+
expect(errorMessagesString.value).toEqual('');
27+
expect(validateField).toEqual(expect.any(Function));
28+
});
29+
30+
describe('validateField function', () => {
31+
beforeEach(() => {
32+
instance = useFieldValidation(model, rules);
33+
});
34+
35+
it('validates the model value against the rules', () => {
36+
instance.validateField('foo bar baz');
37+
38+
expect(rule).toHaveBeenCalledWith('foo bar baz');
39+
});
40+
41+
describe('if the validation is successful', () => {
42+
beforeEach(() => {
43+
rule.mockReturnValue(true);
44+
45+
instance.validateField('foo bar baz');
46+
});
47+
48+
it('sets isValid to true', () => {
49+
expect(instance.isValid.value).toEqual(true);
50+
});
51+
52+
it('sets errorMessages to an empty array', () => {
53+
expect(instance.errorMessages.value).toEqual([]);
54+
});
55+
});
56+
57+
describe('if the validation fails', () => {
58+
beforeEach(() => {
59+
rule.mockReturnValue('You failed miserably');
60+
61+
instance.validateField('foo bar baz');
62+
});
63+
64+
it('sets isValid to false', () => {
65+
expect(instance.isValid.value).toEqual(false);
66+
});
67+
68+
it('sets errorMessages as an array of all failure messages', () => {
69+
expect(instance.errorMessages.value).toEqual(['You failed miserably']);
70+
});
71+
});
72+
});
73+
74+
describe('when the model value changes', () => {
75+
beforeEach(() => {
76+
instance = useFieldValidation(model, rules);
77+
});
78+
79+
it('validates the model value against the rules', async () => {
80+
model.value = 'foo bar baz';
81+
await nextTick();
82+
83+
expect(rule).toHaveBeenCalledWith('foo bar baz');
84+
});
85+
86+
describe('if the validation is successful', () => {
87+
beforeEach(async () => {
88+
rule.mockReturnValue(true);
89+
90+
model.value = 'foo bar baz';
91+
await nextTick();
92+
});
93+
94+
it('sets isValid to true', () => {
95+
expect(instance.isValid.value).toEqual(true);
96+
});
97+
98+
it('sets errorMessages to an empty array', () => {
99+
expect(instance.errorMessages.value).toEqual([]);
100+
});
101+
});
102+
103+
describe('if the validation fails', () => {
104+
beforeEach(async () => {
105+
rule.mockReturnValue('You failed miserably');
106+
107+
model.value = 'foo bar baz';
108+
await nextTick();
109+
});
110+
111+
it('sets isValid to false', () => {
112+
expect(instance.isValid.value).toEqual(false);
113+
});
114+
115+
it('sets errorMessages as an array of all failure messages', () => {
116+
expect(instance.errorMessages.value).toEqual(['You failed miserably']);
117+
});
118+
});
119+
});
120+
121+
describe('errorMessagesString computed', () => {
122+
let instance;
123+
124+
beforeEach(() => {
125+
instance = useFieldValidation(model, rules);
126+
});
127+
128+
it('returns an empty string if errorMessages is empty', async () => {
129+
instance.errorMessages.value = [];
130+
await nextTick();
131+
132+
expect(instance.errorMessagesString.value).toEqual('');
133+
});
134+
135+
it('returns the joined messages in errorMessages otherwise', async () => {
136+
instance.errorMessages.value = ['Bad value', 'Big mistake here'];
137+
await nextTick();
138+
139+
expect(instance.errorMessagesString.value).toEqual('Bad value. Big mistake here.');
140+
});
141+
});
142+
});
143+
});

components/src/stories/Select.stories.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,25 @@ export const Object = {
3737
},
3838
};
3939

40+
export const Validation = {
41+
name: 'Input validation',
42+
render: Basic.render,
43+
44+
args: {
45+
...Basic.args,
46+
label: 'Select input with validation',
47+
hint: 'Select the second option if you want the validation to be successful',
48+
propValue: 'id',
49+
propText: 'name',
50+
options: [
51+
{ id: 'OBJ-123', name: 'The first object' },
52+
{ id: 'OBJ-456', name: 'The second object' },
53+
{ id: 'OBJ-789', name: 'The third object' },
54+
],
55+
rules: [(value) => value === 'OBJ-456' || 'You picked the wrong option :( '],
56+
},
57+
};
58+
4059
export const Events = {
4160
name: 'Using v-model',
4261
render: (args) => ({
@@ -63,6 +82,45 @@ export const Events = {
6382
args: Basic.args,
6483
};
6584

85+
export const Slots = {
86+
name: 'Custom element render',
87+
render: (args) => ({
88+
setup() {
89+
const selectedItem = ref('');
90+
const setSelectedItem = (event) => {
91+
selectedItem.value = event.detail[0];
92+
};
93+
94+
return { args, selectedItem, setSelectedItem };
95+
},
96+
template: `
97+
<div>
98+
<ui-select
99+
v-bind="args"
100+
:modelValue="selectedItem"
101+
@update:modelValue="setSelectedItem"
102+
style="width:500px;"
103+
>
104+
<span slot="selected">
105+
<template v-if="selectedItem">The current selected value is: {{ selectedItem }}</template>
106+
<template v-else>There is no item selected</template>
107+
</span>
108+
</ui-select>
109+
</div>
110+
`,
111+
}),
112+
args: {
113+
...Basic.args,
114+
label: 'This implementation uses the "selected" slot and the "optionTextFn"',
115+
options: [
116+
{ id: 'OBJ-123', name: 'The first object' },
117+
{ id: 'OBJ-456', name: 'The second object' },
118+
{ id: 'OBJ-789', name: 'The third object' },
119+
],
120+
optionTextFn: (item) => `${item.name} (${item.id})`,
121+
},
122+
};
123+
66124
export default {
67125
title: 'Components/Select',
68126
component: Select,

components/src/stories/TextField.stories.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,44 @@
1+
import isEmail from 'validator/es/lib/isEmail';
2+
13
import cTextField from '~widgets/textfield/widget.vue';
24
import registerWidget from '~core/registerWidget';
35

46
registerWidget('ui-textfield', cTextField);
57

6-
export const Component = {
8+
export const Basic = {
9+
name: 'Basic options',
710
render: (args) => ({
811
setup() {
912
return { args };
1013
},
11-
template: '<ui-textfield v-bind="args"></ui-textfield>',
14+
template: '<ui-textfield v-bind="args" style="width:400px;"></ui-textfield>',
1215
}),
1316

1417
args: {
15-
label: 'Label text',
18+
label: 'Simple textfield',
19+
hint: 'This is a hint for the text field input',
1620
value: '',
1721
placeholder: 'Placeholder text',
1822
suffix: '',
1923
},
2024
};
2125

26+
export const Validation = {
27+
name: 'Input validation',
28+
render: Basic.render,
29+
30+
args: {
31+
label: 'Text field with validation',
32+
hint: 'This is a text field with validation. The value should be an email',
33+
value: '',
34+
placeholder: 'john.doe@example.com',
35+
rules: [
36+
(value) => !!value || 'This field is required',
37+
(value) => isEmail(value) || 'The value is not a valid email address',
38+
],
39+
},
40+
};
41+
2242
export default {
2343
title: 'Components/TextField',
2444
component: cTextField,

components/src/widgets/menu/widget.vue

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ const props = defineProps({
4444
},
4545
});
4646
47+
const emit = defineEmits(['opened', 'closed']);
48+
4749
const showMenu = ref(false);
4850
const menu = ref(null);
4951
@@ -55,17 +57,22 @@ const fullWidthClass = computed(() => (props.fullWidth ? 'menu-content_full-widt
5557
5658
const toggle = () => {
5759
showMenu.value = !showMenu.value;
60+
emit(showMenu.value ? 'opened' : 'closed');
5861
};
5962
6063
const handleClickOutside = (event) => {
6164
const isClickWithinMenuBounds = event.composedPath().some((el) => el === menu.value);
6265
if (!isClickWithinMenuBounds) {
6366
showMenu.value = false;
67+
emit('closed');
6468
}
6569
};
6670
6771
const onClickInside = () => {
68-
if (props.closeOnClickInside) showMenu.value = false;
72+
if (props.closeOnClickInside) {
73+
showMenu.value = false;
74+
emit('closed');
75+
}
6976
};
7077
7178
onMounted(() => {

0 commit comments

Comments
 (0)