UNPKG

@volverjs/form-vue

Version:

Vue 3 Forms with @volverjs/ui-vue

384 lines (375 loc) 14.6 kB
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) }, }) }