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 { h, reactive, ref } from 'vue'
import { useForm } from 'vee-validate' import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod' import { toTypedSchema } from '@vee-validate/zod'
import { DependencyType } from '../ui/auto-form/interface'
import { Button } from '@/lib/registry/new-york/ui/button' import { Button } from '@/lib/registry/new-york/ui/button'
import { toast } from '@/lib/registry/new-york/ui/toast' import { toast } from '@/lib/registry/new-york/ui/toast'
import type { Config } from '@/lib/registry/new-york/ui/auto-form' 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({ 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 invitedGuests: z
.array( .array(
z.object({ z.object({
name: z.string(), name: z.string(),
age: z.coerce.number(), age: z.coerce.number(),
}), }),
).default([ ).default([{ name: '123', age: 0 }]),
{ name: '123', age: 30 },
{ name: '456', age: 30 },
]).describe('How many guests'),
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>) { 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))), 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> </script>
<template> <template>
<AutoFormComponent <AutoForm
class="w-2/3 space-y-6" class="w-2/3 space-y-6"
:form="form"
:schema="schema" :schema="schema"
:field-config="{ :field-config="{
guestListName: { age: {
label: 'Lisst', description:
inputProps: { 'Setting this below 18 will require parents consent.',
placeholder: 'testign123',
},
}, },
invitedGuests: { parentsAllowed: {
name: { label: 'Did your parents allow you to register?',
label: 'walaaaaao',
},
}, },
list: { vegetarian: {
label: 'woohooo', label: 'Are you a vegetarian?',
description:
'Setting this to true will remove non-vegetarian food options.',
},
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" @submit="onSubmit"
> >
<Button type="submit"> <Button type="submit">
Submit Submit
</Button> </Button>
</AutoFormComponent> </AutoForm>
</template> </template>

View File

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

View File

@ -3,6 +3,7 @@ import type { ZodAny } from 'zod'
import { computed } from 'vue' import { computed } from 'vue'
import type { Config, ConfigItem, Shape } from './interface' import type { Config, ConfigItem, Shape } from './interface'
import { DEFAULT_ZOD_HANDLERS, INPUT_COMPONENTS } from './constant' import { DEFAULT_ZOD_HANDLERS, INPUT_COMPONENTS } from './constant'
import useDependencies from './dependencies'
const props = defineProps<{ const props = defineProps<{
name: string name: string
@ -20,15 +21,19 @@ const delegatedProps = computed(() => {
return { schema: props.shape?.schema } return { schema: props.shape?.schema }
return undefined return undefined
}) })
const { isDisabled, isHidden, isRequired, overrideOptions } = useDependencies(props.name)
</script> </script>
<template> <template>
<component <component
:is="isValidConfig(config) ? INPUT_COMPONENTS[config.component!] : INPUT_COMPONENTS[DEFAULT_ZOD_HANDLERS[shape.type]] " :is="isValidConfig(config) ? INPUT_COMPONENTS[config.component!] : INPUT_COMPONENTS[DEFAULT_ZOD_HANDLERS[shape.type]] "
v-if="!isHidden"
:name="name" :name="name"
:label="label" :label="label"
:required="shape.required" :required="isRequired || shape.required"
:options="shape.options" :options="overrideOptions || shape.options"
:disabled="isDisabled"
:config="config" :config="config"
v-bind="delegatedProps" 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 required?: boolean
config?: Config<T> config?: Config<T>
schema?: z.ZodArray<T> schema?: z.ZodArray<T>
disabled?: boolean
}>() }>()
const fieldContext = useField(props.name) const fieldContext = useField(props.name)
@ -57,7 +58,7 @@ provide(FieldContextKey, fieldContext)
<template> <template>
<FieldArray v-slot="{ fields, remove, push }" as="section" :name="name"> <FieldArray v-slot="{ fields, remove, push }" as="section" :name="name">
<slot v-bind="props"> <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"> <AccordionItem :value="name" class="border-none">
<AccordionTrigger> <AccordionTrigger>
<AutoFormLabel class="text-base" :required="required"> <AutoFormLabel class="text-base" :required="required">

View File

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

View File

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

View File

@ -23,14 +23,14 @@ const computedOptions = computed(() => props.config?.enumProps?.options || props
</AutoFormLabel> </AutoFormLabel>
<FormControl> <FormControl>
<slot v-bind="slotProps"> <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"> <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" /> <RadioGroupItem :id="`${option}-${index}`" :value="option" />
<Label :for="`${option}-${index}`">{{ beautifyObjectName(option) }}</Label> <Label :for="`${option}-${index}`">{{ beautifyObjectName(option) }}</Label>
</div> </div>
</RadioGroup> </RadioGroup>
<Select v-else v-bind="{ ...slotProps.componentField }"> <Select v-else :disabled="disabled" v-bind="{ ...slotProps.componentField }">
<SelectTrigger class="w-full"> <SelectTrigger class="w-full">
<SelectValue :placeholder="config?.enumProps?.placeholder" /> <SelectValue :placeholder="config?.enumProps?.placeholder" />
</SelectTrigger> </SelectTrigger>

View File

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

View File

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

View File

@ -20,7 +20,7 @@ defineProps<FieldProps>()
</AutoFormLabel> </AutoFormLabel>
<FormControl> <FormControl>
<slot v-bind="slotProps"> <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> </slot>
</FormControl> </FormControl>
<FormDescription v-if="config?.description"> <FormDescription v-if="config?.description">

View File

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

View File

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

View File

@ -17,6 +17,20 @@ export function beautifyObjectName(string: string) {
return output 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. * Get the lowest level Zod type.
* This will unpack optionals, refinements, etc. * This will unpack optionals, refinements, etc.