Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .storybook/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions components/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ module.exports = {
'^~widgets/(.*)$': '<rootDir>./src/widgets/$1',
'^~core/(.*)$': '<rootDir>./src/core/$1',
'^~constants/(.*)$': '<rootDir>./src/constants/$1',
'^~composables/(.*)$': '<rootDir>./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$': '<rootDir>/test/helpers/svgMock.js',
},
Expand Down
28 changes: 28 additions & 0 deletions components/src/composables/validation.js
Original file line number Diff line number Diff line change
@@ -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 };
};
143 changes: 143 additions & 0 deletions components/src/composables/validation.spec.js
Original file line number Diff line number Diff line change
@@ -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.');
});
});
});
});
58 changes: 58 additions & 0 deletions components/src/stories/Select.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand All @@ -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: `
<div>
<ui-select
v-bind="args"
:modelValue="selectedItem"
@update:modelValue="setSelectedItem"
style="width:500px;"
>
<span slot="selected">
<template v-if="selectedItem">The current selected value is: {{ selectedItem }}</template>
<template v-else>There is no item selected</template>
</span>
</ui-select>
</div>
`,
}),
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,
Expand Down
26 changes: 23 additions & 3 deletions components/src/stories/TextField.stories.js
Original file line number Diff line number Diff line change
@@ -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: '<ui-textfield v-bind="args"></ui-textfield>',
template: '<ui-textfield v-bind="args" style="width:400px;"></ui-textfield>',
}),

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,
Expand Down
9 changes: 8 additions & 1 deletion components/src/widgets/menu/widget.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ const props = defineProps({
},
});

const emit = defineEmits(['opened', 'closed']);

const showMenu = ref(false);
const menu = ref(null);

Expand All @@ -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(() => {
Expand Down
Loading