chore: replicate to default styling
This commit is contained in:
parent
ed9c70934e
commit
637aa119e7
|
|
@ -1,53 +1,161 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { h } from 'vue'
|
import * as z from 'zod'
|
||||||
|
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 * as z from 'zod'
|
import { DependencyType } from '../ui/auto-form/interface'
|
||||||
|
|
||||||
import { Button } from '@/lib/registry/default/ui/button'
|
import { Button } from '@/lib/registry/default/ui/button'
|
||||||
import {
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@/lib/registry/default/ui/form'
|
|
||||||
import { Input } from '@/lib/registry/default/ui/input'
|
|
||||||
import { toast } from '@/lib/registry/default/ui/toast'
|
import { toast } from '@/lib/registry/default/ui/toast'
|
||||||
|
import type { Config } from '@/lib/registry/default/ui/auto-form'
|
||||||
|
import { AutoForm, AutoFormField } from '@/lib/registry/default/ui/auto-form'
|
||||||
|
|
||||||
const formSchema = toTypedSchema(z.object({
|
enum Sports {
|
||||||
username: z.string().min(2).max(50),
|
Football = 'Football/Soccer',
|
||||||
}))
|
Basketball = 'Basketball',
|
||||||
|
Baseball = 'Baseball',
|
||||||
|
Hockey = 'Hockey (Ice)',
|
||||||
|
None = 'I don\'t like sports',
|
||||||
|
}
|
||||||
|
|
||||||
const { handleSubmit } = useForm({
|
const schema = z.object({
|
||||||
validationSchema: formSchema,
|
username: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Username is required.',
|
||||||
|
})
|
||||||
|
.min(2, {
|
||||||
|
message: 'Username must be at least 2 characters.',
|
||||||
|
}),
|
||||||
|
|
||||||
|
password: z
|
||||||
|
.string({
|
||||||
|
required_error: 'Password is required.',
|
||||||
|
})
|
||||||
|
.min(8, {
|
||||||
|
message: 'Password must be at least 8 characters.',
|
||||||
|
}),
|
||||||
|
|
||||||
|
favouriteNumber: z.coerce
|
||||||
|
.number({
|
||||||
|
invalid_type_error: 'Favourite number must be a number.',
|
||||||
|
})
|
||||||
|
.min(1, {
|
||||||
|
message: 'Favourite number must be at least 1.',
|
||||||
|
})
|
||||||
|
.max(10, {
|
||||||
|
message: 'Favourite number must be at most 10.',
|
||||||
|
})
|
||||||
|
.default(1)
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
acceptTerms: z
|
||||||
|
.boolean()
|
||||||
|
.refine(value => value, {
|
||||||
|
message: 'You must accept the terms and conditions.',
|
||||||
|
path: ['acceptTerms'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
sendMeMails: z.boolean().optional(),
|
||||||
|
|
||||||
|
birthday: z.coerce.date().optional(),
|
||||||
|
|
||||||
|
color: z.enum(['red', 'green', 'blue']).optional(),
|
||||||
|
|
||||||
|
// Another enum example
|
||||||
|
marshmallows: z
|
||||||
|
.enum(['not many', 'a few', 'a lot', 'too many']),
|
||||||
|
|
||||||
|
// Native enum example
|
||||||
|
sports: z.nativeEnum(Sports).describe('What is your favourite sport?'),
|
||||||
|
|
||||||
|
bio: z
|
||||||
|
.string()
|
||||||
|
.min(10, {
|
||||||
|
message: 'Bio must be at least 10 characters.',
|
||||||
|
})
|
||||||
|
.max(160, {
|
||||||
|
message: 'Bio must not be longer than 30 characters.',
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
customParent: z.string().optional(),
|
||||||
|
|
||||||
|
file: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const onSubmit = handleSubmit((values) => {
|
function onSubmit(values: Record<string, any>) {
|
||||||
toast({
|
toast({
|
||||||
title: 'You submitted the following values:',
|
title: 'You submitted the following values:',
|
||||||
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))),
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form class="w-2/3 space-y-6" @submit="onSubmit">
|
<AutoForm
|
||||||
<FormField v-slot="{ componentField }" name="username">
|
class="w-2/3 space-y-6"
|
||||||
<FormItem>
|
:schema="schema"
|
||||||
<FormLabel>Username</FormLabel>
|
:field-config="{
|
||||||
<FormControl>
|
password: {
|
||||||
<Input type="text" placeholder="shadcn" v-bind="componentField" />
|
label: 'Your secure password',
|
||||||
</FormControl>
|
inputProps: {
|
||||||
<FormDescription>
|
type: 'password',
|
||||||
This is your public display name.
|
placeholder: '••••••••',
|
||||||
</FormDescription>
|
},
|
||||||
<FormMessage />
|
},
|
||||||
</FormItem>
|
favouriteNumber: {
|
||||||
</FormField>
|
description: 'Your favourite number between 1 and 10.',
|
||||||
|
},
|
||||||
|
acceptTerms: {
|
||||||
|
label: 'Accept terms and conditions.',
|
||||||
|
inputProps: {
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
birthday: {
|
||||||
|
description: 'We need your birthday to send you a gift.',
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMeMails: {
|
||||||
|
component: 'switch',
|
||||||
|
},
|
||||||
|
|
||||||
|
bio: {
|
||||||
|
component: 'textarea',
|
||||||
|
},
|
||||||
|
|
||||||
|
marshmallows: {
|
||||||
|
label: 'How many marshmallows fit in your mouth?',
|
||||||
|
component: 'radio',
|
||||||
|
},
|
||||||
|
|
||||||
|
file: {
|
||||||
|
label: 'Text file',
|
||||||
|
component: 'file',
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
@submit="onSubmit"
|
||||||
|
>
|
||||||
|
<template #acceptTerms="slotProps">
|
||||||
|
<AutoFormField v-bind="slotProps" />
|
||||||
|
<div class="!mt-2 text-sm">
|
||||||
|
I agree to the <button class="text-primary underline">
|
||||||
|
terms and conditions
|
||||||
|
</button>.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #customParent="slotProps">
|
||||||
|
<div class="flex items-end space-x-2">
|
||||||
|
<AutoFormField v-bind="slotProps" class="w-full" />
|
||||||
|
<Button type="button">
|
||||||
|
Check
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<Button type="submit">
|
<Button type="submit">
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</AutoForm>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
104
apps/www/src/lib/registry/default/ui/auto-form/AutoForm.vue
Normal file
104
apps/www/src/lib/registry/default/ui/auto-form/AutoForm.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<script setup lang="ts" generic="U extends ZodRawShape, T extends ZodObject<U>">
|
||||||
|
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 { 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/default/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
|
||||||
|
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)
|
||||||
|
|
||||||
|
val[name as keyof T] = {
|
||||||
|
type: getBaseType(item),
|
||||||
|
default: getDefaultValueInZodStack(item),
|
||||||
|
options,
|
||||||
|
required: !['ZodOptional', 'ZodNullable'].includes(item._def.typeName),
|
||||||
|
schema: baseItem,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return val
|
||||||
|
})
|
||||||
|
|
||||||
|
const fields = computed(() => {
|
||||||
|
// @ts-expect-error ignore {} not assignable to object
|
||||||
|
const val: { [key in keyof z.infer<T>]: { shape: Shape, fieldName: string, config: ConfigItem } } = {}
|
||||||
|
for (const key in shapes.value) {
|
||||||
|
const shape = shapes.value[key]
|
||||||
|
val[key as keyof z.infer<T>] = {
|
||||||
|
shape,
|
||||||
|
config: props.fieldConfig?.[key] as ConfigItem,
|
||||||
|
fieldName: key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
})
|
||||||
|
|
||||||
|
const formComponent = computed(() => props.form ? 'form' : Form)
|
||||||
|
const formComponentProps = computed(() => {
|
||||||
|
if (props.form) {
|
||||||
|
return {
|
||||||
|
onSubmit: props.form.handleSubmit(val => emits('submit', val)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const formSchema = toTypedSchema(props.schema)
|
||||||
|
return {
|
||||||
|
keepValues: true,
|
||||||
|
validationSchema: formSchema,
|
||||||
|
onSubmit: (val: GenericObject) => emits('submit', val),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="formComponent"
|
||||||
|
v-bind="formComponentProps"
|
||||||
|
>
|
||||||
|
<slot name="customAutoForm" :fields="fields">
|
||||||
|
<template v-for="(shape, key) of shapes" :key="key">
|
||||||
|
<slot
|
||||||
|
:shape="shape"
|
||||||
|
:name="key.toString() as keyof z.infer<T>"
|
||||||
|
:field-name="key.toString()"
|
||||||
|
:config="fieldConfig?.[key as keyof typeof fieldConfig] as ConfigItem"
|
||||||
|
>
|
||||||
|
<AutoFormField
|
||||||
|
:config="fieldConfig?.[key as keyof typeof fieldConfig] as ConfigItem"
|
||||||
|
:field-name="key.toString()"
|
||||||
|
:shape="shape"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<slot :shapes="shapes" />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
<script setup lang="ts" generic="U extends ZodAny">
|
||||||
|
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<{
|
||||||
|
fieldName: string
|
||||||
|
shape: Shape
|
||||||
|
label?: string
|
||||||
|
config?: ConfigItem | Config<U>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function isValidConfig(config: any): config is ConfigItem {
|
||||||
|
return !!config?.component
|
||||||
|
}
|
||||||
|
|
||||||
|
const delegatedProps = computed(() => {
|
||||||
|
if (['ZodObject', 'ZodArray'].includes(props.shape?.type))
|
||||||
|
return { schema: props.shape?.schema }
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const { isDisabled, isHidden, isRequired, overrideOptions } = useDependencies(props.fieldName)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="isValidConfig(config) ? INPUT_COMPONENTS[config.component!] : INPUT_COMPONENTS[DEFAULT_ZOD_HANDLERS[shape.type]] "
|
||||||
|
v-if="!isHidden"
|
||||||
|
:field-name="fieldName"
|
||||||
|
:label="label"
|
||||||
|
:required="isRequired || shape.required"
|
||||||
|
:options="overrideOptions || shape.options"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
:config="config"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { FormLabel } from '@/lib/registry/default/ui/form'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
required?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormLabel>
|
||||||
|
<slot />
|
||||||
|
<span v-if="required" class="text-destructive"> *</span>
|
||||||
|
</FormLabel>
|
||||||
|
</template>
|
||||||
39
apps/www/src/lib/registry/default/ui/auto-form/constant.ts
Normal file
39
apps/www/src/lib/registry/default/ui/auto-form/constant.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import FieldArray from './fields/Array.vue'
|
||||||
|
import FieldBoolean from './fields/Boolean.vue'
|
||||||
|
import FieldDate from './fields/Date.vue'
|
||||||
|
import FieldEnum from './fields/Enum.vue'
|
||||||
|
import FieldFile from './fields/File.vue'
|
||||||
|
import FieldInput from './fields/Input.vue'
|
||||||
|
import FieldNumber from './fields/Number.vue'
|
||||||
|
import FieldObject from './fields/Object.vue'
|
||||||
|
|
||||||
|
export const INPUT_COMPONENTS = {
|
||||||
|
date: FieldDate,
|
||||||
|
select: FieldEnum,
|
||||||
|
radio: FieldEnum,
|
||||||
|
checkbox: FieldBoolean,
|
||||||
|
switch: FieldBoolean,
|
||||||
|
textarea: FieldInput,
|
||||||
|
number: FieldNumber,
|
||||||
|
string: FieldInput,
|
||||||
|
file: FieldFile,
|
||||||
|
array: FieldArray,
|
||||||
|
object: FieldObject,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define handlers for specific Zod types.
|
||||||
|
* You can expand this object to support more types.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ZOD_HANDLERS: {
|
||||||
|
[key: string]: keyof typeof INPUT_COMPONENTS
|
||||||
|
} = {
|
||||||
|
ZodString: 'string',
|
||||||
|
ZodBoolean: 'checkbox',
|
||||||
|
ZodDate: 'date',
|
||||||
|
ZodEnum: 'select',
|
||||||
|
ZodNativeEnum: 'select',
|
||||||
|
ZodNumber: 'number',
|
||||||
|
ZodArray: 'array',
|
||||||
|
ZodObject: 'object',
|
||||||
|
}
|
||||||
110
apps/www/src/lib/registry/default/ui/auto-form/dependencies.ts
Normal file
110
apps/www/src/lib/registry/default/ui/auto-form/dependencies.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
109
apps/www/src/lib/registry/default/ui/auto-form/fields/Array.vue
Normal file
109
apps/www/src/lib/registry/default/ui/auto-form/fields/Array.vue
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
<script setup lang="ts" generic="T extends z.ZodAny">
|
||||||
|
import * as z from 'zod'
|
||||||
|
import { computed, provide } from 'vue'
|
||||||
|
import { PlusIcon, TrashIcon } from 'lucide-vue-next'
|
||||||
|
import { FieldArray, FieldContextKey, useField, useFieldArray } from 'vee-validate'
|
||||||
|
import type { Config, ConfigItem } from '../interface'
|
||||||
|
import { beautifyObjectName, getBaseType } from '../utils'
|
||||||
|
import AutoFormField from '../AutoFormField.vue'
|
||||||
|
import AutoFormLabel from '../AutoFormLabel.vue'
|
||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/lib/registry/default/ui/accordion'
|
||||||
|
import { Button } from '@/lib/registry/default/ui/button'
|
||||||
|
import { Separator } from '@/lib/registry/default/ui/separator'
|
||||||
|
import { FormMessage } from '@/lib/registry/default/ui/form'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
fieldName: string
|
||||||
|
required?: boolean
|
||||||
|
config?: Config<T>
|
||||||
|
schema?: z.ZodArray<T>
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const fieldContext = useField(props.fieldName)
|
||||||
|
|
||||||
|
function isZodArray(
|
||||||
|
item: z.ZodArray<any> | z.ZodDefault<any>,
|
||||||
|
): item is z.ZodArray<any> {
|
||||||
|
return item instanceof z.ZodArray
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZodDefault(
|
||||||
|
item: z.ZodArray<any> | z.ZodDefault<any>,
|
||||||
|
): item is z.ZodDefault<any> {
|
||||||
|
return item instanceof z.ZodDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemShape = computed(() => {
|
||||||
|
if (!props.schema)
|
||||||
|
return
|
||||||
|
|
||||||
|
const schema: z.ZodAny = isZodArray(props.schema)
|
||||||
|
? props.schema._def.type
|
||||||
|
: isZodDefault(props.schema)
|
||||||
|
// @ts-expect-error missing schema
|
||||||
|
? props.schema._def.innerType._def.type
|
||||||
|
: null
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: getBaseType(schema),
|
||||||
|
schema,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// @ts-expect-error ignore missing `id`
|
||||||
|
provide(FieldContextKey, fieldContext)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FieldArray v-slot="{ fields, remove, push }" as="section" :name="fieldName">
|
||||||
|
<slot v-bind="props">
|
||||||
|
<Accordion type="multiple" class="w-full" collapsible :disabled="disabled">
|
||||||
|
<AccordionItem :value="fieldName" class="border-none">
|
||||||
|
<AccordionTrigger>
|
||||||
|
<AutoFormLabel class="text-base" :required="required">
|
||||||
|
{{ schema?.description || beautifyObjectName(fieldName) }}
|
||||||
|
</AutoFormLabel>
|
||||||
|
</AccordionTrigger>
|
||||||
|
|
||||||
|
<AccordionContent>
|
||||||
|
<template v-for="(field, index) of fields" :key="field.key">
|
||||||
|
<div class="mb-4 p-[1px]">
|
||||||
|
<AutoFormField
|
||||||
|
:field-name="`${fieldName}[${index}]`"
|
||||||
|
:label="fieldName"
|
||||||
|
:shape="itemShape!"
|
||||||
|
:config="config as ConfigItem"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="!my-4 flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="secondary"
|
||||||
|
@click="remove(index)"
|
||||||
|
>
|
||||||
|
<TrashIcon :size="16" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Separator v-if="!field.isLast" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
class="mt-4 flex items-center"
|
||||||
|
@click="push(null)"
|
||||||
|
>
|
||||||
|
<PlusIcon class="mr-2" :size="16" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</AccordionContent>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</slot>
|
||||||
|
</FieldArray>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { beautifyObjectName } from '../utils'
|
||||||
|
import type { FieldProps } from '../interface'
|
||||||
|
import AutoFormLabel from '../AutoFormLabel.vue'
|
||||||
|
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/default/ui/form'
|
||||||
|
import { Switch } from '@/lib/registry/default/ui/switch'
|
||||||
|
import { Checkbox } from '@/lib/registry/default/ui/checkbox'
|
||||||
|
|
||||||
|
const props = defineProps<FieldProps>()
|
||||||
|
|
||||||
|
const booleanComponent = computed(() => props.config?.component === 'switch' ? Switch : Checkbox)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormField v-slot="slotProps" :name="fieldName">
|
||||||
|
<FormItem>
|
||||||
|
<div class="space-y-0 mb-3 flex items-center gap-3">
|
||||||
|
<FormControl>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<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">
|
||||||
|
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||||
|
</AutoFormLabel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormDescription v-if="config?.description">
|
||||||
|
{{ config.description }}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DateFormatter, getLocalTimeZone } from '@internationalized/date'
|
||||||
|
import { CalendarIcon } from 'lucide-vue-next'
|
||||||
|
import { beautifyObjectName } from '../utils'
|
||||||
|
import AutoFormLabel from '../AutoFormLabel.vue'
|
||||||
|
import type { FieldProps } from '../interface'
|
||||||
|
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/default/ui/form'
|
||||||
|
|
||||||
|
import { Calendar } from '@/lib/registry/default/ui/calendar'
|
||||||
|
import { Button } from '@/lib/registry/default/ui/button'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/lib/registry/default/ui/popover'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
defineProps<FieldProps>()
|
||||||
|
|
||||||
|
const df = new DateFormatter('en-US', {
|
||||||
|
dateStyle: 'long',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormField v-slot="slotProps" :name="fieldName">
|
||||||
|
<FormItem>
|
||||||
|
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||||
|
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||||
|
</AutoFormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<div>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger as-child :disabled="disabled">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
:class="cn(
|
||||||
|
'w-full justify-start text-left font-normal',
|
||||||
|
!slotProps.componentField.modelValue && 'text-muted-foreground',
|
||||||
|
)"
|
||||||
|
>
|
||||||
|
<CalendarIcon class="mr-2 h-4 w-4" :size="16" />
|
||||||
|
{{ slotProps.componentField.modelValue ? df.format(slotProps.componentField.modelValue.toDate(getLocalTimeZone())) : "Pick a date" }}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-auto p-0">
|
||||||
|
<Calendar initial-focus v-bind="slotProps.componentField" />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription v-if="config?.description">
|
||||||
|
{{ config.description }}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { beautifyObjectName } from '../utils'
|
||||||
|
import type { FieldProps } from '../interface'
|
||||||
|
import AutoFormLabel from '../AutoFormLabel.vue'
|
||||||
|
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/default/ui/form'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/lib/registry/default/ui/select'
|
||||||
|
import { Label } from '@/lib/registry/default/ui/label'
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/lib/registry/default/ui/radio-group'
|
||||||
|
|
||||||
|
const props = defineProps<FieldProps & {
|
||||||
|
options?: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const computedOptions = computed(() => props.config?.enumProps?.options || props.options)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormField v-slot="slotProps" :name="fieldName">
|
||||||
|
<FormItem>
|
||||||
|
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||||
|
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||||
|
</AutoFormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<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 :disabled="disabled" v-bind="{ ...slotProps.componentField }">
|
||||||
|
<SelectTrigger class="w-full">
|
||||||
|
<SelectValue :placeholder="config?.enumProps?.placeholder" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem v-for="option in computedOptions" :key="option" :value="option">
|
||||||
|
{{ beautifyObjectName(option) }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</slot>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription v-if="config?.description">
|
||||||
|
{{ config.description }}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { TrashIcon } from 'lucide-vue-next'
|
||||||
|
import { beautifyObjectName } from '../utils'
|
||||||
|
import type { FieldProps } from '../interface'
|
||||||
|
import AutoFormLabel from '../AutoFormLabel.vue'
|
||||||
|
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/default/ui/form'
|
||||||
|
import { Input } from '@/lib/registry/default/ui/input'
|
||||||
|
import { Button } from '@/lib/registry/default/ui/button'
|
||||||
|
|
||||||
|
defineProps<FieldProps>()
|
||||||
|
|
||||||
|
const inputFile = ref<File>()
|
||||||
|
async function parseFileAsString(file: File | undefined): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onloadend = () => {
|
||||||
|
resolve(reader.result as string)
|
||||||
|
}
|
||||||
|
reader.onerror = (err) => {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormField v-slot="slotProps" :name="fieldName">
|
||||||
|
<FormItem v-bind="$attrs">
|
||||||
|
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||||
|
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||||
|
</AutoFormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<Input
|
||||||
|
v-if="!inputFile"
|
||||||
|
type="file"
|
||||||
|
v-bind="{ ...config?.inputProps }"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="async (ev: InputEvent) => {
|
||||||
|
const file = (ev.target as HTMLInputElement).files?.[0]
|
||||||
|
inputFile = file
|
||||||
|
const parsed = await parseFileAsString(file)
|
||||||
|
slotProps.componentField.onInput(parsed)
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<div v-else class="flex h-10 w-full items-center justify-between rounded-md border border-input bg-transparent pl-3 pr-1 py-1 text-sm shadow-sm transition-colors">
|
||||||
|
<p>{{ inputFile?.name }}</p>
|
||||||
|
<Button
|
||||||
|
:size="'icon'"
|
||||||
|
:variant="'ghost'"
|
||||||
|
class="h-[26px] w-[26px]"
|
||||||
|
aria-label="Remove file"
|
||||||
|
type="button"
|
||||||
|
@click="() => {
|
||||||
|
inputFile = undefined
|
||||||
|
slotProps.componentField.onInput(undefined)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<TrashIcon :size="16" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription v-if="config?.description">
|
||||||
|
{{ config.description }}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { beautifyObjectName } from '../utils'
|
||||||
|
import type { FieldProps } from '../interface'
|
||||||
|
import AutoFormLabel from '../AutoFormLabel.vue'
|
||||||
|
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/default/ui/form'
|
||||||
|
import { Input } from '@/lib/registry/default/ui/input'
|
||||||
|
import { Textarea } from '@/lib/registry/default/ui/textarea'
|
||||||
|
|
||||||
|
const props = defineProps<FieldProps>()
|
||||||
|
const inputComponent = computed(() => props.config?.component === 'textarea' ? Textarea : Input)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormField v-slot="slotProps" :name="fieldName">
|
||||||
|
<FormItem v-bind="$attrs">
|
||||||
|
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||||
|
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||||
|
</AutoFormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<component
|
||||||
|
:is="inputComponent"
|
||||||
|
type="text"
|
||||||
|
v-bind="{ ...slotProps.componentField, ...config?.inputProps }"
|
||||||
|
:disabled="disabled"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription v-if="config?.description">
|
||||||
|
{{ config.description }}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { beautifyObjectName } from '../utils'
|
||||||
|
import type { FieldProps } from '../interface'
|
||||||
|
import AutoFormLabel from '../AutoFormLabel.vue'
|
||||||
|
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/default/ui/form'
|
||||||
|
import { Input } from '@/lib/registry/default/ui/input'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<FieldProps>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormField v-slot="slotProps" :name="fieldName">
|
||||||
|
<FormItem>
|
||||||
|
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||||
|
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||||
|
</AutoFormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<slot v-bind="slotProps">
|
||||||
|
<Input type="number" v-bind="{ ...slotProps.componentField, ...config?.inputProps }" :disabled="disabled" />
|
||||||
|
</slot>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription v-if="config?.description">
|
||||||
|
{{ config.description }}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
<script setup lang="ts" generic="T extends ZodRawShape">
|
||||||
|
import type { ZodAny, ZodObject, ZodRawShape } from 'zod'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { Config, ConfigItem, Shape } from '../interface'
|
||||||
|
import { beautifyObjectName, getBaseSchema, getBaseType, getDefaultValueInZodStack } from '../utils'
|
||||||
|
import AutoFormField from '../AutoFormField.vue'
|
||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/lib/registry/default/ui/accordion'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
fieldName: string
|
||||||
|
required?: boolean
|
||||||
|
config?: Config<T>
|
||||||
|
schema?: ZodObject<T>
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const shapes = computed(() => {
|
||||||
|
// @ts-expect-error ignore {} not assignable to object
|
||||||
|
const val: { [key in keyof T]: Shape } = {}
|
||||||
|
|
||||||
|
if (!props.schema)
|
||||||
|
return
|
||||||
|
const shape = getBaseSchema(props.schema)?.shape
|
||||||
|
if (!shape)
|
||||||
|
return
|
||||||
|
Object.keys(shape).forEach((name) => {
|
||||||
|
const item = shape[name] as ZodAny
|
||||||
|
let options = 'values' in item._def ? item._def.values as string[] : undefined
|
||||||
|
if (!Array.isArray(options) && typeof options === 'object')
|
||||||
|
options = Object.values(options)
|
||||||
|
|
||||||
|
val[name as keyof T] = {
|
||||||
|
type: getBaseType(item),
|
||||||
|
default: getDefaultValueInZodStack(item),
|
||||||
|
options,
|
||||||
|
required: !['ZodOptional', 'ZodNullable'].includes(item._def.typeName),
|
||||||
|
schema: item,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return val
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<slot v-bind="props">
|
||||||
|
<Accordion type="multiple" class="w-full" collapsible :disabled="disabled">
|
||||||
|
<AccordionItem :value="fieldName" class="border-none">
|
||||||
|
<AccordionTrigger class="text-base">
|
||||||
|
{{ schema?.description || beautifyObjectName(fieldName) }}
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent class="p-[1px] space-y-5">
|
||||||
|
<template v-for="(shape, key) in shapes" :key="key">
|
||||||
|
<AutoFormField
|
||||||
|
:config="config?.[key as keyof typeof config] as ConfigItem"
|
||||||
|
:field-name="`${fieldName}.${key.toString()}`"
|
||||||
|
:label="key.toString()"
|
||||||
|
:shape="shape"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</slot>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export { default as AutoFormFieldArray } from './Array.vue'
|
||||||
|
export { default as AutoFormFieldBoolean } from './Boolean.vue'
|
||||||
|
export { default as AutoFormFieldDate } from './Date.vue'
|
||||||
|
export { default as AutoFormFieldEnum } from './Enum.vue'
|
||||||
|
export { default as AutoFormFieldFile } from './File.vue'
|
||||||
|
export { default as AutoFormFieldInput } from './Input.vue'
|
||||||
|
export { default as AutoFormFieldNumber } from './Number.vue'
|
||||||
|
export { default as AutoFormFieldObject } from './Object.vue'
|
||||||
5
apps/www/src/lib/registry/default/ui/auto-form/index.ts
Normal file
5
apps/www/src/lib/registry/default/ui/auto-form/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export { default as AutoForm } from './AutoForm.vue'
|
||||||
|
export { default as AutoFormField } from './AutoFormField.vue'
|
||||||
|
export { getObjectFormSchema, getBaseSchema, getBaseType } from './utils'
|
||||||
|
export type { Config, ConfigItem } from './interface'
|
||||||
|
export * from './fields'
|
||||||
82
apps/www/src/lib/registry/default/ui/auto-form/interface.ts
Normal file
82
apps/www/src/lib/registry/default/ui/auto-form/interface.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import type { InputHTMLAttributes, SelectHTMLAttributes } from 'vue'
|
||||||
|
import type { ZodAny, z } from 'zod'
|
||||||
|
import type { INPUT_COMPONENTS } from './constant'
|
||||||
|
|
||||||
|
export interface FieldProps {
|
||||||
|
fieldName: string
|
||||||
|
label?: string
|
||||||
|
required?: boolean
|
||||||
|
config?: ConfigItem
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Shape {
|
||||||
|
type: string
|
||||||
|
default?: any
|
||||||
|
required?: boolean
|
||||||
|
options?: string[]
|
||||||
|
schema?: ZodAny
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigItem {
|
||||||
|
/** Value for the `FormLabel` */
|
||||||
|
label?: string
|
||||||
|
/** Value for the `FormDescription` */
|
||||||
|
description?: string
|
||||||
|
/** Pick which component to be rendered. */
|
||||||
|
component?: keyof typeof INPUT_COMPONENTS
|
||||||
|
/** Hide `FormLabel`. */
|
||||||
|
hideLabel?: boolean
|
||||||
|
inputProps?: InputHTMLAttributes
|
||||||
|
enumProps?: SelectHTMLAttributes & { options?: any[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define a type to unwrap an array
|
||||||
|
type UnwrapArray<T> = T extends (infer U)[] ? U : never
|
||||||
|
|
||||||
|
export type Config<SchemaType extends object> = {
|
||||||
|
// If SchemaType.key is an object, create a nested Config, otherwise ConfigItem
|
||||||
|
[Key in keyof SchemaType]?:
|
||||||
|
SchemaType[Key] extends any[]
|
||||||
|
? UnwrapArray<Config<SchemaType[Key]>>
|
||||||
|
: SchemaType[Key] extends object
|
||||||
|
? Config<SchemaType[Key]>
|
||||||
|
: ConfigItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DependencyType {
|
||||||
|
DISABLES,
|
||||||
|
REQUIRES,
|
||||||
|
HIDES,
|
||||||
|
SETS_OPTIONS,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseDependency<SchemaType extends z.infer<z.ZodObject<any, any>>> {
|
||||||
|
sourceField: keyof SchemaType
|
||||||
|
type: DependencyType
|
||||||
|
targetField: keyof SchemaType
|
||||||
|
when: (sourceFieldValue: any, targetFieldValue: any) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValueDependency<SchemaType extends z.infer<z.ZodObject<any, any>>> =
|
||||||
|
BaseDependency<SchemaType> & {
|
||||||
|
type:
|
||||||
|
| DependencyType.DISABLES
|
||||||
|
| DependencyType.REQUIRES
|
||||||
|
| DependencyType.HIDES
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnumValues = readonly [string, ...string[]]
|
||||||
|
|
||||||
|
export type OptionsDependency<
|
||||||
|
SchemaType extends z.infer<z.ZodObject<any, any>>,
|
||||||
|
> = BaseDependency<SchemaType> & {
|
||||||
|
type: DependencyType.SETS_OPTIONS
|
||||||
|
|
||||||
|
// Partial array of values from sourceField that will trigger the dependency
|
||||||
|
options: EnumValues
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Dependency<SchemaType extends z.infer<z.ZodObject<any, any>>> =
|
||||||
|
| ValueDependency<SchemaType>
|
||||||
|
| OptionsDependency<SchemaType>
|
||||||
94
apps/www/src/lib/registry/default/ui/auto-form/utils.ts
Normal file
94
apps/www/src/lib/registry/default/ui/auto-form/utils.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import type { z } from 'zod'
|
||||||
|
|
||||||
|
// TODO: This should support recursive ZodEffects but TypeScript doesn't allow circular type definitions.
|
||||||
|
export type ZodObjectOrWrapped =
|
||||||
|
| z.ZodObject<any, any>
|
||||||
|
| z.ZodEffects<z.ZodObject<any, any>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beautify a camelCase string.
|
||||||
|
* e.g. "myString" -> "My String"
|
||||||
|
*/
|
||||||
|
export function beautifyObjectName(string: string) {
|
||||||
|
// Remove bracketed indices
|
||||||
|
// if numbers only return the string
|
||||||
|
let output = string.replace(/\[\d+\]/g, '').replace(/([A-Z])/g, ' $1')
|
||||||
|
output = output.charAt(0).toUpperCase() + output.slice(1)
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export function getBaseSchema<
|
||||||
|
ChildType extends z.ZodAny | z.AnyZodObject = z.ZodAny,
|
||||||
|
>(schema: ChildType | z.ZodEffects<ChildType>): ChildType | null {
|
||||||
|
if (!schema)
|
||||||
|
return null
|
||||||
|
if ('innerType' in schema._def)
|
||||||
|
return getBaseSchema(schema._def.innerType as ChildType)
|
||||||
|
|
||||||
|
if ('schema' in schema._def)
|
||||||
|
return getBaseSchema(schema._def.schema as ChildType)
|
||||||
|
|
||||||
|
return schema as ChildType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the type name of the lowest level Zod type.
|
||||||
|
* This will unpack optionals, refinements, etc.
|
||||||
|
*/
|
||||||
|
export function getBaseType(schema: z.ZodAny) {
|
||||||
|
const baseSchema = getBaseSchema(schema)
|
||||||
|
return baseSchema ? baseSchema._def.typeName : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for a "ZodDefault" in the Zod stack and return its value.
|
||||||
|
*/
|
||||||
|
export function getDefaultValueInZodStack(schema: z.ZodAny): any {
|
||||||
|
const typedSchema = schema as unknown as z.ZodDefault<
|
||||||
|
z.ZodNumber | z.ZodString
|
||||||
|
>
|
||||||
|
|
||||||
|
if (typedSchema._def.typeName === 'ZodDefault')
|
||||||
|
return typedSchema._def.defaultValue()
|
||||||
|
|
||||||
|
if ('innerType' in typedSchema._def) {
|
||||||
|
return getDefaultValueInZodStack(
|
||||||
|
typedSchema._def.innerType as unknown as z.ZodAny,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if ('schema' in typedSchema._def) {
|
||||||
|
return getDefaultValueInZodStack(
|
||||||
|
(typedSchema._def as any).schema as z.ZodAny,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getObjectFormSchema(
|
||||||
|
schema: ZodObjectOrWrapped,
|
||||||
|
): z.ZodObject<any, any> {
|
||||||
|
if (schema?._def.typeName === 'ZodEffects') {
|
||||||
|
const typedSchema = schema as z.ZodEffects<z.ZodObject<any, any>>
|
||||||
|
return getObjectFormSchema(typedSchema._def.schema)
|
||||||
|
}
|
||||||
|
return schema as z.ZodObject<any, any>
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user