chore: initial poc
This commit is contained in:
parent
fbe14a20c1
commit
0d868693e0
|
|
@ -8,7 +8,7 @@ export interface NavItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SidebarNavItem = NavItem & {
|
export type SidebarNavItem = NavItem & {
|
||||||
items: SidebarNavItem[]
|
items?: SidebarNavItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NavItemWithChildren = NavItem & {
|
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',
|
title: 'Components',
|
||||||
items: [
|
items: [
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,13 @@ export const Index = {
|
||||||
component: () => import("../src/lib/registry/default/example/AspectRatioDemo.vue").then((m) => m.default),
|
component: () => import("../src/lib/registry/default/example/AspectRatioDemo.vue").then((m) => m.default),
|
||||||
files: ["../src/lib/registry/default/example/AspectRatioDemo.vue"],
|
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": {
|
"AvatarDemo": {
|
||||||
name: "AvatarDemo",
|
name: "AvatarDemo",
|
||||||
type: "components:example",
|
type: "components:example",
|
||||||
|
|
@ -1278,6 +1285,13 @@ export const Index = {
|
||||||
component: () => import("../src/lib/registry/new-york/example/AspectRatioDemo.vue").then((m) => m.default),
|
component: () => import("../src/lib/registry/new-york/example/AspectRatioDemo.vue").then((m) => m.default),
|
||||||
files: ["../src/lib/registry/new-york/example/AspectRatioDemo.vue"],
|
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": {
|
"AvatarDemo": {
|
||||||
name: "AvatarDemo",
|
name: "AvatarDemo",
|
||||||
type: "components:example",
|
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