feat: dependencies rendering

This commit is contained in:
zernonia 2024-04-19 22:23:16 +08:00
parent c986fed5ef
commit 0cd3371e4a
14 changed files with 236 additions and 51 deletions

View File

@ -3,25 +3,35 @@ import * as z from 'zod'
import { h, reactive, ref } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { DependencyType } from '../ui/auto-form/interface'
import { Button } from '@/lib/registry/new-york/ui/button'
import { toast } from '@/lib/registry/new-york/ui/toast'
import type { Config } from '@/lib/registry/new-york/ui/auto-form'
import { AutoForm as AutoFormComponent, AutoFormField } from '@/lib/registry/new-york/ui/auto-form'
import { AutoForm, AutoFormField } from '@/lib/registry/new-york/ui/auto-form'
const schema = z.object({
guestListName: z.string(),
age: z.number().default(20),
parentsAllowed: z.boolean().optional(),
vegetarian: z.boolean().default(true),
mealOptions: z.enum(['Pasta', 'Salad', 'Beef Wellington']).optional(),
invitedGuests: z
.array(
z.object({
name: z.string(),
age: z.coerce.number(),
}),
).default([
{ name: '123', age: 30 },
{ name: '456', age: 30 },
]).describe('How many guests'),
).default([{ name: '123', age: 0 }]),
list: z.array(z.string()).describe('test the config').min(1, 'Please add some item').default([]),
subObject: z.object({
subField: z.string().optional().default('Sub Field'),
numberField: z.number().optional().default(10),
subSubObject: z
.object({
subSubField: z.string().default('Sub Sub Field'),
})
.describe('Sub Sub Object Description'),
}),
})
function onSubmit(values: Record<string, any>) {
@ -30,42 +40,64 @@ function onSubmit(values: Record<string, any>) {
description: h('pre', { class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' }, h('code', { class: 'text-white' }, JSON.stringify(values, null, 2))),
})
}
const form = useForm({
keepValuesOnUnmount: true, // make sure the array/object field doesn't destroy the linked field
validationSchema: toTypedSchema(schema),
})
form.setValues({
guestListName: 'testing 123',
})
</script>
<template>
<AutoFormComponent
<AutoForm
class="w-2/3 space-y-6"
:form="form"
:schema="schema"
:field-config="{
guestListName: {
label: 'Lisst',
inputProps: {
placeholder: 'testign123',
age: {
description:
'Setting this below 18 will require parents consent.',
},
parentsAllowed: {
label: 'Did your parents allow you to register?',
},
invitedGuests: {
name: {
label: 'walaaaaao',
vegetarian: {
label: 'Are you a vegetarian?',
description:
'Setting this to true will remove non-vegetarian food options.',
},
},
list: {
label: 'woohooo',
mealOptions: {
component: 'radio',
},
}"
:dependencies="[
{
// 'age' hides 'parentsAllowed' when the age is 18 or older
sourceField: 'age',
type: DependencyType.HIDES,
targetField: 'parentsAllowed',
when: (age) => age >= 18,
},
{
// 'vegetarian' checkbox hides the 'Beef Wellington' option from 'mealOptions'
// if its not already selected
sourceField: 'vegetarian',
type: DependencyType.SETS_OPTIONS,
targetField: 'mealOptions',
when: (vegetarian, mealOption) =>
vegetarian && mealOption !== 'Beef Wellington',
options: ['Pasta', 'Salad'],
},
{
sourceField: 'age',
type: DependencyType.HIDES,
targetField: 'invitedGuests.age' as any,
when: (age) => age >= 18,
},
{
sourceField: 'age' as any,
type: DependencyType.HIDES,
targetField: 'subObject.subSubObject' as any,
when: (age) => age >= 18,
},
]"
@submit="onSubmit"
>
<Button type="submit">
Submit
</Button>
</AutoFormComponent>
</AutoForm>
</template>

View File

@ -1,30 +1,36 @@
<script setup lang="ts" generic="U extends ZodRawShape, T extends ZodObject<U>">
import { computed, ref } from 'vue'
import { computed, ref, toRef, toRefs } from 'vue'
import type { ZodAny, ZodObject, ZodRawShape, z } from 'zod'
import { toTypedSchema } from '@vee-validate/zod'
import type { FormContext, GenericObject } from 'vee-validate'
import { getBaseType, getDefaultValueInZodStack } from './utils'
import type { Config, ConfigItem, Shape } from './interface'
import { getBaseSchema, getBaseType, getDefaultValueInZodStack } from './utils'
import type { Config, ConfigItem, Dependency, Shape } from './interface'
import AutoFormField from './AutoFormField.vue'
import { provideDependencies } from './dependencies'
import { Form } from '@/lib/registry/new-york/ui/form'
const props = defineProps<{
schema: T
form?: FormContext<GenericObject>
fieldConfig?: Config<z.infer<T>>
dependencies?: Dependency<z.infer<T>>[]
}>()
const emits = defineEmits<{
submit: [event: GenericObject]
}>()
const { dependencies } = toRefs(props)
provideDependencies(dependencies)
const shapes = computed(() => {
// @ts-expect-error ignore {} not assignable to object
const val: { [key in keyof T]: Shape } = {}
const shape = props.schema.shape
Object.keys(shape).forEach((name) => {
const item = shape[name] as ZodAny
let options = 'values' in item._def ? item._def.values as string[] : undefined
const baseItem = getBaseSchema(item) as ZodAny
let options = (baseItem && 'values' in baseItem._def) ? baseItem._def.values as string[] : undefined
if (!Array.isArray(options) && typeof options === 'object')
options = Object.values(options)
@ -33,7 +39,7 @@ const shapes = computed(() => {
default: getDefaultValueInZodStack(item),
options,
required: !['ZodOptional', 'ZodNullable'].includes(item._def.typeName),
schema: item,
schema: baseItem,
}
})
return val
@ -41,13 +47,13 @@ const shapes = computed(() => {
const formComponent = computed(() => props.form ? 'form' : Form)
const formComponentProps = computed(() => {
const formSchema = toTypedSchema(props.schema)
if (props.form) {
return {
onSubmit: props.form.handleSubmit(val => emits('submit', val)),
}
}
else {
const formSchema = toTypedSchema(props.schema)
return {
keepValues: true,
validationSchema: formSchema,

View File

@ -3,6 +3,7 @@ import type { ZodAny } from 'zod'
import { computed } from 'vue'
import type { Config, ConfigItem, Shape } from './interface'
import { DEFAULT_ZOD_HANDLERS, INPUT_COMPONENTS } from './constant'
import useDependencies from './dependencies'
const props = defineProps<{
name: string
@ -20,15 +21,19 @@ const delegatedProps = computed(() => {
return { schema: props.shape?.schema }
return undefined
})
const { isDisabled, isHidden, isRequired, overrideOptions } = useDependencies(props.name)
</script>
<template>
<component
:is="isValidConfig(config) ? INPUT_COMPONENTS[config.component!] : INPUT_COMPONENTS[DEFAULT_ZOD_HANDLERS[shape.type]] "
v-if="!isHidden"
:name="name"
:label="label"
:required="shape.required"
:options="shape.options"
:required="isRequired || shape.required"
:options="overrideOptions || shape.options"
:disabled="isDisabled"
:config="config"
v-bind="delegatedProps"
>

View File

@ -0,0 +1,110 @@
import type * as z from 'zod'
import type { Ref } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import { FieldContextKey, FormContextKey } from 'vee-validate'
import { createContext } from 'radix-vue'
import { type Dependency, DependencyType, type EnumValues } from './interface'
import { getIndexIfArray } from './utils'
export const [injectDependencies, provideDependencies] = createContext<Ref<Dependency<z.infer<z.ZodObject<any>>>[] | undefined>>('AutoFormDependencies')
function getValueByPath<T extends Record<string, any>>(obj: T, path: string): any {
const keys = path.split('.')
let value = obj
for (const key of keys) {
if (value && typeof value === 'object' && key in value)
value = value[key]
else
return undefined
}
return value
}
export default function useDependencies(
fieldName: string,
) {
const form = inject(FormContextKey)
const field = inject(FieldContextKey)
const currentFieldName = fieldName.replace(/\[\d+\]/g, '')
if (!form)
throw new Error('useDependencies should be used within <AutoForm>')
const { controlledValues } = form
const dependencies = injectDependencies()
const isDisabled = ref(false)
const isHidden = ref(false)
const isRequired = ref(false)
const overrideOptions = ref<EnumValues | undefined>()
const currentFieldValue = computed(() => field?.value.value)
const currentFieldDependencies = computed(() => dependencies.value?.filter(
dependency => dependency.targetField === currentFieldName,
))
function getSourceValue(dep: Dependency<any>) {
const source = dep.sourceField as string
const lastKey = source.split('.').at(-1)
if (source.includes('.') && lastKey) {
if (Array.isArray(field?.value.value)) {
const index = getIndexIfArray(fieldName) ?? -1
return field?.value.value[index][lastKey]
}
return getValueByPath(form!.values, source)
}
return controlledValues.value[source as string]
}
const sourceFieldValues = computed(() => currentFieldDependencies.value?.map(dep => getSourceValue(dep)))
const resetConditionState = () => {
isDisabled.value = false
isHidden.value = false
isRequired.value = false
overrideOptions.value = undefined
}
watch([sourceFieldValues, dependencies], () => {
resetConditionState()
currentFieldDependencies.value?.forEach((dep) => {
const sourceValue = getSourceValue(dep)
const conditionMet = dep.when(sourceValue, currentFieldValue.value)
switch (dep.type) {
case DependencyType.DISABLES:
if (conditionMet)
isDisabled.value = true
break
case DependencyType.REQUIRES:
if (conditionMet)
isRequired.value = true
break
case DependencyType.HIDES:
if (conditionMet)
isHidden.value = true
break
case DependencyType.SETS_OPTIONS:
if (conditionMet)
overrideOptions.value = dep.options
break
}
})
}, { immediate: true, deep: true })
return {
isDisabled,
isHidden,
isRequired,
overrideOptions,
}
}

View File

@ -17,6 +17,7 @@ const props = defineProps<{
required?: boolean
config?: Config<T>
schema?: z.ZodArray<T>
disabled?: boolean
}>()
const fieldContext = useField(props.name)
@ -57,7 +58,7 @@ provide(FieldContextKey, fieldContext)
<template>
<FieldArray v-slot="{ fields, remove, push }" as="section" :name="name">
<slot v-bind="props">
<Accordion type="multiple" class="w-full" collapsible>
<Accordion type="multiple" class="w-full" collapsible :disabled="disabled">
<AccordionItem :value="name" class="border-none">
<AccordionTrigger>
<AutoFormLabel class="text-base" :required="required">

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { beautifyObjectName } from '../utils'
import type { ConfigItem, FieldProps } from '../interface'
import AutoFormLabel from '../AutoFormLabel.vue'
@ -6,7 +7,9 @@ import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessa
import { Switch } from '@/lib/registry/new-york/ui/switch'
import { Checkbox } from '@/lib/registry/new-york/ui/checkbox'
defineProps<FieldProps>()
const props = defineProps<FieldProps>()
const booleanComponent = computed(() => props.config?.component === 'switch' ? Switch : Checkbox)
</script>
<template>
@ -15,8 +18,13 @@ defineProps<FieldProps>()
<div class="space-y-0 mb-3 flex items-center gap-3">
<FormControl>
<slot v-bind="slotProps">
<Switch v-if="config?.component === 'switch'" v-bind="{ ...slotProps.componentField }" />
<Checkbox v-else v-bind="{ ...slotProps.componentField }" />
<component
:is="booleanComponent"
v-bind="{ ...slotProps.componentField }"
:disabled="disabled"
:checked="slotProps.componentField.modelValue"
@update:checked="slotProps.componentField['onUpdate:modelValue']"
/>
</slot>
</FormControl>
<AutoFormLabel v-if="!config?.hideLabel" :required="required">

View File

@ -28,7 +28,7 @@ const df = new DateFormatter('en-US', {
<slot v-bind="slotProps">
<div>
<Popover>
<PopoverTrigger as-child>
<PopoverTrigger as-child :disabled="disabled">
<Button
variant="outline"
:class="cn(

View File

@ -23,14 +23,14 @@ const computedOptions = computed(() => props.config?.enumProps?.options || props
</AutoFormLabel>
<FormControl>
<slot v-bind="slotProps">
<RadioGroup v-if="config?.component === 'radio'" :orientation="'vertical'" v-bind="{ ...slotProps.componentField }">
<RadioGroup v-if="config?.component === 'radio'" :disabled="disabled" :orientation="'vertical'" v-bind="{ ...slotProps.componentField }">
<div v-for="(option, index) in computedOptions" :key="option" class="mb-2 flex items-center gap-3 space-y-0">
<RadioGroupItem :id="`${option}-${index}`" :value="option" />
<Label :for="`${option}-${index}`">{{ beautifyObjectName(option) }}</Label>
</div>
</RadioGroup>
<Select v-else v-bind="{ ...slotProps.componentField }">
<Select v-else :disabled="disabled" v-bind="{ ...slotProps.componentField }">
<SelectTrigger class="w-full">
<SelectValue :placeholder="config?.enumProps?.placeholder" />
</SelectTrigger>

View File

@ -34,6 +34,7 @@ async function parseFileAsString(file: File | undefined): Promise<string> {
<Input
type="file"
v-bind="{ ...config?.inputProps }"
:disabled="disabled"
@change="async (ev: InputEvent) => {
const file = (ev.target as HTMLInputElement).files?.[0]
const parsed = await parseFileAsString(file)

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { beautifyObjectName } from '../utils'
import type { Config, ConfigItem, FieldProps } from '../interface'
import AutoFormLabel from '../AutoFormLabel.vue'
@ -6,7 +7,8 @@ import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessa
import { Input } from '@/lib/registry/new-york/ui/input'
import { Textarea } from '@/lib/registry/new-york/ui/textarea'
defineProps<FieldProps>()
const props = defineProps<FieldProps>()
const inputComponent = computed(() => props.config?.component === 'textarea' ? Textarea : Input)
</script>
<template>
@ -17,8 +19,12 @@ defineProps<FieldProps>()
</AutoFormLabel>
<FormControl>
<slot v-bind="slotProps">
<Textarea v-if="config?.component === 'textarea'" type="text" v-bind="{ ...slotProps.componentField, ...config?.inputProps }" />
<Input v-else type="text" v-bind="{ ...slotProps.componentField, ...config?.inputProps }" />
<component
:is="inputComponent"
type="text"
v-bind="{ ...slotProps.componentField, ...config?.inputProps }"
:disabled="disabled"
/>
</slot>
</FormControl>
<FormDescription v-if="config?.description">

View File

@ -20,7 +20,7 @@ defineProps<FieldProps>()
</AutoFormLabel>
<FormControl>
<slot v-bind="slotProps">
<Input type="number" v-bind="{ ...slotProps.componentField, ...config?.inputProps }" />
<Input type="number" v-bind="{ ...slotProps.componentField, ...config?.inputProps }" :disabled="disabled" />
</slot>
</FormControl>
<FormDescription v-if="config?.description">

View File

@ -11,6 +11,7 @@ const props = defineProps<{
required?: boolean
config?: Config<T>
schema?: ZodObject<T>
disabled?: boolean
}>()
const shapes = computed(() => {
@ -43,7 +44,7 @@ const shapes = computed(() => {
<template>
<section>
<slot v-bind="props">
<Accordion type="multiple" class="w-full" collapsible>
<Accordion type="multiple" class="w-full" collapsible :disabled="disabled">
<AccordionItem :value="name" class="border-none">
<AccordionTrigger class="text-base">
{{ schema?.description || beautifyObjectName(name) }}
@ -52,7 +53,7 @@ const shapes = computed(() => {
<template v-for="(shape, key) in shapes" :key="key">
<AutoFormField
:config="config?.[key as keyof typeof config] as ConfigItem"
:name="`${name}${key.toString()}`"
:name="`${name}.${key.toString()}`"
:label="key.toString()"
:shape="shape"
/>

View File

@ -7,6 +7,7 @@ export interface FieldProps {
label?: string
required?: boolean
config?: ConfigItem
disabled?: boolean
}
export interface Shape {

View File

@ -17,6 +17,20 @@ export function beautifyObjectName(string: string) {
return output
}
/**
* Parse string and extract the index
* @param string
* @returns index or undefined
*/
export function getIndexIfArray(string: string) {
const indexRegex = /\[(\d+)\]/
// Match the index
const match = string.match(indexRegex)
// Extract the index (number)
const index = match ? Number.parseInt(match[1]) : undefined
return index
}
/**
* Get the lowest level Zod type.
* This will unpack optionals, refinements, etc.