From 36f668a841b57a0a6692f7afffca162c61a42a94 Mon Sep 17 00:00:00 2001 From: Maksim Nedoshev Date: Fri, 8 Dec 2023 22:39:26 +0200 Subject: [PATCH 1/2] fix(#4075): mark whole form dirty is validate called --- packages/ui/src/components/va-form/VaForm.stories.ts | 2 +- packages/ui/src/composables/useForm/useFormChild.ts | 2 ++ packages/ui/src/composables/useForm/useFormParent.ts | 12 ++++++++++-- packages/ui/src/composables/useValidation.ts | 12 ++++++++++-- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/va-form/VaForm.stories.ts b/packages/ui/src/components/va-form/VaForm.stories.ts index 695db8fa44..447e15bad0 100644 --- a/packages/ui/src/components/va-form/VaForm.stories.ts +++ b/packages/ui/src/components/va-form/VaForm.stories.ts @@ -240,7 +240,7 @@ export const ValidateAndResetValidation: StoryFn = () => ({ components: { VaForm, VaInput, VaButton }, template: ` - + Validate diff --git a/packages/ui/src/composables/useForm/useFormChild.ts b/packages/ui/src/composables/useForm/useFormChild.ts index 8857368bf5..93f0e26ce9 100644 --- a/packages/ui/src/composables/useForm/useFormChild.ts +++ b/packages/ui/src/composables/useForm/useFormChild.ts @@ -8,6 +8,7 @@ export const useFormChild = (context: FormFiled) => { if (!formContext) { return { + isFormDirty: ref(false), isFormImmediate: ref(false), doShowError: ref(true), doShowErrorMessages: ref(true), @@ -26,6 +27,7 @@ export const useFormChild = (context: FormFiled) => { }) return { + isFormDirty: formContext.isFormDirty, isFormImmediate: formContext.immediate, doShowError: formContext.doShowError, doShowErrorMessages: formContext.doShowErrorMessages, diff --git a/packages/ui/src/composables/useForm/useFormParent.ts b/packages/ui/src/composables/useForm/useFormParent.ts index 7f8c487521..081abc6509 100644 --- a/packages/ui/src/composables/useForm/useFormParent.ts +++ b/packages/ui/src/composables/useForm/useFormParent.ts @@ -20,6 +20,7 @@ export const createFormContext = (options: FormParentOptio doShowError: computed(() => !options.hideErrors), doShowErrorMessages: computed(() => !options.hideErrorMessages), doShowLoading: computed(() => !options.hideLoading), + isFormDirty: ref(false), registerField: (uid: number, field: FormFiled) => { fields.value.set(uid, field as FormFiled) }, @@ -34,7 +35,7 @@ export const useFormParent = (options: FormParent provide(FormServiceKey, formContext) - const { fields } = formContext + const { fields, isFormDirty } = formContext const fieldNames = computed(() => fields.value.map((field) => unref(field.name)).filter(Boolean) as Names[]) const fieldsNamed = computed(() => fields.value.reduce((acc, field) => { @@ -47,7 +48,10 @@ export const useFormParent = (options: FormParent }, {} as Record)) const isValid = computed(() => fields.value.every((field) => unref(field.isValid))) const isLoading = computed(() => fields.value.some((field) => unref(field.isLoading))) - const isDirty = computed(() => fields.value.some((field) => unref(field.isLoading))) + const isDirty = computed({ + get () { return fields.value.some((field) => unref(field.isLoading)) || isFormDirty.value }, + set (v) { isFormDirty.value = v }, + }) const errorMessages = computed(() => fields.value.map((field) => unref(field.errorMessages)).flat()) const errorMessagesNamed = computed(() => fields.value.reduce((acc, field) => { if (unref(field.name)) { acc[unref(field.name) as Names] = unref(field.errorMessages) } @@ -55,6 +59,7 @@ export const useFormParent = (options: FormParent }, {} as Record)) const validate = () => { + isDirty.value = true // Validate each filed to get the error messages return fields.value.reduce((acc, field) => { return field.validate() && acc @@ -62,16 +67,19 @@ export const useFormParent = (options: FormParent } const validateAsync = () => { + isDirty.value = true return Promise.all(fields.value.map((field) => field.validateAsync())).then((results) => { return results.every(Boolean) }) } const reset = () => { + isDirty.value = false fields.value.forEach((field) => field.reset()) } const resetValidation = () => { + isDirty.value = false fields.value.forEach((field) => field.resetValidation()) } diff --git a/packages/ui/src/composables/useValidation.ts b/packages/ui/src/composables/useValidation.ts index 8f04c1cd5b..9f92000de1 100644 --- a/packages/ui/src/composables/useValidation.ts +++ b/packages/ui/src/composables/useValidation.ts @@ -100,6 +100,7 @@ export const useValidation = { computedError.value = false computedErrorMessages.value = [] + isDirty.value = false } const processResults = (results: any[]) => { @@ -193,6 +194,7 @@ export const useValidation = !computedError.value), @@ -202,7 +204,11 @@ export const useValidation = { + reset() + resetValidation() + isDirty.value = false + }, value: computed(() => options.value || props.modelValue), name: toRef(props, 'name'), }) @@ -212,7 +218,9 @@ export const useValidation = { // Hide error if component haven't been interacted yet // Ignore dirty state if immediateValidation is true - if (!immediateValidation.value && !isDirty.value) { return false } + if (!isFormDirty.value) { + if (!immediateValidation.value && !isDirty.value) { return false } + } return doShowError.value ? computedError.value : false }), From 2e6a60619731ad8beaa964ec9e4bb1077c8ae483 Mon Sep 17 00:00:00 2001 From: Maksim Nedoshev Date: Fri, 8 Dec 2023 23:13:49 +0200 Subject: [PATCH 2/2] chore(form): add dirty form tests --- .../src/components/va-form/VaForm.stories.ts | 57 +++++++++++++++++++ .../ui/src/components/va-input/VaInput.vue | 2 + 2 files changed, 59 insertions(+) diff --git a/packages/ui/src/components/va-form/VaForm.stories.ts b/packages/ui/src/components/va-form/VaForm.stories.ts index 447e15bad0..2c97a8dc48 100644 --- a/packages/ui/src/components/va-form/VaForm.stories.ts +++ b/packages/ui/src/components/va-form/VaForm.stories.ts @@ -539,3 +539,60 @@ export const FormDataInitialValue = () => ({ `, }) + +export const DirtyForm: StoryFn = () => ({ + components: { VaForm, VaInput, VaButton }, + + setup () { + const { isDirty } = useForm('form') + + return { + isDirty, + } + }, + + template: ` +

[form-dirty]: {{ isDirty }}

+

[input-dirty]: {{ $refs.input?.isDirty }}

+ + + + + Validate + + + Reset validation + + `, +}) + +DirtyForm.play = async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const input = canvas.getByTestId('input') + const validateButton = canvas.getByRole('button', { name: 'Validate' }) as HTMLElement + const resetButton = canvas.getByRole('button', { name: 'Reset validation' }) as HTMLElement + + await step('Validates input with error', async () => { + await userEvent.click(validateButton) + expect(input.getAttribute('aria-invalid')).toEqual('true') + expect(canvasElement.querySelector('#form-dirty')?.innerHTML.includes('true')).toBeTruthy() + expect(canvasElement.querySelector('#input-dirty')?.innerHTML.includes('false')).toBeTruthy() + }) + + await step('Reset inputs validation', async () => { + await userEvent.click(resetButton) + expect(input.getAttribute('aria-invalid')).toEqual('false') + }) + + await step('Validates input on input error', async () => { + await userEvent.type(input, 'Hello') + expect(input.getAttribute('aria-invalid')).toEqual('true') + expect(canvasElement.querySelector('#form-dirty')?.innerHTML.includes('false')).toBeTruthy() + expect(canvasElement.querySelector('#input-dirty')?.innerHTML.includes('true')).toBeTruthy() + }) + + await step('Reset inputs validation', async () => { + await userEvent.click(resetButton) + expect(input.getAttribute('aria-invalid')).toEqual('false') + }) +} diff --git a/packages/ui/src/components/va-input/VaInput.vue b/packages/ui/src/components/va-input/VaInput.vue index 03cfe22b31..24715e31b4 100644 --- a/packages/ui/src/components/va-input/VaInput.vue +++ b/packages/ui/src/components/va-input/VaInput.vue @@ -148,6 +148,7 @@ export default defineComponent({ }) const { + isDirty, computedError, computedErrorMessages, listeners: { onBlur, onFocus }, @@ -240,6 +241,7 @@ export default defineComponent({ fieldListeners: createFieldListeners(emit), filterSlots, + isDirty, reset, focus, blur,