@volverjs/form-vue
Version:
Vue 3 Forms with @volverjs/ui-vue
334 lines (325 loc) • 8.72 kB
text/typescript
import { get, set } from 'ts-dot-prop'
import {
type Component,
type InjectionKey,
type PropType,
type Ref,
type ConcreteComponent,
computed,
defineAsyncComponent,
h,
inject,
onMounted,
provide,
readonly,
resolveComponent,
toRefs,
watch,
defineComponent,
onBeforeUnmount,
unref,
} from 'vue'
import type { z } from 'zod'
import { FormFieldType } from './enums'
import type {
InjectedFormData,
InjectedFormWrapperData,
InjectedFormFieldData,
FormFieldComponentOptions,
Path,
FormSchema,
} from './types'
export const defineFormField = <Schema extends FormSchema>(
formProvideKey: InjectionKey<InjectedFormData<Schema>>,
wrapperProvideKey: InjectionKey<InjectedFormWrapperData<Schema>>,
formFieldInjectionKey: InjectionKey<InjectedFormFieldData<Schema>>,
options?: FormFieldComponentOptions,
) => {
// define component
return defineComponent({
name: 'FieldComponent',
props: {
type: {
type: String as PropType<`${FormFieldType}`>,
validator: (value: FormFieldType) => {
return Object.values(FormFieldType).includes(value)
},
default: FormFieldType.custom,
},
is: {
type: [Object, String] as PropType<Component>,
default: undefined,
},
name: {
type: [String, Number, Boolean, Symbol] as PropType<
Path<z.infer<Schema>>
>,
required: true,
},
props: {
type: [Object, Function] as PropType<
Partial<
| z.infer<Schema>
| undefined
| ((
formData?: Ref<ObjectConstructor>,
) => Partial<z.infer<Schema>> | undefined)
>
>,
default: () => ({}),
},
showValid: {
type: Boolean,
default: false,
},
defaultValue: {
type: [String, Number, Boolean, Array, Object],
default: undefined,
},
lazyLoad: {
type: Boolean,
default: false,
},
},
emits: ['invalid', 'valid', 'update:formData', 'update:modelValue'],
expose: ['invalid', 'invalidLabel', 'errors'],
setup(props, { slots, emit }) {
// v-model
const modelValue = computed({
get() {
if (!injectedFormData?.formData) return
return get(
Object(injectedFormData.formData.value),
String(props.name),
)
},
set(value) {
if (!injectedFormData?.formData) return
set(
Object(injectedFormData.formData.value),
String(props.name),
value,
)
emit('update:modelValue', {
newValue: modelValue.value,
formData: injectedFormData?.formData,
})
},
})
onMounted(() => {
if (
modelValue.value === undefined &&
props.defaultValue !== undefined
) {
modelValue.value = props.defaultValue
}
})
onBeforeUnmount(() => {
unwatchInvalid()
unwatchInjectedFormData()
})
// inject data from parent form wrapper
const injectedWrapperData = inject(wrapperProvideKey, undefined)
if (injectedWrapperData) {
injectedWrapperData.fields.value.add(props.name as string)
}
// inject data from parent form
const injectedFormData = inject(formProvideKey)
const { props: fieldProps, name: fieldName } = toRefs(props)
const errors = computed(() => {
if (!injectedFormData?.errors.value) {
return undefined
}
return get(injectedFormData.errors.value, String(props.name))
})
const invalidLabel = computed(() => {
return errors.value?._errors
})
const invalid = computed(() => {
return errors.value !== undefined
})
const unwatchInvalid = watch(invalid, () => {
if (invalid.value) {
emit('invalid', invalidLabel.value)
if (injectedWrapperData) {
injectedWrapperData.errors.value.set(
props.name as string,
{
_errors: invalidLabel.value,
} as z.inferFormattedError<Schema>,
)
}
} else {
emit('valid', modelValue.value)
if (injectedWrapperData) {
injectedWrapperData.errors.value.delete(
props.name as string,
)
}
}
})
const unwatchInjectedFormData = watch(
() => injectedFormData?.formData,
() => {
emit('update:formData', injectedFormData?.formData)
},
{ deep: true },
)
const onUpdate = (value: unknown) => {
modelValue.value = value
}
const hasFieldProps = computed(() => {
let toReturn = fieldProps.value
if (typeof toReturn === 'function') {
toReturn = toReturn(injectedFormData?.formData)
}
return Object.keys(toReturn).reduce<Record<string, unknown>>(
(acc, key) => {
acc[key] = unref(toReturn[key])
return acc
},
{},
)
})
const hasProps = computed(() => ({
...hasFieldProps.value,
name: hasFieldProps.value.name ?? props.name,
invalid: invalid.value,
valid: props.showValid
? Boolean(!invalid.value && modelValue.value)
: undefined,
type: ((type: FormFieldType) => {
if (
[
FormFieldType.text,
FormFieldType.number,
FormFieldType.email,
FormFieldType.password,
FormFieldType.tel,
FormFieldType.url,
FormFieldType.search,
FormFieldType.date,
FormFieldType.time,
FormFieldType.datetimeLocal,
FormFieldType.month,
FormFieldType.week,
FormFieldType.color,
].includes(type)
) {
return type
}
return undefined
})(props.type as FormFieldType),
invalidLabel: invalidLabel.value,
modelValue: modelValue.value,
'onUpdate:modelValue': onUpdate,
}))
provide(formFieldInjectionKey, {
name: readonly(fieldName as Ref<string>),
errors: readonly(errors),
})
const component = computed(() => {
if (props.type === FormFieldType.custom) {
return {
render() {
return (
slots.default?.({
modelValue: modelValue.value,
onUpdate,
submit: injectedFormData?.submit,
validate: injectedFormData?.validate,
invalid: invalid.value,
invalidLabel: invalidLabel.value,
formData: injectedFormData?.formData.value,
formErrors: injectedFormData?.errors.value,
errors: errors.value,
}) ?? slots.defalut
)
},
}
}
if (!(options?.lazyLoad ?? props.lazyLoad)) {
let component: string | ConcreteComponent
switch (props.type) {
case FormFieldType.select:
component = resolveComponent('VvSelect')
break
case FormFieldType.checkbox:
component = resolveComponent('VvCheckbox')
break
case FormFieldType.radio:
component = resolveComponent('VvRadio')
break
case FormFieldType.textarea:
component = resolveComponent('VvTextarea')
break
case FormFieldType.radioGroup:
component = resolveComponent('VvRadioGroup')
break
case FormFieldType.checkboxGroup:
component = resolveComponent('VvCheckboxGroup')
break
case FormFieldType.combobox:
component = resolveComponent('VvCombobox')
break
default:
component = resolveComponent('VvInputText')
}
if (typeof component !== 'string') {
return component
} else {
console.warn(
`[form-vue warn]: ${component} not found, the component will be loaded asynchronously. To avoid this warning, please set "lazyLoad" option.`,
)
}
}
return defineAsyncComponent(async () => {
if (options?.sideEffects) {
await Promise.resolve(options.sideEffects(props.type))
}
switch (props.type) {
case FormFieldType.textarea:
return import(
'@volverjs/ui-vue/vv-textarea'
) as Component
case FormFieldType.radio:
return import(
'@volverjs/ui-vue/vv-radio'
) as Component
case FormFieldType.radioGroup:
return import(
'@volverjs/ui-vue/vv-radio-group'
) as Component
case FormFieldType.checkbox:
return import(
'@volverjs/ui-vue/vv-checkbox'
) as Component
case FormFieldType.checkboxGroup:
return import(
'@volverjs/ui-vue/vv-checkbox-group'
) as Component
case FormFieldType.select:
return import(
'@volverjs/ui-vue/vv-select'
) as Component
case FormFieldType.combobox:
return import(
'@volverjs/ui-vue/vv-combobox'
) as Component
}
return import('@volverjs/ui-vue/vv-input-text') as Component
})
})
return { component, hasProps, invalid }
},
render() {
if (this.is) {
return h(this.is, this.hasProps, this.$slots)
}
if (this.type === FormFieldType.custom) {
return h(this.component as Component, null, this.$slots)
}
return h(this.component as Component, this.hasProps, this.$slots)
},
})
}