Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(forms): dirty state #3968

Merged
merged 4 commits into from
Nov 13, 2023
Merged
Show file tree
Hide file tree
Changes from 3 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
55 changes: 55 additions & 0 deletions packages/ui/src/components/va-select/VaSelect.stories.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { StoryFn } from '@storybook/vue3'
import VaSelectDemo from './VaSelect.demo.vue'
import VaSelect from './VaSelect.vue'

Expand All @@ -20,3 +21,57 @@ export const Loading = () => ({
components: { VaSelect },
template: '<VaSelect loading />',
})

export const Validation: StoryFn = () => ({
components: { VaSelect },

data () {
return { value: '', options: ['one', 'two', 'tree'], rules: [(v) => (v && v === 'one') || 'Must be one'] }
},

template: '<VaSelect v-model="value" :options="options" :rules="rules" />',
})

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'] }
},

mounted () {
this.value = 'three'
},

template: `
<p>[haveError]: {{ haveError }}</p>
<p>[dirty]: {{ dirty }}</p>
<Component v-model="value" v-model:dirty="dirty" v-model:error="haveError" :options="options" :rules="rules" clearable />
<p> [controls] </p>
<div>
<button @click="value = 'two'">Change value to two</button>
</div>
`,
})

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'] }
},

mounted () {
this.value = 'three'
},

template: `
<p>[haveError]: {{ haveError }}</p>
<p>[dirty]: {{ dirty }}</p>
<Component v-model="value" v-model:dirty="dirty" v-model:error="haveError" :options="options" :rules="rules" clearable immediate-validation />
<p> [controls] </p>
<div>
<button @click="value = 'two'">Change value to two</button>
</div>
`,
})
3 changes: 3 additions & 0 deletions packages/ui/src/composables/useForm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type FormFiled<Name extends string = string> = {
value?: Ref<unknown>;
isValid: Ref<boolean>;
isLoading: Ref<boolean>;
isDirty: Ref<boolean>;
errorMessages: Ref<string[]>;
validate: () => boolean;
validateAsync: () => Promise<boolean>;
Expand All @@ -15,9 +16,11 @@ export type FormFiled<Name extends string = string> = {

export type Form<Names extends string = string> = {
fields: ComputedRef<FormFiled<Names>[]>;
fieldsNamed: ComputedRef<Record<Names, FormFiled>>;
fieldNames: ComputedRef<Names[]>;
formData: ComputedRef<Record<Names, unknown>>;
isValid: ComputedRef<boolean>;
isDirty: Ref<boolean>;
isLoading: ComputedRef<boolean>;
errorMessages: ComputedRef<string[]>;
errorMessagesNamed: ComputedRef<Record<Names, string[]>>;
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/composables/useForm/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ export const useForm = <Names extends string = string>(ref: string | Ref<typeof
return {
isValid: computed(() => 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 || []),
Expand Down
9 changes: 8 additions & 1 deletion packages/ui/src/composables/useForm/useFormParent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,17 @@ export const useFormParent = <Names extends string = string>(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<Names, FormFiled>))
const formData = computed(() => fields.value.reduce((acc, field) => {
if (unref(field.name)) { acc[unref(field.name) as Names] = field.value }
return acc
}, {} as Record<Names, FormFiled['value']>))
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) }
Expand Down Expand Up @@ -69,7 +74,6 @@ export const useFormParent = <Names extends string = string>(options: FormParent
}

const focus = () => {
console.log('fields.value', fields.value)
fields.value[0]?.focus()
}

Expand All @@ -83,6 +87,7 @@ export const useFormParent = <Names extends string = string>(options: FormParent
name: ref(undefined),
isValid: isValid,
isLoading: isLoading,
isDirty: isDirty,
validate,
validateAsync,
reset,
Expand All @@ -92,8 +97,10 @@ export const useFormParent = <Names extends string = string>(options: FormParent
})

return {
isDirty,
formData,
fields,
fieldsNamed,
fieldNames,
isValid,
isLoading,
Expand Down
57 changes: 47 additions & 10 deletions packages/ui/src/composables/useValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type WritableComputedRef,
ref,
toRef,
Ref,
} from 'vue'
import flatten from 'lodash/flatten.js'
import isFunction from 'lodash/isFunction.js'
Expand All @@ -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 = any> = ((v: V) => any | string) | Promise<((v: V) => any | string)>

Expand All @@ -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<string[] | string>, default: undefined },
errorCount: { type: [String, Number], default: 1 },
Expand All @@ -48,12 +52,32 @@ export type ValidationProps<V> = typeof useValidationProps & {
rules: { type: PropType<ValidationRule<V>[]> }
}

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<any> => {
return typeof value === 'object' && typeof value.then === 'function'
}

const useDirtyValue = (
value: Ref<any>,
props: ExtractPropTypes<typeof useValidationProps>,
emit: (event: ExtractReadonlyArrayKeys<typeof useValidationEmits>, ...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 = <V, P extends ExtractPropTypes<typeof useValidationProps>>(
props: P,
emit: (event: any, ...args: any[]) => void,
Expand Down Expand Up @@ -142,28 +166,34 @@ export const useValidation = <V, P extends ExtractPropTypes<typeof useValidation

watch(isFocused, (newVal) => !newVal && validate())

const immediateValidation = ref(props.immediateValidation)
m0ksem marked this conversation as resolved.
Show resolved Hide resolved

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,
Expand All @@ -177,7 +207,14 @@ export const useValidation = <V, P extends ExtractPropTypes<typeof useValidation
})

return {
computedError: computed(() => 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 },
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/utils/types/readonly-array-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type ExtractReadonlyArrayKeys<T extends readonly any[]> = (T) extends readonly (infer P)[] ? P : never
16 changes: 16 additions & 0 deletions packages/ui/src/utils/watch-setter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ComputedRef, Ref } from 'vue'

const isComputedRef = <T>(value: Ref<T>): value is ComputedRef<any> & { _setter: (v: T) => void} => {
return typeof value === 'object' && '_setter' in value
}

/** Do not watches for effect, but looking for computed ref setter triggered */
export const watchSetter = <T>(ref: Ref<T>, cb: (newValue: T) => void) => {
if (!isComputedRef(ref)) { return }
const originalSetter = ref._setter

ref._setter = (newValue: T) => {
cb(newValue)
originalSetter(newValue)
}
}