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
33 changes: 28 additions & 5 deletions src/plugin/components/fields/CommonField/CommonField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
:error="hasErrors"
:error-messages="errorMessage || field.errorMessages"
:items="fieldItems"
@blur="fieldValidateOn === 'blur' ? onActions('blur') : undefined"
@change="fieldValidateOn === 'change' ? onActions('change') : undefined"
@input="fieldValidateOn === 'input' ? onActions('input') : undefined"
@update:focused="onFocusUpdate"
@update:menu="onMenuUpdate"
>
<template #label>
<FieldLabel
Expand Down Expand Up @@ -48,10 +49,7 @@ const { errorMessage, setValue, validate, value } = useField(
undefined,
{
initialValue: modelValue.value,
validateOnBlur: fieldValidateOn.value === 'blur',
validateOnChange: fieldValidateOn.value === 'change',
validateOnInput: fieldValidateOn.value === 'input',
validateOnModelUpdate: fieldValidateOn.value != null,
validateOnValueUpdate: fieldValidateOn.value === 'input' || fieldValidateOn.value === 'change',
},
);

Expand All @@ -63,6 +61,31 @@ onUnmounted(() => {
});


// ------------------------- Menu-aware blur validation //
// Overlay fields (select/autocomplete/combobox/date/color) emit `update:focused`
// false the moment their menu opens, because focus moves into the overlay list.
// Validating on that raw blur would flag a required field as invalid the instant
// the user opens it. To avoid that we:
// - skip the blur validation while the menu is open, and
// - validate when the menu closes (the real "done with the field" signal).
// Menu-less fields (text/textarea/number) never emit `update:menu`, so they fall
// back to plain blur validation via onFocusUpdate.
const isMenuOpen = ref(false);

function onMenuUpdate(open: boolean): void {
isMenuOpen.value = open;

if (fieldValidateOn.value === 'blur' && !open) {
onActions('blur');
}
}

function onFocusUpdate(isFocused: boolean): void {
if (fieldValidateOn.value === 'blur' && !isFocused && !isMenuOpen.value) {
onActions('blur');
}
}

