chore: initial poc
This commit is contained in:
parent
fbe14a20c1
commit
0d868693e0
|
|
@ -8,7 +8,7 @@ export interface NavItem {
|
|||
}
|
||||
|
||||
export type SidebarNavItem = NavItem & {
|
||||
items: SidebarNavItem[]
|
||||
items?: SidebarNavItem[]
|
||||
}
|
||||
|
||||
export type NavItemWithChildren = NavItem & {
|
||||
|
|
@ -134,6 +134,16 @@ export const docsConfig: DocsConfig = {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Auto Form',
|
||||
items: [
|
||||
{
|
||||
title: 'Test',
|
||||
href: '/docs/components/auto-form',
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Components',
|
||||
items: [
|
||||
|
|
|
|||
|
|
@ -38,6 +38,13 @@ export const Index = {
|
|||
component: () => import("../src/lib/registry/default/example/AspectRatioDemo.vue").then((m) => m.default),
|
||||
files: ["../src/lib/registry/default/example/AspectRatioDemo.vue"],
|
||||
},
|
||||
"AutoForm": {
|
||||
name: "AutoForm",
|
||||
type: "components:example",
|
||||
registryDependencies: ["button","form","input","toast"],
|
||||
component: () => import("../src/lib/registry/default/example/AutoForm.vue").then((m) => m.default),
|
||||
files: ["../src/lib/registry/default/example/AutoForm.vue"],
|
||||
},
|
||||
"AvatarDemo": {
|
||||
name: "AvatarDemo",
|
||||
type: "components:example",
|
||||
|
|
@ -1278,6 +1285,13 @@ export const Index = {
|
|||
component: () => import("../src/lib/registry/new-york/example/AspectRatioDemo.vue").then((m) => m.default),
|
||||
files: ["../src/lib/registry/new-york/example/AspectRatioDemo.vue"],
|
||||
},
|
||||
"AutoForm": {
|
||||
name: "AutoForm",
|
||||
type: "components:example",
|
||||
registryDependencies: ["button","form","input","toast"],
|
||||
component: () => import("../src/lib/registry/new-york/example/AutoForm.vue").then((m) => m.default),
|
||||
files: ["../src/lib/registry/new-york/example/AutoForm.vue"],
|
||||
},
|
||||
"AvatarDemo": {
|
||||
name: "AvatarDemo",
|
||||
type: "components:example",
|
||||
|
|
|
|||
7
apps/www/src/content/docs/components/auto-form.md
Normal file
7
apps/www/src/content/docs/components/auto-form.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
title: Auto Form
|
||||
description: Building forms with VeeValidate and Zod.
|
||||
primitive: https://vee-validate.logaretm.com/v4/guide/overview/
|
||||
---
|
||||
|
||||
<ComponentPreview name="AutoForm" />
|
||||
53
apps/www/src/lib/registry/default/example/AutoForm.vue
Normal file
53
apps/www/src/lib/registry/default/example/AutoForm.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<script setup lang="ts">
|
||||
import { h } from 'vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
|
||||
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'
|
||||
|
||||
const formSchema = toTypedSchema(z.object({
|
||||
username: z.string().min(2).max(50),
|
||||
}))
|
||||
|
||||
const { handleSubmit } = useForm({
|
||||
validationSchema: formSchema,
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
toast({
|
||||
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))),
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="w-2/3 space-y-6" @submit="onSubmit">
|
||||
<FormField v-slot="{ componentField }" name="username">
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="shadcn" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is your public display name.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<Button type="submit">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</template>
|
||||
178
apps/www/src/lib/registry/new-york/example/AutoForm.vue
Normal file
178
apps/www/src/lib/registry/new-york/example/AutoForm.vue
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<script setup lang="ts">
|
||||
import { h } from 'vue'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
|
||||
import AutoFormField from '../ui/auto-form/AutoFormField.vue'
|
||||
import { Button } from '@/lib/registry/new-york/ui/button'
|
||||
import { toast } from '@/lib/registry/new-york/ui/toast'
|
||||
import { AutoForm, getBaseSchema, getBaseType } from '@/lib/registry/new-york/ui/auto-form'
|
||||
|
||||
enum Sports {
|
||||
Football = 'Football/Soccer',
|
||||
Basketball = 'Basketball',
|
||||
Baseball = 'Baseball',
|
||||
Hockey = 'Hockey (Ice)',
|
||||
None = 'I don\'t like sports',
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
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.',
|
||||
})
|
||||
.describe('Your secure password')
|
||||
.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()
|
||||
.describe('Accept terms and conditions.')
|
||||
.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'])
|
||||
.describe('How many marshmallows fit in your mouth?'),
|
||||
|
||||
// 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().describe('Text file'),
|
||||
|
||||
subObject: z.object({
|
||||
subField: z.string().optional().default('Sub Field'),
|
||||
numberField: z.number().optional().default(1),
|
||||
|
||||
subSubObject: z
|
||||
.object({
|
||||
subSubField: z.string().default('Sub Sub Field'),
|
||||
})
|
||||
.describe('Sub Sub Object Description'),
|
||||
}),
|
||||
optionalSubObject: z
|
||||
.object({
|
||||
optionalSubField: z.string(),
|
||||
otherOptionalSubField: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
})
|
||||
|
||||
const formSchema = toTypedSchema(schema)
|
||||
|
||||
const { handleSubmit } = useForm({
|
||||
validationSchema: formSchema,
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
toast({
|
||||
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))),
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AutoForm
|
||||
class="w-2/3 space-y-6"
|
||||
:schema="schema"
|
||||
:field-config="{
|
||||
username: {
|
||||
description: 'Test description',
|
||||
inputProps: {
|
||||
id: '123',
|
||||
},
|
||||
},
|
||||
password: {
|
||||
inputProps: {
|
||||
id: '345',
|
||||
type: 'password',
|
||||
},
|
||||
},
|
||||
acceptTerms: {
|
||||
description: 'this is custom',
|
||||
},
|
||||
sendMeMails: {
|
||||
component: 'switch',
|
||||
},
|
||||
color: {
|
||||
enumProps: {
|
||||
options: ['red', 'green', 'blue'],
|
||||
placeholder: 'Choose a color',
|
||||
},
|
||||
},
|
||||
|
||||
marshmallows: {
|
||||
component: 'radio',
|
||||
},
|
||||
bio: {
|
||||
component: 'textarea',
|
||||
},
|
||||
|
||||
subObject: {
|
||||
|
||||
},
|
||||
}"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<template #username="componentField">
|
||||
<div class="w-1/3">
|
||||
<AutoFormField v-bind="{ ...componentField, name: 'username' }" />
|
||||
</div>
|
||||
</template>
|
||||
<template #password="componentField">
|
||||
<div class="w-1/3">
|
||||
<AutoFormField v-bind="{ ...componentField, name: 'password' }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Button type="submit">
|
||||
Submit
|
||||
</Button>
|
||||
</AutoForm>
|
||||
</template>
|
||||
63
apps/www/src/lib/registry/new-york/ui/auto-form/AutoForm.vue
Normal file
63
apps/www/src/lib/registry/new-york/ui/auto-form/AutoForm.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<script setup lang="ts" generic="T extends ZodRawShape">
|
||||
import { computed } from 'vue'
|
||||
import type { ZodAny, ZodObject, ZodRawShape } from 'zod'
|
||||
import { getBaseType, getDefaultValueInZodStack } from './utils'
|
||||
import type { Config } from './interface'
|
||||
import AutoFormField from './AutoFormField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
schema: ZodObject<T>
|
||||
fieldConfig?: {
|
||||
[key in keyof T]?: Config
|
||||
}
|
||||
}>()
|
||||
|
||||
const shapes = computed(() => {
|
||||
// @ts-expect-error ignore {} not assignable to object
|
||||
const val: {
|
||||
[key in keyof T]: {
|
||||
type: string
|
||||
default: any
|
||||
required?: boolean
|
||||
options?: string[]
|
||||
schema?: ZodAny
|
||||
}
|
||||
} = {}
|
||||
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
|
||||
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>
|
||||
<form>
|
||||
<template v-for="(shape, key) of shapes" :key="key">
|
||||
<slot
|
||||
:shape="shape"
|
||||
:name="key.toString()"
|
||||
:config="fieldConfig?.[key as keyof typeof fieldConfig] as Config"
|
||||
>
|
||||
<AutoFormField
|
||||
:config="fieldConfig?.[key as keyof typeof fieldConfig] as Config"
|
||||
:name="key.toString()"
|
||||
:shape="shape"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<slot :shapes="shapes" />
|
||||
</form>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<script setup lang="ts">
|
||||
import type { ZodAny } from 'zod'
|
||||
|
||||
import type { Config } from './interface'
|
||||
import { DEFAULT_ZOD_HANDLERS, INPUT_COMPONENTS } from './constant'
|
||||
|
||||
defineProps<{
|
||||
name: string
|
||||
shape: {
|
||||
type: string
|
||||
required?: boolean
|
||||
options?: any[]
|
||||
schema?: ZodAny
|
||||
}
|
||||
config?: Config
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="INPUT_COMPONENTS[DEFAULT_ZOD_HANDLERS[shape.type]] "
|
||||
:name="name"
|
||||
:required="shape.required"
|
||||
:options="shape.options"
|
||||
:config="config"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
|
||||
<!-- <FieldInput
|
||||
v-if="shape.type === ZodFirstPartyTypeKind.ZodString"
|
||||
:name="name"
|
||||
:required="shape.required"
|
||||
:config="config"
|
||||
/>
|
||||
<FieldInput
|
||||
v-else-if="shape.type === ZodFirstPartyTypeKind.ZodNumber"
|
||||
:name="name"
|
||||
:required="shape.required"
|
||||
type="number"
|
||||
:config="config"
|
||||
/>
|
||||
<FieldBoolean
|
||||
v-else-if="shape.type === ZodFirstPartyTypeKind.ZodBoolean"
|
||||
:name="name"
|
||||
:required="shape.required"
|
||||
:config="config"
|
||||
/>
|
||||
<FieldDate
|
||||
v-else-if="shape.type === ZodFirstPartyTypeKind.ZodDate"
|
||||
:name="name"
|
||||
:required="shape.required"
|
||||
:config="config"
|
||||
/>
|
||||
<FieldEnum
|
||||
v-else-if="shape.type === ZodFirstPartyTypeKind.ZodEnum"
|
||||
:name="name"
|
||||
:required="shape.required"
|
||||
:config="config"
|
||||
:options="shape.options"
|
||||
/> -->
|
||||
</template>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { FormLabel } from '@/lib/registry/new-york/ui/form'
|
||||
|
||||
defineProps<{
|
||||
required?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormLabel>
|
||||
<slot />
|
||||
<span v-if="required" class="text-destructive"> *</span>
|
||||
</FormLabel>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
obkect
|
||||
</template>
|
||||
31
apps/www/src/lib/registry/new-york/ui/auto-form/constant.ts
Normal file
31
apps/www/src/lib/registry/new-york/ui/auto-form/constant.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import FieldNumber from './fields/Number.vue'
|
||||
import FieldInput from './fields/Input.vue'
|
||||
import FieldBoolean from './fields/Boolean.vue'
|
||||
import FieldDate from './fields/Date.vue'
|
||||
import FieldEnum from './fields/Enum.vue'
|
||||
|
||||
export const INPUT_COMPONENTS = {
|
||||
date: FieldDate,
|
||||
select: FieldEnum,
|
||||
radio: FieldEnum,
|
||||
checkbox: FieldBoolean,
|
||||
switch: FieldBoolean,
|
||||
textarea: FieldInput,
|
||||
number: FieldNumber,
|
||||
string: FieldInput,
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
import { beautifyObjectName } from '../utils'
|
||||
import type { Config } from '../interface'
|
||||
import AutoFormLabel from '../AutoFormLabel.vue'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/lib/registry/new-york/ui/form'
|
||||
import { Switch } from '@/lib/registry/new-york/ui/switch'
|
||||
import { Checkbox } from '@/lib/registry/new-york/ui/checkbox'
|
||||
|
||||
defineProps<{
|
||||
name: string
|
||||
required?: boolean
|
||||
config?: Config
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField v-slot="{ componentField }" :name="name">
|
||||
<FormItem>
|
||||
<div class="space-y-0 mb-3 flex items-center gap-3">
|
||||
<FormControl>
|
||||
<Switch v-if="config?.component === 'switch'" v-bind="{ ...componentField }" />
|
||||
<Checkbox v-else v-bind="{ ...componentField }" />
|
||||
</FormControl>
|
||||
<AutoFormLabel :required="required">
|
||||
{{ config?.label || beautifyObjectName(name) }}
|
||||
</AutoFormLabel>
|
||||
</div>
|
||||
|
||||
<FormDescription v-if="config?.description">
|
||||
{{ config.description }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<script setup lang="ts">
|
||||
import { DateFormatter, getLocalTimeZone } from '@internationalized/date'
|
||||
import { CalendarIcon } from '@radix-icons/vue'
|
||||
import { beautifyObjectName } from '../utils'
|
||||
import type { Config } from '../interface'
|
||||
import AutoFormLabel from '../AutoFormLabel.vue'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/lib/registry/new-york/ui/form'
|
||||
|
||||
import { Calendar } from '@/lib/registry/new-york/ui/calendar'
|
||||
import { Button } from '@/lib/registry/new-york/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/lib/registry/new-york/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineProps<{
|
||||
name: string
|
||||
required?: boolean
|
||||
config?: Config
|
||||
}>()
|
||||
|
||||
const df = new DateFormatter('en-US', {
|
||||
dateStyle: 'long',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField v-slot="{ componentField }" :name="name">
|
||||
<FormItem>
|
||||
<AutoFormLabel :required="required">
|
||||
{{ config?.label || beautifyObjectName(name) }}
|
||||
</AutoFormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
:class="cn(
|
||||
'w-full justify-start text-left font-normal',
|
||||
!componentField.modelValue && 'text-muted-foreground',
|
||||
)"
|
||||
>
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
{{ componentField.modelValue ? df.format(componentField.modelValue.toDate(getLocalTimeZone())) : "Pick a date" }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0">
|
||||
<Calendar initial-focus v-bind="{ ...componentField }" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription v-if="config?.description">
|
||||
{{ config.description }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { beautifyObjectName } from '../utils'
|
||||
import type { Config } from '../interface'
|
||||
import AutoFormLabel from '../AutoFormLabel.vue'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/lib/registry/new-york/ui/form'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/lib/registry/new-york/ui/select'
|
||||
import { Label } from '@/lib/registry/new-york/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/lib/registry/new-york/ui/radio-group'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
required?: boolean
|
||||
options?: string[]
|
||||
config?: Config
|
||||
}>()
|
||||
|
||||
const computedOptions = computed(() => props.config?.enumProps?.options || props.options)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField v-slot="{ componentField }" :name="name">
|
||||
<FormItem>
|
||||
<AutoFormLabel :required="required">
|
||||
{{ config?.label || beautifyObjectName(name) }}
|
||||
</AutoFormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup v-if="config?.component === 'radio'" :orientation="'vertical'" v-bind="{ ...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="{ ...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>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription v-if="config?.description">
|
||||
{{ config.description }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<script setup lang="ts">
|
||||
import { beautifyObjectName } from '../utils'
|
||||
import type { Config } from '../interface'
|
||||
import AutoFormLabel from '../AutoFormLabel.vue'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/lib/registry/new-york/ui/form'
|
||||
import { Input } from '@/lib/registry/new-york/ui/input'
|
||||
import { Textarea } from '@/lib/registry/new-york/ui/textarea'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
name: string
|
||||
required?: boolean
|
||||
config?: Config
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField v-slot="{ componentField }" :name="name">
|
||||
<FormItem>
|
||||
<AutoFormLabel :required="required">
|
||||
{{ config?.label || beautifyObjectName(name) }}
|
||||
</AutoFormLabel>
|
||||
<FormControl>
|
||||
<Textarea v-if="config?.component === 'textarea'" type="text" v-bind="{ ...$attrs, ...componentField, ...config?.inputProps }" />
|
||||
<Input v-else type="text" v-bind="{ ...$attrs, ...componentField, ...config?.inputProps }" />
|
||||
</FormControl>
|
||||
<FormDescription v-if="config?.description">
|
||||
{{ config.description }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<script setup lang="ts">
|
||||
import { beautifyObjectName } from '../utils'
|
||||
import type { Config } from '../interface'
|
||||
import AutoFormLabel from '../AutoFormLabel.vue'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/new-york/ui/form'
|
||||
import { Input } from '@/lib/registry/new-york/ui/input'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
name: string
|
||||
required?: boolean
|
||||
config?: Config
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField v-slot="{ componentField }" :name="name">
|
||||
<FormItem>
|
||||
<AutoFormLabel :required="required">
|
||||
{{ config?.label || beautifyObjectName(name) }}
|
||||
</AutoFormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" v-bind="{ ...$attrs, ...componentField, ...config?.inputProps }" />
|
||||
</FormControl>
|
||||
<FormDescription v-if="config?.description">
|
||||
{{ config.description }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
2
apps/www/src/lib/registry/new-york/ui/auto-form/index.ts
Normal file
2
apps/www/src/lib/registry/new-york/ui/auto-form/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as AutoForm } from './AutoForm.vue'
|
||||
export { getObjectFormSchema, getBaseSchema, getBaseType, getDefaultValues } from './utils'
|
||||
55
apps/www/src/lib/registry/new-york/ui/auto-form/interface.ts
Normal file
55
apps/www/src/lib/registry/new-york/ui/auto-form/interface.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { InputHTMLAttributes, SelectHTMLAttributes } from 'vue'
|
||||
import type * as z from 'zod'
|
||||
import type { INPUT_COMPONENTS } from './constant'
|
||||
|
||||
export interface Config {
|
||||
label?: string
|
||||
description?: string
|
||||
component?: keyof typeof INPUT_COMPONENTS
|
||||
inputProps?: InputHTMLAttributes
|
||||
enumProps?: SelectHTMLAttributes & { options?: any[] }
|
||||
}
|
||||
|
||||
export type FieldConfig<SchemaType extends z.infer<z.ZodObject<any, any>>> = {
|
||||
// If SchemaType.key is an object, create a nested FieldConfig, otherwise FieldConfigItem
|
||||
[Key in keyof SchemaType]?: SchemaType[Key] extends object
|
||||
? FieldConfig<z.infer<SchemaType[Key]>>
|
||||
: FieldConfig<z.infer<SchemaType[Key]>> // TODO;
|
||||
}
|
||||
|
||||
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>
|
||||
129
apps/www/src/lib/registry/new-york/ui/auto-form/utils.ts
Normal file
129
apps/www/src/lib/registry/new-york/ui/auto-form/utils.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { ZodFirstPartyTypeKind, type z } from 'zod'
|
||||
import type { FieldConfig } from './interface'
|
||||
|
||||
// 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) {
|
||||
// if numbers only return the string
|
||||
let output = string.replace(/([A-Z])/g, ' $1')
|
||||
output = output.charAt(0).toUpperCase() + output.slice(1)
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all default values from a Zod schema.
|
||||
*/
|
||||
export function getDefaultValues<Schema extends z.ZodObject<any, any>>(
|
||||
schema: Schema,
|
||||
fieldConfig?: FieldConfig<z.infer<Schema>>,
|
||||
) {
|
||||
if (!schema)
|
||||
return null
|
||||
const { shape } = schema
|
||||
type DefaultValuesType = Partial<z.infer<Schema>>
|
||||
const defaultValues = {} as DefaultValuesType
|
||||
if (!shape)
|
||||
return defaultValues
|
||||
|
||||
for (const key of Object.keys(shape)) {
|
||||
const item = shape[key] as z.ZodAny
|
||||
|
||||
// @ts-expect-error weird
|
||||
if (getBaseType(item) === ZodFirstPartyTypeKind.ZodObject) {
|
||||
const defaultItems = getDefaultValues(
|
||||
getBaseSchema(item) as unknown as z.ZodObject<any, any>,
|
||||
fieldConfig?.[key] as FieldConfig<z.infer<Schema>>,
|
||||
)
|
||||
|
||||
if (defaultItems !== null) {
|
||||
for (const defaultItemKey of Object.keys(defaultItems)) {
|
||||
const pathKey = `${key}.${defaultItemKey}` as keyof DefaultValuesType
|
||||
defaultValues[pathKey] = defaultItems[defaultItemKey]
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
let defaultValue = getDefaultValueInZodStack(item)
|
||||
if (
|
||||
(defaultValue === null || defaultValue === '')
|
||||
&& fieldConfig?.[key]?.inputProps
|
||||
) {
|
||||
defaultValue = (fieldConfig?.[key]?.inputProps as unknown as any)
|
||||
.defaultValue
|
||||
}
|
||||
if (defaultValue !== undefined)
|
||||
defaultValues[key as keyof DefaultValuesType] = defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValues
|
||||
}
|
||||
|
||||
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