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)
+ }
+}