@volverjs/form-vue
Version:
Vue 3 Forms with @volverjs/ui-vue
384 lines (375 loc) • 14.6 kB
text/typescript
import type { Component, DeepReadonly, InjectionKey, PropType, Ref, SlotsType } from 'vue'
import type {
FormSchema,
InjectedFormData,
InjectedFormFieldsGroupData,
InjectedFormWrapperData,
Path,
InferSchema,
InferFormattedError,
} from './types'
import { getProperty, setProperty } from 'dot-prop'
import {
computed,
defineComponent,
h,
inject,
onBeforeUnmount,
onMounted,
provide,
readonly,
toRefs,
unref,
useId,
watch,
} from 'vue'
export function defineFormFieldsGroup<Schema extends FormSchema, Type = undefined>(formProvideKey: InjectionKey<InjectedFormData<Schema, Type>>, wrapperProvideKey: InjectionKey<InjectedFormWrapperData<Schema>>, formFieldsGroupInjectionKey: InjectionKey<InjectedFormFieldsGroupData<Schema>>) {
return defineComponent({
name: 'VvFormFieldsGroup',
props: {
is: {
type: [Object, String] as PropType<Component | string>,
default: undefined,
},
names: {
type: [Array, Object] as PropType<
Path<InferSchema<Schema>>[] | Record<string, Path<InferSchema<Schema>>>
>,
required: true,
},
props: {
type: [Object, Function] as PropType<
Partial<
| InferSchema<Schema>
| undefined
| ((
formData?: Ref<ObjectConstructor>,
) => Partial<InferSchema<Schema>> | undefined)
>
>,
default: () => ({}),
},
showValid: {
type: Boolean,
default: false,
},
defaultValues: {
type: [Object] as PropType<
Record<Path<InferSchema<Schema>>, any>
>,
default: undefined,
},
readonly: {
type: Boolean,
default: undefined,
},
},
emits: [
'invalid',
'update:formData',
'update:modelValue',
'valid',
],
expose: [
'component',
'errors',
'hasProps',
'invalid',
'invalidLabels',
'is',
],
slots: Object as SlotsType<{
[key: string]: any
default: {
errors?: Record<Path<InferSchema<Schema>>, InferFormattedError<Schema>>
formData?: undefined extends Type ? Partial<InferSchema<Schema>> : Type
formErrors?: DeepReadonly<InferFormattedError<Schema>>
invalid: boolean
invalids: Record<string, boolean>
invalidLabels?: Record<string, string[]>
modelValue: Record<string, any>
onUpdate: (value: Record<string, any>) => void
onUpdateField: (name: string, value: any) => void
readonly: boolean
submit?: InjectedFormData<Schema, Type>['submit']
validate?: InjectedFormData<Schema, Type>['validate']
}
}>,
setup(props, { slots, emit }) {
const { props: fieldProps, names: fieldsNames, defaultValues } = toRefs(props)
const fieldGroupId = useId()
const names = computed<Path<InferSchema<Schema>>[]>(() => {
if (Array.isArray(fieldsNames.value)) {
return fieldsNames.value
}
return Object.values(fieldsNames.value)
})
const namesKeys = computed(() => {
if (Array.isArray(fieldsNames.value)) {
return fieldsNames.value
}
return Object.keys(fieldsNames.value)
})
const namesMap = computed(() => {
if (Array.isArray(fieldsNames.value)) {
return fieldsNames.value.reduce<Record<string, Path<InferSchema<Schema>>>>((
acc,
name,
) => {
acc[String(name)] = name
return acc
}, {})
}
return fieldsNames.value
})
const namesKeysMap = computed(() => {
return Object.keys(namesMap.value).reduce<Record<string, string>>((acc, key) => {
acc[String(namesMap.value[key])] = key
return acc
}, {})
})
// inject data from parent form wrapper
const injectedWrapperData = inject(wrapperProvideKey, undefined)
if (injectedWrapperData) {
names.value.forEach((name) => {
injectedWrapperData.fields.value.set(`${fieldGroupId}-${name}`, name as string)
})
}
// inject data from parent form
const injectedFormData = inject(formProvideKey)
// v-model
const modelValue = computed({
get() {
if (!injectedFormData?.formData) {
return {}
}
return namesKeys.value.reduce<Record<string, any>>((acc, nameKey) => {
acc[nameKey] = getProperty(
new Object(injectedFormData.formData.value),
namesMap.value[nameKey],
)
return acc
}, {})
},
set(value) {
if (!injectedFormData?.formData) {
return
}
namesKeys.value.forEach((nameKey) => {
setProperty(
new Object(injectedFormData.formData.value),
namesMap.value[nameKey],
value?.[nameKey],
)
})
emit('update:modelValue', {
newValue: modelValue.value,
formData: injectedFormData?.formData,
})
},
})
onMounted(() => {
if (
defaultValues.value
) {
names.value.forEach((name) => {
if (defaultValues.value?.[name] === undefined) {
return
}
if (modelValue.value[name] !== undefined) {
return
}
modelValue.value = {
...modelValue.value,
[name]: defaultValues.value?.[name],
}
})
}
})
onBeforeUnmount(() => {
if (injectedWrapperData) {
names.value.forEach((name) => {
injectedWrapperData.fields.value.delete(
`${fieldGroupId}-${name}`,
)
})
}
})
const errors = computed(() => {
if (!injectedFormData?.errors.value) {
return undefined
}
const toReturn = names.value.reduce<Record<string, InferFormattedError<Schema>>>((acc, name) => {
if (!injectedFormData.errors.value) {
return acc
}
const error = getProperty(injectedFormData.errors.value, String(name))
if (error === undefined) {
return acc
}
acc[String(name)] = error
return acc
}, {})
if (Object.keys(toReturn).length === 0) {
return undefined
}
return toReturn
})
const invalidLabels = computed(() => {
if (!errors.value) {
return
}
const toReturn = Object.keys(errors.value).reduce<Record<string, string[]>>((acc, name) => {
if (!errors.value?.[name]) {
return acc
}
acc[namesKeysMap.value[name]] = errors.value[name]._errors
return acc
}, {})
if (Object.keys(toReturn).length === 0) {
return
}
return toReturn
})
const invalid = computed(() => {
return errors.value !== undefined
})
const invalids = computed(() => {
return namesKeys.value.reduce<Record<string, boolean>>((acc, name) => {
acc[name] = Boolean(errors.value?.[namesKeysMap.value[name]])
return acc
}, {})
})
const unwatchInvalid = watch(invalid, () => {
if (invalid.value) {
emit('invalid', errors.value)
if (injectedWrapperData) {
names.value.forEach((name) => {
if (!errors.value?.[name]) {
injectedWrapperData.errors.value.delete(
name,
)
return
}
injectedWrapperData.errors.value.set(
name,
errors.value?.[name],
)
})
}
return
}
emit('valid', modelValue.value)
if (injectedWrapperData) {
names.value.forEach((name) => {
injectedWrapperData.errors.value.delete(
name,
)
})
}
})
const unwatchInjectedFormData = watch(
() => injectedFormData?.formData,
() => {
emit('update:formData', injectedFormData?.formData)
},
{ deep: true },
)
onBeforeUnmount(() => {
unwatchInvalid()
unwatchInjectedFormData()
})
const onUpdate = (value: Record<string, any>) => {
modelValue.value = value
}
const onUpdateField = (name: string, value: unknown) => {
if (value instanceof InputEvent) {
value = (value.target as HTMLInputElement).value
}
if (!namesKeys.value.includes(name)) {
return
}
modelValue.value = {
...modelValue.value,
[name]: 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 isReadonly = computed(() => {
if (injectedFormData?.readonly.value) {
return true
}
return (hasFieldProps.value.readonly ?? props.readonly) as boolean
})
const onUpdateEvents = computed(() => {
return namesKeys.value.reduce<Record<string, (value: any) => void>>((acc, name) => {
acc[`onUpdate:${name}`] = (value) => {
onUpdateField(name, value)
}
return acc
}, {
'onUpdate:modelValue': onUpdate,
})
})
const hasProps = computed(() => ({
...onUpdateEvents.value,
...hasFieldProps.value,
...modelValue.value,
modelValue: modelValue.value,
names: hasFieldProps.value.name ?? names.value,
invalid: invalid.value,
invalids: invalids.value,
valid: props.showValid
? Boolean(!invalid.value && modelValue.value)
: undefined,
invalidLabels: invalidLabels.value,
readonly: isReadonly.value,
}))
// provide data to children
provide(formFieldsGroupInjectionKey, {
names: readonly(fieldsNames) as DeepReadonly<Ref<Path<InferSchema<Schema>>[]>>,
errors: readonly(errors),
})
// define component
const component = computed(() => ({
render() {
return (
slots.default?.({
errors: errors.value,
formData: injectedFormData?.formData.value,
formErrors: injectedFormData?.errors.value,
invalid: invalid.value,
invalids: invalids.value,
invalidLabels: invalidLabels.value,
modelValue: modelValue.value,
onUpdate,
onUpdateField,
readonly: isReadonly.value,
submit: injectedFormData?.submit,
validate: injectedFormData?.validate,
}) ?? slots.default
)
},
}))
return { component, hasProps, invalid }
},
render() {
if (this.is) {
return h(this.is, this.hasProps, this.$slots)
}
return h(this.component, null, this.$slots)
},
})
}