// ------------------------- Validate On Actions //
async function onActions(action: ValidateAction): Promise<void> {
await useOnActions({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import * as DATA from '@cypress/templates/testData';
import { object as yupObject, string as yupString } from 'yup';
import VStepperForm from '../../../../VStepperForm.vue';


// Regression coverage for menu-aware blur validation.
// A required select must NOT be flagged as invalid simply because the user
// opened its menu (opening moves focus into the overlay and emits a blur).
// See CommonField.vue > onFocusUpdate / onMenuUpdate.

const requiredMessage = DATA.isRequired('Select Animal');

const pages = [
{
fields: [DATA.defaultFields.select],
title: 'Select Page',
},
];

const validationSchema = yupObject({
selectAnimal: yupString().required(requiredMessage),
});

function mountForm(): void {
cy.mount(VStepperForm as any, {
props: {
modelValue: { selectAnimal: null },
pages,
validateOn: 'blur',
validationSchema,
},
});

cy.getDataCy('vsf-field-selectAnimal').as('select');
}

function openMenu(): void {
cy.get('@select')
.find('.v-field')
.invoke('attr', 'aria-controls')
.as('menuId');

cy.get('@select').click();
}


describe('CommonField validation (validateOn="blur")', () => {

it('does not error when a required select menu is merely opened', () => {
mountForm();
openMenu();

// Menu is open and its items are visible.
cy.get('@menuId').then((menuId) => {
cy.get(`#${menuId}`).find('.v-list-item').should('exist');
});

// Wait past the ~250ms menu-open blur that used to trigger premature
// validation, then confirm the field is still clean.
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(500);
cy.get('@select').should('not.have.class', 'v-input--error');
cy.get('@select').find('.v-messages__message').should('not.exist');
});

it('errors when a required select is closed without a selection', () => {
mountForm();
openMenu();

// Close the menu without choosing anything (click outside).
cy.get('.v-application__wrap').click();

cy.get('@select').should('have.class', 'v-input--error');
cy.get('@select')
.find('.v-messages__message')
.should('contain', requiredMessage);
});

it('stays valid after selecting a value and closing the menu', () => {
mountForm();
openMenu();

cy.get('@menuId').then((menuId) => {
cy.get(`#${menuId}`)
.find('.v-list-item')
.contains('.v-list-item', 'Rabbit')
.click();
});

cy.get('@select').should('not.have.class', 'v-input--error');
cy.get('@select').find('.v-messages__message').should('not.exist');
});

});
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,7 @@ const { errorMessage, handleChange, setValue, validate, value } = useField(
undefined,
{
initialValue: field.value?.multiple ? [] : null,
validateOnBlur: fieldValidateOn.value === 'blur',
validateOnChange: fieldValidateOn.value === 'change',
validateOnInput: fieldValidateOn.value === 'input',
validateOnModelUpdate: fieldValidateOn.value != null,
validateOnValueUpdate: fieldValidateOn.value === 'input' || fieldValidateOn.value === 'change',
},
);

Expand Down
5 changes: 1 addition & 4 deletions src/plugin/components/fields/VSFCheckbox/VSFCheckbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,7 @@ const { errorMessage, setValue, validate, value } = useField(
undefined,
{
initialValue: modelValue.value,
validateOnBlur: fieldValidateOn.value === 'blur',
validateOnChange: fieldValidateOn.value === 'change',
validateOnInput: fieldValidateOn.value === 'input',
validateOnModelUpdate: fieldValidateOn.value != null,
validateOnValueUpdate: fieldValidateOn.value === 'input' || fieldValidateOn.value === 'change',
},
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import * as DATA from '@cypress/templates/testData';
import { object as yupObject, string as yupString } from 'yup';
import VStepperForm from '../../../../VStepperForm.vue';


// Validation-timing coverage for the single checkbox field.
// A menu-less field has no overlay, so it cannot suffer the "errors on open"
// bug the select had. What we verify instead is that toggling the box drives
// validation at the right moment (via onActions('click')) and that the field
// is not flagged before the user interacts with it.
// See VSFCheckbox.vue > onActions and the useField `validateOnValueUpdate`.

const requiredMessage = DATA.isRequired('Checkbox Single');
const mustBeCheckedMessage = 'The box must be checked';

const pages = [
{
fields: [DATA.defaultFields.checkbox],
title: 'Checkbox Page',
},
];

// A Vuetify single checkbox with `true-value: 'yes'` and no `false-value`
// resolves to `false` when unchecked, so `required()` alone would treat the
// unchecked state as valid (yup casts `false` -> "false"). `oneOf(['yes'])`
// lets us assert that the commit click actually re-runs validation.
const validationSchema = yupObject({
isThisBoxChecked: yupString()
.required(requiredMessage)
.oneOf(['yes'], mustBeCheckedMessage),
});

function mountForm(validateOn: 'blur' | 'change'): void {
cy.mount(VStepperForm as any, {
props: {
modelValue: { isThisBoxChecked: null },
pages,
validateOn,
validationSchema,
},
});

cy.getDataCy('vsf-field-isThisBoxChecked').as('checkbox');
}


for (const validateOn of ['blur', 'change'] as const) {
describe(`VSFCheckbox validation (validateOn="${validateOn}")`, () => {

it('is not flagged before the user interacts with it', () => {
mountForm(validateOn);

cy.get('@checkbox').should('not.have.class', 'v-input--error');
cy.get('@checkbox').find('.v-messages__message').should('not.exist');
});

it('stays valid after checking the box', () => {
mountForm(validateOn);

cy.get('@checkbox').find('input').click();

cy.get('@checkbox').should('not.have.class', 'v-input--error');
cy.get('@checkbox').find('.v-messages__message').should('not.exist');
});

it('re-validates and errors after the box is checked then unchecked', () => {
mountForm(validateOn);

cy.get('@checkbox').find('input').click();
cy.get('@checkbox').find('input').click();

cy.get('@checkbox').should('have.class', 'v-input--error');
cy.get('@checkbox')
.find('.v-messages__message')
.should('contain', mustBeCheckedMessage);
});

});
}
5 changes: 1 addition & 4 deletions src/plugin/components/fields/VSFCustom/VSFCustom.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,7 @@ const $useField = useField(
undefined,
{
initialValue: modelValue.value,
validateOnBlur: fieldValidateOn.value === 'blur',
validateOnChange: fieldValidateOn.value === 'change',
validateOnInput: fieldValidateOn.value === 'input',
validateOnModelUpdate: fieldValidateOn.value != null,
validateOnValueUpdate: fieldValidateOn.value === 'input' || fieldValidateOn.value === 'change',
},
);

Expand Down
5 changes: 1 addition & 4 deletions src/plugin/components/fields/VSFRadio/VSFRadio.vue
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,7 @@ const { errorMessage, setValue, validate, value } = useField(
{
initialValue: modelValue.value,
type: 'radio',
validateOnBlur: fieldValidateOn.value === 'blur',
validateOnChange: fieldValidateOn.value === 'change',
validateOnInput: fieldValidateOn.value === 'input',
validateOnModelUpdate: fieldValidateOn.value != null,
validateOnValueUpdate: fieldValidateOn.value === 'input' || fieldValidateOn.value === 'change',
},
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import * as DATA from '@cypress/templates/testData';
import { object as yupObject, string as yupString } from 'yup';
import VStepperForm from '../../../../VStepperForm.vue';


// Validation-timing coverage for the single radio field.
// Radio is menu-less, so it can't hit the select "errors on open" bug. It also
// never returns to an empty state once a value is picked, so we exercise the
// schema's `matches(/(yes|no)/)` rule: picking "Maybe" must flag the field the
// moment it is selected, and picking a valid option must clear it.
// See VSFRadio.vue > onActions and the useField `validateOnValueUpdate`.

const requiredMessage = DATA.isRequired('Radio Single');
const matchesMessage = 'Only "yes" or "no" is allowed';

const pages = [
{
fields: [DATA.defaultFields.radio],
title: 'Radio Page',
},
];

const validationSchema = yupObject({
isSingleRadioSelected: yupString()
.required(requiredMessage)
.matches(/(yes|no)/, matchesMessage),
});

function mountForm(validateOn: 'blur' | 'change'): void {
cy.mount(VStepperForm as any, {
props: {
modelValue: { isSingleRadioSelected: null },
pages,
validateOn,
validationSchema,
},
});

cy.getDataCy('vsf-field-group-isSingleRadioSelected').as('radioGroup');
}


for (const validateOn of ['blur', 'change'] as const) {
describe(`VSFRadio validation (validateOn="${validateOn}")`, () => {

it('is not flagged before an option is selected', () => {
mountForm(validateOn);

cy.get('@radioGroup').find('.v-messages__message').should('not.exist');
});

it('flags an invalid option the moment it is selected', () => {
mountForm(validateOn);

cy.contains('.v-radio', 'Maybe').click();

cy.get('@radioGroup')
.find('.v-messages__message')
.should('contain', matchesMessage);
});

it('clears the error after switching to a valid option', () => {
mountForm(validateOn);

cy.contains('.v-radio', 'Maybe').click();
cy.get('@radioGroup')
.find('.v-messages__message')
.should('contain', matchesMessage);

cy.contains('.v-radio', 'Yes').click();

cy.get('@radioGroup').find('.v-messages__message').should('not.exist');
});

});
}
Loading