@volverjs/form-vue
Version:
Vue 3 Forms with @volverjs/ui-vue
380 lines (363 loc) • 12.7 kB
text/typescript
import type { Component, InjectionKey, PropType, SlotsType, UnwrapRef } from 'vue'
import type {
FormComponentOptions,
FormSchema,
FormTemplate,
InjectedFormData,
InjectedFormWrapperData,
Path,
InferSchema,
InferFormattedError,
RefinementCtx,
} from './types'
import {
computed,
defineComponent,
h,
isProxy,
onMounted,
provide,
readonly as makeReadonly,
ref,
toRaw,
watch,
withModifiers,
} from 'vue'
import {
throttleFilter,
watchIgnorable,
} from '@vueuse/core'
import { FormStatus } from './enums'
import { safeParseAsync, defaultObjectBySchema, formatError, formatIssues } from './utils'
export function defineForm<Schema extends FormSchema, Type, FormTemplateComponent extends Component>(schema: Schema, provideKey: InjectionKey<InjectedFormData<Schema, Type>>, options: FormComponentOptions<Schema, Type>, VvFormTemplate: FormTemplateComponent, wrappers: Map<string, InjectedFormWrapperData<Schema>>) {
const errors = ref<InferFormattedError<Schema> | undefined>()
const status = ref<FormStatus | undefined>()
const invalid = computed(() => status.value === FormStatus.invalid)
const formData = ref<undefined extends Type ? Partial<InferSchema<Schema>> : Type>()
const readonly = ref<boolean>(false)
let validateFields: Set<Path<InferSchema<Schema>>> | undefined
const formDataAdapter = (data?: InferSchema<Schema>): undefined extends Type ? Partial<InferSchema<Schema>> : Type => {
const toReturn = defaultObjectBySchema(schema, data)
if (options?.class) {
const ClassObject = options.class
// @ts-expect-error - this is a class
return new ClassObject(toReturn)
}
// @ts-expect-error - this is a plain object
return toReturn
}
const validate = async (value = formData.value, options?: {
fields?: Set<Path<InferSchema<Schema>>>
superRefine?: (arg: InferSchema<Schema>, ctx: RefinementCtx<Schema>) => void | Promise<void>
}) => {
validateFields = options?.fields
if (readonly.value) {
return true
}
const parseResult = await safeParseAsync(schema, value)
if (!parseResult.success) {
status.value = FormStatus.invalid
if (!validateFields?.size) {
errors.value = formatError(schema, parseResult.error) as InferFormattedError<Schema>
return false
}
const fieldsIssues = parseResult.error.issues.filter(item =>
validateFields?.has(item.path.join('.') as Path<InferSchema<Schema>>),
)
if (!fieldsIssues.length) {
errors.value = undefined
return true
}
errors.value = formatIssues(schema, fieldsIssues) as InferFormattedError<Schema>
return false
}
errors.value = undefined
status.value = FormStatus.valid
formData.value = formDataAdapter(parseResult.data)
return true
}
const clear = () => {
errors.value = undefined
status.value = undefined
validateFields = undefined
}
const reset = () => {
formData.value = formDataAdapter()
clear()
status.value = FormStatus.reset
}
const submit = async (options?: {
fields?: Set<Path<InferSchema<Schema>>>
superRefine?: (arg: InferSchema<Schema>, ctx: RefinementCtx<Schema>) => void | Promise<void>
}) => {
if (readonly.value) {
return false
}
if (!(await validate(undefined, options))) {
return false
}
status.value = FormStatus.submitting
return true
}
const { ignoreUpdates, stop: stopUpdatesWatch } = watchIgnorable(
formData,
() => {
status.value = FormStatus.updated
},
{
deep: true,
eventFilter: throttleFilter(options?.updateThrottle ?? 500),
},
)
const readonlyErrors = makeReadonly(errors)
const readonlyStatus = makeReadonly(status)
const VvForm = defineComponent({
name: 'VvForm',
props: {
continuousValidation: {
type: Boolean,
default: false,
},
modelValue: {
type: Object,
default: () => ({}),
},
readonly: {
type: Boolean,
default: options?.readonly,
},
tag: {
type: String,
default: 'form',
},
template: {
type: [Array, Function] as PropType<FormTemplate<Schema, Type>>,
default: undefined,
},
superRefine: {
type: Function as PropType<(arg: InferSchema<Schema>, ctx: RefinementCtx<Schema>) => void | Promise<void>>,
default: undefined,
},
validateFields: {
type: Array as PropType<Path<InferSchema<Schema>>[]>,
default: undefined,
},
},
emits: [
'invalid',
'submit',
'update:modelValue',
'update:readonly',
'valid',
'reset',
],
expose: [
'errors',
'invalid',
'readonly',
'status',
'submit',
'tag',
'template',
'valid',
'validate',
'clear',
'reset',
],
slots: Object as SlotsType<{
default: {
errors: UnwrapRef<typeof readonlyErrors>
formData: UnwrapRef<typeof formData>
invalid: UnwrapRef<typeof invalid>
readonly: UnwrapRef<typeof readonly>
status: UnwrapRef<typeof readonlyStatus>
wrappers: typeof wrappers
clear: typeof clear
ignoreUpdates: typeof ignoreUpdates
reset: typeof reset
stopUpdatesWatch: typeof stopUpdatesWatch
submit: typeof submit
validate: typeof validate
}
}>,
setup(props, { emit }) {
formData.value = formDataAdapter(toRaw(props.modelValue) as InferSchema<Schema> | undefined)
watch(
() => props.modelValue,
(newValue) => {
if (newValue) {
const original = isProxy(newValue)
? toRaw(newValue)
: newValue
if (
JSON.stringify(original)
=== JSON.stringify(toRaw(formData.value))
) {
return
}
formData.value = typeof original?.clone === 'function'
? original.clone()
: JSON.parse(JSON.stringify(original))
}
},
{ deep: true },
)
watch(status, async (newValue) => {
if (newValue === FormStatus.invalid) {
const toReturn = toRaw(errors.value)
emit('invalid', toReturn)
options?.onInvalid?.(
toReturn,
)
return
}
if (newValue === FormStatus.valid) {
const toReturn = toRaw(formData.value)
emit('valid', toReturn)
options?.onValid?.(toReturn)
emit('update:modelValue', toReturn)
options?.onUpdate?.(toReturn)
return
}
if (newValue === FormStatus.submitting) {
const toReturn = toRaw(formData.value)
emit('submit', toReturn)
options?.onSubmit?.(toReturn)
return
}
if (newValue === FormStatus.reset) {
const toReturn = toRaw(formData.value)
emit('reset', toReturn)
options?.onReset?.(toReturn)
return
}
if (newValue === FormStatus.updated) {
if (
errors.value
|| options?.continuousValidation
|| props.continuousValidation
) {
await validate(undefined, {
superRefine: props.superRefine,
fields: validateFields ?? new Set(props.validateFields),
})
}
if (
!formData.value
|| !props.modelValue
|| JSON.stringify(formData.value) !== JSON.stringify(props.modelValue)
) {
const toReturn = toRaw(formData.value)
emit('update:modelValue', toReturn)
options?.onUpdate?.(toReturn)
}
if (status.value === FormStatus.updated) {
status.value = FormStatus.unknown
}
}
})
// readonly
onMounted(() => {
if (props.readonly !== undefined) {
readonly.value = props.readonly
}
})
watch(
() => props.readonly,
(newValue) => {
readonly.value = newValue
},
)
watch(readonly, (newValue) => {
if (newValue !== props.readonly) {
emit('update:readonly', readonly.value)
}
})
provide(provideKey, {
clear,
errors: readonlyErrors,
formData,
ignoreUpdates,
invalid,
readonly,
reset,
status: readonlyStatus,
stopUpdatesWatch,
submit,
validate,
wrappers,
})
return {
clear,
errors: readonlyErrors,
formData,
ignoreUpdates,
invalid,
isReadonly: readonly,
reset,
status: readonlyStatus,
stopUpdatesWatch,
submit: () => submit({
superRefine: props.superRefine,
fields: new Set(props.validateFields),
}),
validate,
wrappers,
}
},
render() {
const defaultSlot = () =>
this.$slots?.default?.({
errors: readonlyErrors.value,
formData: formData.value,
invalid: invalid.value,
readonly: readonly.value,
status: readonlyStatus.value,
wrappers,
clear,
ignoreUpdates,
reset,
stopUpdatesWatch,
submit,
validate,
}) ?? this.$slots.default
return h(
this.tag,
{
onSubmit: withModifiers(this.submit, ['prevent']),
onReset: withModifiers(this.reset, ['prevent']),
},
(this.template ?? options?.template) && VvFormTemplate
? [
h(
VvFormTemplate,
{
schema: this.template ?? options?.template,
},
{
default: defaultSlot,
},
),
]
: {
default: defaultSlot,
},
)
},
})
return {
clear,
errors,
formData,
ignoreUpdates,
invalid,
readonly,
reset,
status,
wrappers,
stopUpdatesWatch,
submit,
validate,
VvForm,
}
}