Skip to content

Commit

Permalink
feat(forms): dirty state (#3968)
Browse files Browse the repository at this point in the history
* feat(forms): dirty state

* feat(form): fieldsNamed prop

* chore: imrpove validation stories
  • Loading branch information
m0ksem authored Nov 13, 2023
1 parent 32e8f62 commit 17e96d9
Show file tree
Hide file tree
Showing 7 changed files with 187 additions and 11 deletions.
104 changes: 104 additions & 0 deletions packages/ui/src/components/va-select/VaSelect.stories.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -20,3 +24,103 @@ 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" />',
})

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: '<VaSelect v-model="value" :options="options" :rules="rules" immediate-validation />',
})

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: `
<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'" data-test="change">Change value to two</button>
</div>
`,
})

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: `
<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>
`,
})

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()
})
}
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 = 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,
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
22 changes: 22 additions & 0 deletions packages/ui/src/utils/watch-setter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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
}

// 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 = <T>(ref: Ref<T>, cb: (newValue: T) => void) => {
if (!isComputedRef(ref)) { return }
const originalSetter = ref._setter

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

0 comments on commit 17e96d9

Please sign in to comment.