diff --git a/packages/ui/src/components/va-select/VaSelect.stories.ts b/packages/ui/src/components/va-select/VaSelect.stories.ts index 6e8a5cdf20..ea0e10f471 100644 --- a/packages/ui/src/components/va-select/VaSelect.stories.ts +++ b/packages/ui/src/components/va-select/VaSelect.stories.ts @@ -1,5 +1,9 @@ +import { userEvent } from './../../../.storybook/interaction-utils/userEvent' +import { StoryFn } from '@storybook/vue3' import VaSelectDemo from './VaSelect.demo.vue' import VaSelect from './VaSelect.vue' +import { expect } from '@storybook/jest' +import { sleep } from '../../utils/sleep' export default { title: 'VaSelect', @@ -20,3 +24,103 @@ export const Loading = () => ({ components: { VaSelect }, template: '', }) + +export const Validation: StoryFn = () => ({ + components: { VaSelect }, + + data () { + return { value: '', options: ['one', 'two', 'tree'], rules: [(v) => (v && v === 'one') || 'Must be one'] } + }, + + template: '', +}) + +Validation.play = async ({ canvasElement, step }) => { + step('Expect no error when mounted even if value is incorrect', () => { + const error = canvasElement.querySelector('.va-input-wrapper.va-input-wrapper--error') as HTMLElement + expect(error).toBeNull() + }) +} + +export const ImmediateValidation: StoryFn = () => ({ + components: { VaSelect }, + + data () { + return { value: '', options: ['one', 'two', 'tree'], rules: [(v) => (v && v === 'one') || 'Must be one'] } + }, + + template: '', +}) + +ImmediateValidation.play = async ({ canvasElement, step }) => { + step('Expect error when mounted even if value is incorrect', () => { + const error = canvasElement.querySelector('.va-input-wrapper.va-input-wrapper--error') as HTMLElement + expect(error).not.toBeNull() + }) +} + +export const DirtyValidation: StoryFn = () => ({ + components: { Component: VaSelect }, + + data () { + return { value: '', dirty: false, haveError: false, options: ['one', 'two', 'tree'], rules: [(v) => (v && v === 'one') || 'Must be one'] } + }, + + template: ` +

[haveError]: {{ haveError }}

+

[dirty]: {{ dirty }}

+ +

[controls]

+
+ +
+ `, +}) + +DirtyValidation.play = async ({ canvasElement, step }) => { + step('Expect no error when mounted even if value is incorrect', () => { + const error = canvasElement.querySelector('.va-input-wrapper.va-input-wrapper--error') as HTMLElement + expect(error).toBeNull() + }) + + await step('Expect no error when value changed programaticaly', () => { + userEvent.click(canvasElement.querySelector('[data-test="change"]')!) + + const error = canvasElement.querySelector('.va-input-wrapper.va-input-wrapper--error') as HTMLElement + expect(error).toBeNull() + }) + + await step('Expect error appear when component is interacted', async () => { + userEvent.click(canvasElement.querySelector('.va-input-wrapper__field')!) + await sleep(1000) + userEvent.click(document.body.querySelectorAll('.va-select-option')[1]) + await sleep(1000) + const error = canvasElement.querySelector('.va-input-wrapper.va-input-wrapper--error') as HTMLElement + expect(error).not.toBeNull() + }) +} + +export const DirtyImmediateValidation: StoryFn = () => ({ + components: { Component: VaSelect }, + + data () { + return { value: '', dirty: false, haveError: false, options: ['one', 'two', 'tree'], rules: [(v) => (v && v === 'one') || 'Must be one'] } + }, + + template: ` +

[haveError]: {{ haveError }}

+

[dirty]: {{ dirty }}

+ +

[controls]

+
+ +
+ `, +}) + +DirtyImmediateValidation.play = async ({ canvasElement, step }) => { + step('Expect error when mounted if value is incorrect', () => { + const error = canvasElement.querySelector('.va-input-wrapper.va-input-wrapper--error') as HTMLElement + expect(error).not.toBeNull() + }) +} diff --git a/packages/ui/src/composables/useForm/types.ts b/packages/ui/src/composables/useForm/types.ts index 825e87b78b..73b69807dd 100644 --- a/packages/ui/src/composables/useForm/types.ts +++ b/packages/ui/src/composables/useForm/types.ts @@ -5,6 +5,7 @@ export type FormFiled = { value?: Ref; isValid: Ref; isLoading: Ref; + isDirty: Ref; errorMessages: Ref; validate: () => boolean; validateAsync: () => Promise; @@ -15,9 +16,11 @@ export type FormFiled = { export type Form = { fields: ComputedRef[]>; + fieldsNamed: ComputedRef>; fieldNames: ComputedRef; formData: ComputedRef>; isValid: ComputedRef; + isDirty: Ref; isLoading: ComputedRef; errorMessages: ComputedRef; errorMessagesNamed: ComputedRef>; diff --git a/packages/ui/src/composables/useForm/useForm.ts b/packages/ui/src/composables/useForm/useForm.ts index 27841d3c59..a2fc0e4fa8 100644 --- a/packages/ui/src/composables/useForm/useForm.ts +++ b/packages/ui/src/composables/useForm/useForm.ts @@ -29,7 +29,9 @@ export const useForm = (ref: string | Ref form.value?.isValid || false), isLoading: computed(() => form.value?.isLoading || false), + isDirty: computed(() => form.value?.isDirty || false), fields: computed(() => form.value?.fields ?? []), + fieldsNamed: computed(() => form.value?.fieldsNamed ?? []), fieldNames: computed(() => form.value?.fieldNames ?? []), formData: computed(() => form.value?.formData ?? {}), errorMessages: computed(() => form.value?.errorMessages || []), diff --git a/packages/ui/src/composables/useForm/useFormParent.ts b/packages/ui/src/composables/useForm/useFormParent.ts index 22c5d6e025..c9327c4aeb 100644 --- a/packages/ui/src/composables/useForm/useFormParent.ts +++ b/packages/ui/src/composables/useForm/useFormParent.ts @@ -35,12 +35,17 @@ export const useFormParent = (options: FormParent const { fields } = formContext const fieldNames = computed(() => fields.value.map((field) => unref(field.name)).filter(Boolean) as Names[]) + const fieldsNamed = computed(() => fields.value.reduce((acc, field) => { + if (unref(field.name)) { acc[unref(field.name) as Names] = field } + return acc + }, {} as Record)) const formData = computed(() => fields.value.reduce((acc, field) => { if (unref(field.name)) { acc[unref(field.name) as Names] = field.value } return acc }, {} 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 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) } @@ -69,7 +74,6 @@ export const useFormParent = (options: FormParent } const focus = () => { - console.log('fields.value', fields.value) fields.value[0]?.focus() } @@ -83,6 +87,7 @@ export const useFormParent = (options: FormParent name: ref(undefined), isValid: isValid, isLoading: isLoading, + isDirty: isDirty, validate, validateAsync, reset, @@ -92,8 +97,10 @@ export const useFormParent = (options: FormParent }) return { + isDirty, formData, fields, + fieldsNamed, fieldNames, isValid, isLoading, diff --git a/packages/ui/src/composables/useValidation.ts b/packages/ui/src/composables/useValidation.ts index 7a7100f848..a5bfd44f11 100644 --- a/packages/ui/src/composables/useValidation.ts +++ b/packages/ui/src/composables/useValidation.ts @@ -7,6 +7,7 @@ import { type WritableComputedRef, ref, toRef, + Ref, } from 'vue' import flatten from 'lodash/flatten.js' import isFunction from 'lodash/isFunction.js' @@ -15,6 +16,8 @@ import isString from 'lodash/isString.js' import { useSyncProp } from './useSyncProp' import { useFocus } from './useFocus' import { useFormChild } from './useForm' +import { ExtractReadonlyArrayKeys } from '../utils/types/readonly-array-keys' +import { watchSetter } from './../utils/watch-setter' export type ValidationRule = ((v: V) => any | string) | Promise<((v: V) => any | string)> @@ -34,6 +37,7 @@ const normalizeValidationRules = (rules: string | ValidationRule[] = [], callArg export const useValidationProps = { name: { type: String, default: undefined }, modelValue: { required: false }, + dirty: { type: Boolean, default: false }, error: { type: Boolean, default: undefined }, errorMessages: { type: [Array, String] as PropType, default: undefined }, errorCount: { type: [String, Number], default: 1 }, @@ -48,12 +52,32 @@ export type ValidationProps = typeof useValidationProps & { rules: { type: PropType[]> } } -export const useValidationEmits = ['update:error', 'update:errorMessages'] as const +export const useValidationEmits = ['update:error', 'update:errorMessages', 'update:dirty'] as const const isPromise = (value: any): value is Promise => { return typeof value === 'object' && typeof value.then === 'function' } +const useDirtyValue = ( + value: Ref, + props: ExtractPropTypes, + emit: (event: ExtractReadonlyArrayKeys, ...args: any[]) => void, +) => { + const isDirty = ref(false) + + watchSetter(value, () => { + isDirty.value = true + emit('update:dirty', true) + }) + + watch(() => props.dirty, (newValue) => { + if (isDirty.value === newValue) { return } + isDirty.value = newValue + }) + + return { isDirty } +} + export const useValidation = >( props: P, emit: (event: any, ...args: any[]) => void, @@ -142,28 +166,34 @@ export const useValidation = !newVal && validate()) + const immediateValidation = toRef(props, 'immediateValidation') + let canValidate = true const withoutValidation = (cb: () => any): void => { + if (immediateValidation.value) { + return cb() + } + canValidate = false cb() // NextTick because we update props in the same tick, but they are updated in the next one nextTick(() => { canValidate = true }) } - watch( - () => props.modelValue, - () => { - if (!canValidate) { return } + watch(options.value, () => { + if (!canValidate) { return } + + return validate() + }, { immediate: true }) - return validate() - }, - { immediate: props.immediateValidation }, - ) + const { isDirty } = useDirtyValue(options.value, props, emit) const { doShowErrorMessages, + // Renamed to forceHideError because it's not clear what it does doShowError, doShowLoading, } = useFormChild({ + isDirty, isValid: computed(() => !computedError.value), isLoading: isLoading, errorMessages: computedErrorMessages, @@ -177,7 +207,14 @@ export const useValidation = doShowError.value ? computedError.value : false), + isDirty, + computedError: computed(() => { + // Hide error if component haven't been interacted yet + // Ignore dirty state if immediateValidation is true + if (!immediateValidation.value && !isDirty.value) { return false } + + return doShowError.value ? computedError.value : false + }), computedErrorMessages: computed(() => doShowErrorMessages.value ? computedErrorMessages.value : []), isLoading: computed(() => doShowLoading.value ? isLoading.value : false), listeners: { onFocus, onBlur }, diff --git a/packages/ui/src/utils/types/readonly-array-keys.ts b/packages/ui/src/utils/types/readonly-array-keys.ts new file mode 100644 index 0000000000..64e3c2088b --- /dev/null +++ b/packages/ui/src/utils/types/readonly-array-keys.ts @@ -0,0 +1 @@ +export type ExtractReadonlyArrayKeys = (T) extends readonly (infer P)[] ? P : never diff --git a/packages/ui/src/utils/watch-setter.ts b/packages/ui/src/utils/watch-setter.ts new file mode 100644 index 0000000000..c695db51fb --- /dev/null +++ b/packages/ui/src/utils/watch-setter.ts @@ -0,0 +1,22 @@ +import { ComputedRef, Ref } from 'vue' + +const isComputedRef = (value: Ref): value is ComputedRef & { _setter: (v: T) => void} => { + return typeof value === 'object' && '_setter' in value +} + +// TODO: Maybe it is better to tweak useStateful +/** + * Do not watches for effect, but looking for computed ref setter triggered. + * Used to track when component tries to update computed ref. + * + * @notice you likely want to watch when value is changed, not setter is called. + */ +export const watchSetter = (ref: Ref, cb: (newValue: T) => void) => { + if (!isComputedRef(ref)) { return } + const originalSetter = ref._setter + + ref._setter = (newValue: T) => { + cb(newValue) + originalSetter(newValue) + } +}