diff --git a/.storybook/main.js b/.storybook/main.js index ab788f25..2a699a10 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -56,6 +56,7 @@ module.exports = { '~core': path.resolve(__dirname, '../components/src/core'), '~widgets': path.resolve(__dirname, '../components/src/widgets'), '~constants': path.resolve(__dirname, '../components/src/constants'), + '~composables': path.resolve(__dirname, '../components/src/composables'), }; return config; diff --git a/components/jest.config.js b/components/jest.config.js index 2c1f7a8a..03748f59 100644 --- a/components/jest.config.js +++ b/components/jest.config.js @@ -17,6 +17,7 @@ module.exports = { '^~widgets/(.*)$': './src/widgets/$1', '^~core/(.*)$': './src/core/$1', '^~constants/(.*)$': './src/constants/$1', + '^~composables/(.*)$': './src/composables/$1', // This replaces import of files from @cloudblueconnect/material-svg in .spec.js files to optimize the run time of all unit tests '^.+\\.svg$': '/test/helpers/svgMock.js', }, diff --git a/components/src/composables/validation.js b/components/src/composables/validation.js new file mode 100644 index 00000000..1c84be36 --- /dev/null +++ b/components/src/composables/validation.js @@ -0,0 +1,28 @@ +import { computed, ref, watch } from 'vue'; + +export const useFieldValidation = (model, rules) => { + const isValid = ref(true); + const errorMessages = ref([]); + + const errorMessagesString = computed(() => { + if (errorMessages.value.length) return `${errorMessages.value.join('. ')}.`; + + return ''; + }); + + const validateField = (value) => { + const results = rules.map((rule) => rule(value)); + + if (results.every((result) => result === true)) { + errorMessages.value = []; + isValid.value = true; + } else { + errorMessages.value = results.filter((result) => typeof result === 'string'); + isValid.value = false; + } + }; + + watch(model, validateField); + + return { isValid, errorMessages, errorMessagesString, validateField }; +}; diff --git a/components/src/composables/validation.spec.js b/components/src/composables/validation.spec.js new file mode 100644 index 00000000..82ca67d1 --- /dev/null +++ b/components/src/composables/validation.spec.js @@ -0,0 +1,143 @@ +import { ref, nextTick } from 'vue'; + +import { useFieldValidation } from './validation'; + +describe('validation composables', () => { + describe('useFieldValidation', () => { + let model; + let rule; + let rules; + let instance; + + beforeEach(() => { + model = ref(''); + rule = jest.fn().mockReturnValue(true); + rules = [rule]; + }); + + it('returns the required properties', () => { + const { isValid, errorMessages, errorMessagesString, validateField } = useFieldValidation( + model, + rules, + ); + + expect(isValid.value).toEqual(true); + expect(errorMessages.value).toEqual([]); + expect(errorMessagesString.value).toEqual(''); + expect(validateField).toEqual(expect.any(Function)); + }); + + describe('validateField function', () => { + beforeEach(() => { + instance = useFieldValidation(model, rules); + }); + + it('validates the model value against the rules', () => { + instance.validateField('foo bar baz'); + + expect(rule).toHaveBeenCalledWith('foo bar baz'); + }); + + describe('if the validation is successful', () => { + beforeEach(() => { + rule.mockReturnValue(true); + + instance.validateField('foo bar baz'); + }); + + it('sets isValid to true', () => { + expect(instance.isValid.value).toEqual(true); + }); + + it('sets errorMessages to an empty array', () => { + expect(instance.errorMessages.value).toEqual([]); + }); + }); + + describe('if the validation fails', () => { + beforeEach(() => { + rule.mockReturnValue('You failed miserably'); + + instance.validateField('foo bar baz'); + }); + + it('sets isValid to false', () => { + expect(instance.isValid.value).toEqual(false); + }); + + it('sets errorMessages as an array of all failure messages', () => { + expect(instance.errorMessages.value).toEqual(['You failed miserably']); + }); + }); + }); + + describe('when the model value changes', () => { + beforeEach(() => { + instance = useFieldValidation(model, rules); + }); + + it('validates the model value against the rules', async () => { + model.value = 'foo bar baz'; + await nextTick(); + + expect(rule).toHaveBeenCalledWith('foo bar baz'); + }); + + describe('if the validation is successful', () => { + beforeEach(async () => { + rule.mockReturnValue(true); + + model.value = 'foo bar baz'; + await nextTick(); + }); + + it('sets isValid to true', () => { + expect(instance.isValid.value).toEqual(true); + }); + + it('sets errorMessages to an empty array', () => { + expect(instance.errorMessages.value).toEqual([]); + }); + }); + + describe('if the validation fails', () => { + beforeEach(async () => { + rule.mockReturnValue('You failed miserably'); + + model.value = 'foo bar baz'; + await nextTick(); + }); + + it('sets isValid to false', () => { + expect(instance.isValid.value).toEqual(false); + }); + + it('sets errorMessages as an array of all failure messages', () => { + expect(instance.errorMessages.value).toEqual(['You failed miserably']); + }); + }); + }); + + describe('errorMessagesString computed', () => { + let instance; + + beforeEach(() => { + instance = useFieldValidation(model, rules); + }); + + it('returns an empty string if errorMessages is empty', async () => { + instance.errorMessages.value = []; + await nextTick(); + + expect(instance.errorMessagesString.value).toEqual(''); + }); + + it('returns the joined messages in errorMessages otherwise', async () => { + instance.errorMessages.value = ['Bad value', 'Big mistake here']; + await nextTick(); + + expect(instance.errorMessagesString.value).toEqual('Bad value. Big mistake here.'); + }); + }); + }); +}); diff --git a/components/src/stories/Select.stories.js b/components/src/stories/Select.stories.js index 077940b1..7fedaee9 100644 --- a/components/src/stories/Select.stories.js +++ b/components/src/stories/Select.stories.js @@ -37,6 +37,25 @@ export const Object = { }, }; +export const Validation = { + name: 'Input validation', + render: Basic.render, + + args: { + ...Basic.args, + label: 'Select input with validation', + hint: 'Select the second option if you want the validation to be successful', + propValue: 'id', + propText: 'name', + options: [ + { id: 'OBJ-123', name: 'The first object' }, + { id: 'OBJ-456', name: 'The second object' }, + { id: 'OBJ-789', name: 'The third object' }, + ], + rules: [(value) => value === 'OBJ-456' || 'You picked the wrong option :( '], + }, +}; + export const Events = { name: 'Using v-model', render: (args) => ({ @@ -63,6 +82,45 @@ export const Events = { args: Basic.args, }; +export const Slots = { + name: 'Custom element render', + render: (args) => ({ + setup() { + const selectedItem = ref(''); + const setSelectedItem = (event) => { + selectedItem.value = event.detail[0]; + }; + + return { args, selectedItem, setSelectedItem }; + }, + template: ` +
+ + + + + + +
+ `, + }), + args: { + ...Basic.args, + label: 'This implementation uses the "selected" slot and the "optionTextFn"', + options: [ + { id: 'OBJ-123', name: 'The first object' }, + { id: 'OBJ-456', name: 'The second object' }, + { id: 'OBJ-789', name: 'The third object' }, + ], + optionTextFn: (item) => `${item.name} (${item.id})`, + }, +}; + export default { title: 'Components/Select', component: Select, diff --git a/components/src/stories/TextField.stories.js b/components/src/stories/TextField.stories.js index c60c84d0..bd5878df 100644 --- a/components/src/stories/TextField.stories.js +++ b/components/src/stories/TextField.stories.js @@ -1,24 +1,44 @@ +import isEmail from 'validator/es/lib/isEmail'; + import cTextField from '~widgets/textfield/widget.vue'; import registerWidget from '~core/registerWidget'; registerWidget('ui-textfield', cTextField); -export const Component = { +export const Basic = { + name: 'Basic options', render: (args) => ({ setup() { return { args }; }, - template: '', + template: '', }), args: { - label: 'Label text', + label: 'Simple textfield', + hint: 'This is a hint for the text field input', value: '', placeholder: 'Placeholder text', suffix: '', }, }; +export const Validation = { + name: 'Input validation', + render: Basic.render, + + args: { + label: 'Text field with validation', + hint: 'This is a text field with validation. The value should be an email', + value: '', + placeholder: 'john.doe@example.com', + rules: [ + (value) => !!value || 'This field is required', + (value) => isEmail(value) || 'The value is not a valid email address', + ], + }, +}; + export default { title: 'Components/TextField', component: cTextField, diff --git a/components/src/widgets/menu/widget.vue b/components/src/widgets/menu/widget.vue index aa5683b8..9e58d2fb 100644 --- a/components/src/widgets/menu/widget.vue +++ b/components/src/widgets/menu/widget.vue @@ -44,6 +44,8 @@ const props = defineProps({ }, }); +const emit = defineEmits(['opened', 'closed']); + const showMenu = ref(false); const menu = ref(null); @@ -55,17 +57,22 @@ const fullWidthClass = computed(() => (props.fullWidth ? 'menu-content_full-widt const toggle = () => { showMenu.value = !showMenu.value; + emit(showMenu.value ? 'opened' : 'closed'); }; const handleClickOutside = (event) => { const isClickWithinMenuBounds = event.composedPath().some((el) => el === menu.value); if (!isClickWithinMenuBounds) { showMenu.value = false; + emit('closed'); } }; const onClickInside = () => { - if (props.closeOnClickInside) showMenu.value = false; + if (props.closeOnClickInside) { + showMenu.value = false; + emit('closed'); + } }; onMounted(() => { diff --git a/components/src/widgets/select/widget.spec.js b/components/src/widgets/select/widget.spec.js index 652e08b4..274eb286 100644 --- a/components/src/widgets/select/widget.spec.js +++ b/components/src/widgets/select/widget.spec.js @@ -49,6 +49,62 @@ describe('Select', () => { expect(menuOptions[1].text()).toEqual('Bar'); expect(menuOptions[2].text()).toEqual('Baz'); }); + + it('can render option text based on the optionTextFn prop', async () => { + await wrapper.setProps({ + options: [ + { id: '123', external_id: 'ext-123', name: 'Foo' }, + { id: '456', external_id: 'ext-456', name: 'Bar' }, + { id: '789', external_id: 'ext-789', name: 'Baz' }, + ], + optionTextFn: (option) => `${option.name} (${option.id})`, + }); + + const menuOptions = wrapper.findAll('.select-input__option'); + + expect(menuOptions.length).toEqual(3); + expect(menuOptions[0].text()).toEqual('Foo (123)'); + expect(menuOptions[1].text()).toEqual('Bar (456)'); + expect(menuOptions[2].text()).toEqual('Baz (789)'); + }); + }); + + describe('validation', () => { + let rule1; + let rule2; + + beforeEach(async () => { + rule1 = jest.fn().mockReturnValue(true); + rule2 = jest.fn().mockReturnValue('This field is invalid'); + + wrapper = mount(Select, { + props: { + modelValue: '', + options: ['foo', 'bar', 'baz'], + hint: 'Hint text', + rules: [rule1, rule2], + }, + }); + + await wrapper.findAll('.select-input__option')[1].trigger('click'); + }); + + it('validates the input value against the rules prop', () => { + expect(rule1).toHaveBeenCalledWith('bar'); + expect(rule2).toHaveBeenCalledWith('bar'); + }); + + it('renders the error messages if validation fails', () => { + expect(wrapper.get('.select-input__error-message').text()).toEqual('This field is invalid.'); + }); + + it('does not render the hint if there is an error', () => { + expect(wrapper.get('.select-input__hint').text()).not.toEqual('Hint text'); + }); + + it('adds the "select-input_invalid" class to the element', () => { + expect(wrapper.classes()).toContain('select-input_invalid'); + }); }); describe('events', () => { @@ -70,5 +126,24 @@ describe('Select', () => { expect(wrapper.emitted('valueChange')[0]).toEqual(['bar']); }); }); + + describe('when the menu is opened', () => { + it('adds the "select-input_focused" class', async () => { + await wrapper.get('ui-menu').trigger('opened'); + + expect(wrapper.classes()).toContain('select-input_focused'); + }); + }); + + describe('when the menu is closed', () => { + it('removes the "select-input_focused" class', async () => { + // open the menu first + await wrapper.get('ui-menu').trigger('opened'); + + await wrapper.get('ui-menu').trigger('closed'); + + expect(wrapper.classes()).not.toContain('select-input_focused'); + }); + }); }); }); diff --git a/components/src/widgets/select/widget.vue b/components/src/widgets/select/widget.vue index b6ef65bd..45318ada 100644 --- a/components/src/widgets/select/widget.vue +++ b/components/src/widgets/select/widget.vue @@ -1,18 +1,25 @@ diff --git a/components/webpack.config.js b/components/webpack.config.js index bc45bb6a..0c89fc2c 100644 --- a/components/webpack.config.js +++ b/components/webpack.config.js @@ -59,6 +59,7 @@ module.exports = { '~core': path.resolve(__dirname, 'src/core'), '~widgets': path.resolve(__dirname, 'src/widgets'), '~constants': path.resolve(__dirname, 'src/constants'), + '~composables': path.resolve(__dirname, 'src/composables'), }, }, diff --git a/package-lock.json b/package-lock.json index fc3c244e..e2b6768e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "stylus": "^0.63.0", "stylus-loader": "^8.1.0", "svgo-loader": "^4.0.0", + "validator": "^13.11.0", "vue-eslint-parser": "^9.4.2", "vue-loader": "^17.4.2", "vue-style-loader": "^4.1.3", @@ -24689,6 +24690,15 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/validator": { + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index e56b35be..5ba93bcb 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "stylus": "^0.63.0", "stylus-loader": "^8.1.0", "svgo-loader": "^4.0.0", + "validator": "^13.11.0", "vue-eslint-parser": "^9.4.2", "vue-loader": "^17.4.2", "vue-style-loader": "^4.1.3",