feat: Auto Form (#497)

* chore: initial poc

* chore: cleanup, log on the docs

* feat: add file component

* feat: typing for nested config

* feat: more props for form field

* feat: export field component, expose more slotprops

* feat: array, config label

* feat: improve array

* feat: support custom form state

* chore: prevent schema props showing on attribute

* feat: dependencies rendering

* refactor: change name to fieldName to allow easier slotProps binding

* feat: improve file upload

* feat: expose custom auto form slot

* chore: bump

* chore: replicate to default styling

* chore: build registry

* fix: export component before init

* chore: add examples

* chore: add form api example

* fix: warning in console

* chore: bump package version

* chore: update example, complete md

* feat: allow zod description as label, allow custom component

* docs: fix link

* feat: show required field for object

* chore: replace enumProps
This commit is contained in:
zernonia 2024-05-01 09:39:09 +08:00 committed by GitHub
parent 18e40cf002
commit 32d7b9ca4a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 3958 additions and 21 deletions

View File

@ -19,7 +19,7 @@ defineProps<CalloutProps>()
<AlertTitle v-if="title">
{{ title }}
</AlertTitle>
<AlertDescription>
<AlertDescription class="[&_a]:underline">
<slot />
</AlertDescription>
</Alert>

View File

@ -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: 'Extended',
items: [
{
title: 'Auto Form',
href: '/docs/components/auto-form',
items: [],
},
],
},
{
title: 'Components',
items: [

View File

@ -38,6 +38,62 @@ export const Index = {
component: () => import("../src/lib/registry/default/example/AspectRatioDemo.vue").then((m) => m.default),
files: ["../src/lib/registry/default/example/AspectRatioDemo.vue"],
},
"AutoFormApi": {
name: "AutoFormApi",
type: "components:example",
registryDependencies: ["button","toast","auto-form"],
component: () => import("../src/lib/registry/default/example/AutoFormApi.vue").then((m) => m.default),
files: ["../src/lib/registry/default/example/AutoFormApi.vue"],
},
"AutoFormArray": {
name: "AutoFormArray",
type: "components:example",
registryDependencies: ["button","toast","auto-form"],
component: () => import("../src/lib/registry/default/example/AutoFormArray.vue").then((m) => m.default),
files: ["../src/lib/registry/default/example/AutoFormArray.vue"],
},
"AutoFormBasic": {
name: "AutoFormBasic",
type: "components:example",
registryDependencies: ["button","toast","auto-form"],
component: () => import("../src/lib/registry/default/example/AutoFormBasic.vue").then((m) => m.default),
files: ["../src/lib/registry/default/example/AutoFormBasic.vue"],
},
"AutoFormConfirmPassword": {
name: "AutoFormConfirmPassword",
type: "components:example",
registryDependencies: ["button","toast","auto-form"],
component: () => import("../src/lib/registry/default/example/AutoFormConfirmPassword.vue").then((m) => m.default),
files: ["../src/lib/registry/default/example/AutoFormConfirmPassword.vue"],
},
"AutoFormControlled": {
name: "AutoFormControlled",
type: "components:example",
registryDependencies: ["button","toast","auto-form"],
component: () => import("../src/lib/registry/default/example/AutoFormControlled.vue").then((m) => m.default),
files: ["../src/lib/registry/default/example/AutoFormControlled.vue"],
},
"AutoFormDependencies": {
name: "AutoFormDependencies",
type: "components:example",
registryDependencies: ["button","toast","auto-form"],
component: () => import("../src/lib/registry/default/example/AutoFormDependencies.vue").then((m) => m.default),
files: ["../src/lib/registry/default/example/AutoFormDependencies.vue"],
},
"AutoFormInputWithoutLabel": {
name: "AutoFormInputWithoutLabel",
type: "components:example",
registryDependencies: ["button","toast","auto-form"],
component: () => import("../src/lib/registry/default/example/AutoFormInputWithoutLabel.vue").then((m) => m.default),
files: ["../src/lib/registry/default/example/AutoFormInputWithoutLabel.vue"],
},
"AutoFormSubObject": {
name: "AutoFormSubObject",
type: "components:example",
registryDependencies: ["button","toast","auto-form"],
component: () => import("../src/lib/registry/default/example/AutoFormSubObject.vue").then((m) => m.default),
files: ["../src/lib/registry/default/example/AutoFormSubObject.vue"],
},
"AvatarDemo": {
name: "AvatarDemo",
type: "components:example",
@ -1278,6 +1334,62 @@ 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"],
},
"AutoFormApi": {
name: "AutoFormApi",
type: "components:example",
registryDependencies: ["button","toast","auto-form"],
component: () => import("../src/lib/registry/new-york/example/AutoFormApi.vue").then((m) => m.default),
files: ["../src/lib/registry/new-york/example/AutoFormApi.vue"],
},
"AutoFormArray": {
name: "AutoFormArray",
type: "components:example",
registryDependencies: ["button","toast","auto-form"],
component: () => import("../src/lib/registry/new-york/example/AutoFormArray.vue").then((m) => m.default),
files: ["../src/lib/registry/new-york/example/AutoFormArray.vue"],
},
"AutoFormBasic": {
name: "AutoFormBasic",
type: "components:example",
registryDependencies: ["button","toast","auto-form"],
component: () => import("../src/lib/registry/new-york/example/AutoFormBasic.vue").then((m) => m.default),
files: ["../src/lib/registry/new-york/example/AutoFormBasic.vue"],
},
"AutoFormConfirmPassword": {
name: "AutoFormConfirmPassword",
type: "components:example",
registryDependencies: ["button","toast","auto-form"],
component: () => import("../src/lib/registry/new-york/example/AutoFormConfirmPassword.vue").then((m) => m.default),
files: ["../src/lib/registry/new-york/example/AutoFormConfirmPassword.vue"],
},
"AutoFormControlled": {
name: "AutoFormControlled",
type: "components:example",
registryDependencies: ["button","toast","auto-form"],
component: () => import("../src/lib/registry/new-york/example/AutoFormControlled.vue").then((m) => m.default),
files: ["../src/lib/registry/new-york/example/AutoFormControlled.vue"],
},
"AutoFormDependencies": {
name: "AutoFormDependencies",
type: "components:example",
registryDependencies: ["button","toast","auto-form"],
component: () => import("../src/lib/registry/new-york/example/AutoFormDependencies.vue").then((m) => m.default),
files: ["../src/lib/registry/new-york/example/AutoFormDependencies.vue"],
},
"AutoFormInputWithoutLabel": {
name: "AutoFormInputWithoutLabel",
type: "components:example",
registryDependencies: ["button","toast","auto-form"],
component: () => import("../src/lib/registry/new-york/example/AutoFormInputWithoutLabel.vue").then((m) => m.default),
files: ["../src/lib/registry/new-york/example/AutoFormInputWithoutLabel.vue"],
},
"AutoFormSubObject": {
name: "AutoFormSubObject",
type: "components:example",
registryDependencies: ["button","toast","auto-form"],
component: () => import("../src/lib/registry/new-york/example/AutoFormSubObject.vue").then((m) => m.default),
files: ["../src/lib/registry/new-york/example/AutoFormSubObject.vue"],
},
"AvatarDemo": {
name: "AvatarDemo",
type: "components:example",

View File

@ -37,7 +37,7 @@
"tailwindcss-animate": "^1.0.7",
"v-calendar": "^3.1.2",
"vaul-vue": "^0.1.0",
"vee-validate": "4.12.5",
"vee-validate": "4.12.6",
"vue": "^3.4.24",
"vue-sonner": "^1.1.2",
"vue-wrap-balancer": "^1.1.3",

View File

@ -0,0 +1,550 @@
---
title: AutoForm
description: Automatically generate a form from Zod schema.
primitive: https://vee-validate.logaretm.com/v4/guide/overview/
---
<Callout class="mt-6">
Credit: Heavily inspired by [AutoForm](https://github.com/vantezzen/auto-form) by Vantezzen
</Callout>
## What is AutoForm
AutoForm is a drop-in form builder for your internal and low-priority forms with existing zod schemas. For example, if you already have zod schemas for your API and want to create a simple admin panel to edit user profiles, simply pass the schema to AutoForm and you're done.
## Installation
<Steps>
### Run the following command
```bash
npx shadcn-vue@latest update form
npx shadcn-vue@latest add auto-form
```
</Steps>
## Field types
Currently, these field types are supported out of the box:
- boolean (checkbox, switch)
- date (date picker)
- enum (select, radio group)
- number (input)
- string (input, textfield)
- file (file)
You can add support for other field types by adding them to the `INPUT_COMPONENTS` object in `auto-form/constants.ts`.
## Zod configuration
### Validations
Your form schema can use any of zod's validation methods including refine.
<Callout>
⚠️ However, there's a known issue with Zods `refine` and `superRefine` not executing whenever some object keys are missing.
[Read more](https://github.com/logaretm/vee-validate/issues/4338)
</Callout>
### Descriptions
You can use the `describe` method to set a label for each field. If no label is set, the field name will be used and un-camel-cased.
```ts
const formSchema = z.object({
username: z.string().describe('Your username'),
someValue: z.string(), // Will be "Some Value"
})
```
You can also configure the label with [`fieldConfig`](#label) too.
### Optional fields
By default, all fields are required. You can make a field optional by using the `optional` method.
```ts
const formSchema = z.object({
username: z.string().optional(),
})
```
### Default values
You can set a default value for a field using the `default` method.
```ts
const formSchema = z.object({
favouriteNumber: z.number().default(5),
})
```
If you want to set default value of date, convert it to Date first using `new Date(val)`.
### Sub-objects
You can nest objects to create accordion sections.
```ts
const formSchema = z.object({
address: z.object({
street: z.string(),
city: z.string(),
zip: z.string(),
// You can nest objects as deep as you want
nested: z.object({
foo: z.string(),
bar: z.string(),
nested: z.object({
foo: z.string(),
bar: z.string(),
}),
}),
}),
})
```
Like with normal objects, you can use the `describe` method to set a label and description for the section:
```ts
const formSchema = z.object({
address: z
.object({
street: z.string(),
city: z.string(),
zip: z.string(),
})
.describe('Your address'),
})
```
### Select/Enums
AutoForm supports `enum` and `nativeEnum` to create select fields.
```ts
const formSchema = z.object({
color: z.enum(['red', 'green', 'blue']),
})
enum BreadTypes {
// For native enums, you can alternatively define a backed enum to set a custom label
White = 'White bread',
Brown = 'Brown bread',
Wholegrain = 'Wholegrain bread',
Other,
}
// Keep in mind that zod will validate and return the enum labels, not the enum values!
const formSchema = z.object({
bread: z.nativeEnum(BreadTypes),
})
```
### Arrays
AutoForm supports arrays _of objects_. Because inferring things like field labels from arrays of strings/numbers/etc. is difficult, only objects are supported.
```ts
const formSchema = z.object({
guestListName: z.string(),
invitedGuests: z
.array(
// Define the fields for each item
z.object({
name: z.string(),
age: z.number(),
})
)
// Optionally set a custom label - otherwise this will be inferred from the field name
.describe('Guests invited to the party'),
})
```
Arrays are not supported as the root element of the form schema.
You also can set default value of an array using .default(), but please make sure the array element has same structure with the schema.
```ts
const formSchema = z.object({
guestListName: z.string(),
invitedGuests: z
.array(
// Define the fields for each item
z.object({
name: z.string(),
age: z.number(),
})
)
.describe('Guests invited to the party')
.default([
{ name: 'John', age: 24, },
{ name: 'Jane', age: 20, },
]),
})
```
## Field configuration
As zod doesn't allow adding other properties to the schema, you can use the `fieldConfig` prop to add additional configuration for the UI of each field.
```vue
<template>
<AutoForm
:field-config="{
username: {
// fieldConfig
},
}"
/>
</template>
```
### Label
You can use the `label` property to customize label if you want to overwrite the pre-defined label via [Zod's description](#descriptions).
```vue
<template>
<AutoForm
:field-config="{
username: {
label: 'Custom username',
},
}"
/>
</template>
```
### Description
You can use the `description` property to add a description below the field.
```vue
<template>
<AutoForm
:field-config="{
username: {
description: 'Enter a unique username. This will be shown to other users.',
},
}"
/>
</template>
```
### Input props
You can use the `inputProps` property to pass props to the input component. You can use any props that the HTML component accepts.
```vue
<template>
<AutoForm
:field-config="{
username: {
inputProps: {
type: 'text',
placeholder: 'Username',
},
},
}"
/>
</template>
// This will be rendered as:
<input type="text" placeholder="Username" />
```
Disabling the label of an input can be done by using the `showLabel` property in `inputProps`.
```vue
<template>
<AutoForm
:field-config="{
username: {
inputProps: {
type: 'text',
placeholder: 'Username',
showLabel: false,
},
},
}"
/>
</template>
```
### Component
By default, AutoForm will use the Zod type to determine which input component to use. You can override this by using the `component` property.
```vue
<template>
<AutoForm
:field-config="{
acceptTerms: {
// Booleans use a checkbox by default, use a switch instead
component: 'switch',
},
}"
/>
</template>
```
The complete list of supported field types is typed. Current supported types are:
- `checkbox` (default for booleans)
- `switch`
- `date` (default for dates)
- `select` (default for enums)
- `radio`
- `textarea`
Alternatively, you can pass a Vue component to the `component` property to use a custom component.
In `CustomField.vue`
```vue
<script setup lang="ts">
import { computed } from 'vue'
import AutoFormLabel from './AutoFormLabel.vue'
import type { FieldProps } from './interface'
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/ui/form'
import { Input } from '@/ui/input'
import { AutoFormLabel } from '@/ui/auto-form'
const props = defineProps<FieldProps>()
</script>
<template>
<FormField v-slot="slotProps" :name="fieldName">
<FormItem v-bind="$attrs">
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
{{ config?.label }}
</AutoFormLabel>
<FormControl>
<CustomInput v-bind="slotProps" />
</FormControl>
<FormDescription v-if="config?.description">
{{ config.description }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</template>
```
Pass the above component in `fieldConfig`.
```vue
<template>
<AutoForm
:field-config="{
username: {
component: CustomField,
},
}"
/>
</template>
```
### Named slot
You can use Vue named slot to customize the rendered `AutoFormField`.
```vue
<template>
<AutoForm
:field-config="{
customParent: {
label: 'Wrapper',
},
}"
>
<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>
</AutoForm>
</template>
```
### Accessing the form data
There are two ways to access the form data:
### @submit
The preferred way is to use the `submit` emit. This will be called when the form is submitted and the data is valid.
```vue
<template>
<AutoForm
@submit="(data) => {
// Do something with the data
}"
/>
</template>
```
### Controlled form
By passing the `form` as props, you can control and use the method provided by `Form`.
```vue
<script setup lang="ts">
import * as z from 'zod'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
const schema = z.object({
username: z.string(),
})
const form = useForm({
validationSchema: toTypedSchema(schema),
})
form.setValues({
username: 'foo'
})
</script>
<template>
<AutoForm :form="form" :schema="schema" />
</template>
```
### Submitting the form
You can use any `button` component to create a submit button. Most importantly is to add attributes `type="submit"`.
```vue
<template>
<AutoForm>
<CustomButton type="submit">
Send now
</CustomButton>
</AutoForm>
// or
<AutoForm>
<button type="submit">
Send now
</button>
</AutoForm>
</template>
```
### Adding other elements
All children passed to the `AutoForm` component will be rendered below the form.
```vue
<template>
<AutoForm>
<Button>Send now</Button>
<p class="text-gray-500 text-sm">
By submitting this form, you agree to our
<a href="#" class="text-primary underline">
terms and conditions
</a>.
</p>
</AutoForm>
</template>
```
### Dependencies
AutoForm allows you to add dependencies between fields to control fields based on the value of other fields. For this, a `dependencies` array can be passed to the `AutoForm` component.
```vue
<template>
<AutoForm
:dependencies="[
{
// 'age' hides 'parentsAllowed' when the age is 18 or older
sourceField: 'age',
type: DependencyType.HIDES,
targetField: 'parentsAllowed',
when: age => age >= 18,
},
{
// 'vegetarian' checkbox hides the 'Beef Wellington' option from 'mealOptions'
// if its not already selected
sourceField: 'vegetarian',
type: DependencyType.SETS_OPTIONS,
targetField: 'mealOptions',
when: (vegetarian, mealOption) =>
vegetarian && mealOption !== 'Beef Wellington',
options: ['Pasta', 'Salad'],
},
]"
/>
</template>
```
The following dependency types are supported:
- `DependencyType.HIDES`: Hides the target field when the `when` function returns true
- `DependencyType.DISABLES`: Disables the target field when the `when` function returns true
- `DependencyType.REQUIRES`: Sets the target field to required when the `when` function returns true
- `DependencyType.SETS_OPTIONS`: Sets the options of the target field to the `options` array when the `when` function returns true
The `when` function is called with the value of the source field and the value of the target field and should return a boolean to indicate if the dependency should be applied.
Please note that dependencies will not cause the inverse action when returning `false` - for example, if you mark a field as required in your zod schema (i.e. by not explicitly setting `optional`), returning `false` in your `REQURIES` dependency will not mark it as optional. You should instead use zod's `optional` method to mark as optional by default and use the `REQURIES` dependency to mark it as required when the dependency is met.
Please note that dependencies do not have any effect on the validation of the form. You should use zod's `refine` method to validate the form based on the value of other fields.
You can create multiple dependencies for the same field and dependency type - for example to hide a field based on multiple other fields. This will then hide the field when any of the dependencies are met.
## Example
### Basic
<ComponentPreview name="AutoFormBasic" />
### Input Without Label
This example shows how to use AutoForm input without label.
<ComponentPreview name="AutoFormInputWithoutLabel" />
### Sub Object
Automatically generate a form from a Zod schema.
<ComponentPreview name="AutoFormSubObject" />
### Controlled
This example shows how to use AutoForm in a controlled way.
<ComponentPreview name="AutoFormControlled" />
### Confirm Password
Refined schema to validate that two fields match.
<ComponentPreview name="AutoFormConfirmPassword" />
### API Example
The form select options are fetched from an API.
<ComponentPreview name="AutoFormApi" />
### Array support
You can use arrays in your schemas to create dynamic forms.
<ComponentPreview name="AutoFormArray" />
### Dependencies
Create dependencies between fields.
<ComponentPreview name="AutoFormDependencies" />

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import * as z from 'zod'
import { h, onMounted, ref, shallowRef } from 'vue'
import { Button } from '@/lib/registry/default/ui/button'
import { toast } from '@/lib/registry/default/ui/toast'
import { AutoForm } from '@/lib/registry/default/ui/auto-form'
const schema = shallowRef<z.ZodObject< any, any, any > | null>(null)
onMounted(() => {
fetch('https://jsonplaceholder.typicode.com/users')
.then(response => response.json())
.then((data) => {
schema.value = z.object({
user: z.enum(data.map((user: any) => user.name)),
})
})
})
function onSubmit(values: Record<string, any>) {
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>
<div class="flex justify-center w-full">
<AutoForm
v-if="schema"
class="w-2/3 space-y-6"
:schema="schema"
@submit="onSubmit"
>
<Button type="submit">
Submit
</Button>
</AutoForm>
<div v-else>
Loading...
</div>
</div>
</template>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import * as z from 'zod'
import { h } from 'vue'
import { Button } from '@/lib/registry/default/ui/button'
import { toast } from '@/lib/registry/default/ui/toast'
import { AutoForm } from '@/lib/registry/default/ui/auto-form'
const schema = z.object({
guestListName: z.string(),
invitedGuests: z
.array(
z.object({
name: z.string(),
age: z.coerce.number(),
}),
)
.describe('Guests invited to the party'),
})
function onSubmit(values: Record<string, any>) {
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"
@submit="onSubmit"
>
<Button type="submit">
Submit
</Button>
</AutoForm>
</template>

View File

@ -0,0 +1,161 @@
<script setup lang="ts">
import * as z from 'zod'
import { h, reactive, ref } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { DependencyType } from '../ui/auto-form/interface'
import { Button } from '@/lib/registry/default/ui/button'
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'
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.',
})
.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(),
})
function onSubmit(values: Record<string, any>) {
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="{
password: {
label: 'Your secure password',
inputProps: {
type: 'password',
placeholder: '••••••••',
},
},
favouriteNumber: {
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">
Submit
</Button>
</AutoForm>
</template>

View File

@ -0,0 +1,36 @@
<script setup lang="ts">
import * as z from 'zod'
import { h } from 'vue'
import { Button } from '@/lib/registry/default/ui/button'
import { toast } from '@/lib/registry/default/ui/toast'
import { AutoForm } from '@/lib/registry/default/ui/auto-form'
const schema = z
.object({
password: z.string(),
confirm: z.string(),
})
.refine(data => data.password === data.confirm, {
message: 'Passwords must match.',
path: ['confirm'],
})
function onSubmit(values: Record<string, any>) {
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"
@submit="onSubmit"
>
<Button type="submit">
Submit
</Button>
</AutoForm>
</template>

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import * as z from 'zod'
import { h } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { Button } from '@/lib/registry/default/ui/button'
import { toast } from '@/lib/registry/default/ui/toast'
import { AutoForm } from '@/lib/registry/default/ui/auto-form'
const schema = z.object({
username: z.string(),
})
const form = useForm({
validationSchema: toTypedSchema(schema),
})
function onSubmit(values: Record<string, any>) {
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"
:form="form"
@submit="onSubmit"
>
<Button type="submit">
Submit
</Button>
</AutoForm>
</template>

View File

@ -0,0 +1,72 @@
<script setup lang="ts">
import * as z from 'zod'
import { h } from 'vue'
import { DependencyType } from '../ui/auto-form/interface'
import { Button } from '@/lib/registry/default/ui/button'
import { toast } from '@/lib/registry/default/ui/toast'
import { AutoForm } from '@/lib/registry/default/ui/auto-form'
const schema = z.object({
age: z.number(),
parentsAllowed: z.boolean().optional(),
vegetarian: z.boolean().optional(),
mealOptions: z.enum(['Pasta', 'Salad', 'Beef Wellington']).optional(),
})
function onSubmit(values: Record<string, any>) {
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="{
age: {
description:
'Setting this below 18 will require parents consent.',
},
parentsAllowed: {
label: 'Did your parents allow you to register?',
},
vegetarian: {
label: 'Are you a vegetarian?',
description:
'Setting this to true will remove non-vegetarian food options.',
},
mealOptions: {
component: 'radio',
},
}"
:dependencies="[
{
sourceField: 'age',
type: DependencyType.HIDES,
targetField: 'parentsAllowed',
when: (age) => age >= 18,
},
{
sourceField: 'age',
type: DependencyType.REQUIRES,
targetField: 'parentsAllowed',
when: (age) => age < 18,
},
{
sourceField: 'vegetarian',
type: DependencyType.SETS_OPTIONS,
targetField: 'mealOptions',
when: (vegetarian) => vegetarian,
options: ['Pasta', 'Salad'],
},
]"
@submit="onSubmit"
>
<Button type="submit">
Submit
</Button>
</AutoForm>
</template>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
import * as z from 'zod'
import { h } from 'vue'
import { Button } from '@/lib/registry/default/ui/button'
import { toast } from '@/lib/registry/default/ui/toast'
import { AutoForm, AutoFormField } from '@/lib/registry/default/ui/auto-form'
const schema = z.object({
username: z.string(),
})
function onSubmit(values: Record<string, any>) {
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: {
hideLabel: true,
},
}"
@submit="onSubmit"
>
<template #username="slotProps">
<div class="flex items-start gap-3">
<div class="flex-1">
<AutoFormField v-bind="slotProps" />
</div>
<div>
<Button type="submit">
Update
</Button>
</div>
</div>
</template>
</AutoForm>
</template>

View File

@ -0,0 +1,54 @@
<script setup lang="ts">
import * as z from 'zod'
import { h } from 'vue'
import { Button } from '@/lib/registry/default/ui/button'
import { toast } from '@/lib/registry/default/ui/toast'
import { AutoForm, AutoFormField } from '@/lib/registry/default/ui/auto-form'
const schema = z.object({
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(),
})
function onSubmit(values: Record<string, any>) {
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="{
subObject: {
numberField: {
inputProps: {
type: 'number',
},
},
},
}"
@submit="onSubmit"
>
<Button type="submit">
Submit
</Button>
</AutoForm>
</template>

View File

@ -0,0 +1,105 @@
<script setup lang="ts" generic="T extends ZodObjectOrWrapped">
import { computed, toRefs } from 'vue'
import type { ZodAny, z } from 'zod'
import { toTypedSchema } from '@vee-validate/zod'
import type { FormContext, GenericObject } from 'vee-validate'
import { type ZodObjectOrWrapped, getBaseSchema, getBaseType, getDefaultValueInZodStack, getObjectFormSchema } 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 baseSchema = getObjectFormSchema(props.schema)
const shape = baseSchema.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>

View File

@ -0,0 +1,45 @@
<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
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)
? typeof config.component === 'string'
? INPUT_COMPONENTS[config.component!]
: config.component
: INPUT_COMPONENTS[DEFAULT_ZOD_HANDLERS[shape.type]] "
v-if="!isHidden"
:field-name="fieldName"
:label="shape.schema?.description"
:required="isRequired || shape.required"
:options="overrideOptions || shape.options"
:disabled="isDisabled"
:config="config"
v-bind="delegatedProps"
>
<slot />
</component>
</template>

View File

@ -0,0 +1,110 @@
<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 } 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 { FormItem, FormMessage } from '@/lib/registry/default/ui/form'
const props = defineProps<{
fieldName: string
required?: boolean
config?: Config<T>
schema?: z.ZodArray<T>
disabled?: boolean
}>()
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,
}
})
const fieldContext = useField(props.fieldName)
// @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" as-child>
<FormItem>
<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-1">
<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>
</FormItem>
</Accordion>
</slot>
</FieldArray>
</template>

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from 'vue'
import AutoFormLabel from './AutoFormLabel.vue'
import { beautifyObjectName } from './utils'
import type { FieldProps } from './interface'
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'
defineProps<FieldProps & {
options?: string[]
}>()
</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 options" :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?.inputProps?.placeholder" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in options" :key="option" :value="option">
{{ beautifyObjectName(option) }}
</SelectItem>
</SelectContent>
</Select>
</slot>
</FormControl>
<FormDescription v-if="config?.description">
{{ config.description }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</template>

View File

@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from 'vue'
import { TrashIcon } from 'lucide-vue-next'
import AutoFormLabel from './AutoFormLabel.vue'
import { beautifyObjectName } from './utils'
import type { FieldProps } from './interface'
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>

View File

@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import AutoFormLabel from './AutoFormLabel.vue'
import { beautifyObjectName } from './utils'
import type { FieldProps } from './interface'
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>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import AutoFormLabel from './AutoFormLabel.vue'
import { beautifyObjectName } from './utils'
import type { FieldProps } from './interface'
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>

View File

@ -0,0 +1,77 @@
<script setup lang="ts" generic="T extends ZodRawShape">
import type { ZodAny, ZodObject, ZodRawShape } from 'zod'
import { computed, provide } from 'vue'
import { FieldContextKey, useField } from 'vee-validate'
import AutoFormField from './AutoFormField.vue'
import type { Config, ConfigItem, Shape } from './interface'
import { beautifyObjectName, getBaseSchema, getBaseType, getDefaultValueInZodStack } from './utils'
import AutoFormLabel from './AutoFormLabel.vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/lib/registry/default/ui/accordion'
import { FormItem } from '@/lib/registry/default/ui/form'
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
})
const fieldContext = useField(props.fieldName)
// @ts-expect-error ignore missing `id`
provide(FieldContextKey, fieldContext)
</script>
<template>
<section>
<slot v-bind="props">
<Accordion type="single" as-child class="w-full" collapsible :disabled="disabled">
<FormItem>
<AccordionItem :value="fieldName" class="border-none">
<AccordionTrigger>
<AutoFormLabel class="text-base" :required="required">
{{ schema?.description || beautifyObjectName(fieldName) }}
</AutoFormLabel>
</AccordionTrigger>
<AccordionContent class="p-1 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>
</FormItem>
</Accordion>
</slot>
</section>
</template>

View File

@ -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>

View File

@ -0,0 +1,39 @@
import AutoFormFieldArray from './AutoFormFieldArray.vue'
import AutoFormFieldBoolean from './AutoFormFieldBoolean.vue'
import AutoFormFieldDate from './AutoFormFieldDate.vue'
import AutoFormFieldEnum from './AutoFormFieldEnum.vue'
import AutoFormFieldFile from './AutoFormFieldFile.vue'
import AutoFormFieldInput from './AutoFormFieldInput.vue'
import AutoFormFieldNumber from './AutoFormFieldNumber.vue'
import AutoFormFieldObject from './AutoFormFieldObject.vue'
export const INPUT_COMPONENTS = {
date: AutoFormFieldDate,
select: AutoFormFieldEnum,
radio: AutoFormFieldEnum,
checkbox: AutoFormFieldBoolean,
switch: AutoFormFieldBoolean,
textarea: AutoFormFieldInput,
number: AutoFormFieldNumber,
string: AutoFormFieldInput,
file: AutoFormFieldFile,
array: AutoFormFieldArray,
object: AutoFormFieldObject,
}
/**
* 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',
}

View File

@ -0,0 +1,92 @@
import type * as z from 'zod'
import type { Ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useFieldValue, useFormValues } from 'vee-validate'
import { createContext } from 'radix-vue'
import { type Dependency, DependencyType, type EnumValues } from './interface'
import { getFromPath, getIndexIfArray } from './utils'
export const [injectDependencies, provideDependencies] = createContext<Ref<Dependency<z.infer<z.ZodObject<any>>>[] | undefined>>('AutoFormDependencies')
export default function useDependencies(
fieldName: string,
) {
const form = useFormValues()
// parsed test[0].age => test.age
const currentFieldName = fieldName.replace(/\[\d+\]/g, '')
const currentFieldValue = useFieldValue<any>(fieldName)
if (!form)
throw new Error('useDependencies should be used within <AutoForm>')
const dependencies = injectDependencies()
const isDisabled = ref(false)
const isHidden = ref(false)
const isRequired = ref(false)
const overrideOptions = ref<EnumValues | undefined>()
const currentFieldDependencies = computed(() => dependencies.value?.filter(
dependency => dependency.targetField === currentFieldName,
))
function getSourceValue(dep: Dependency<any>) {
const source = dep.sourceField as string
const index = getIndexIfArray(fieldName) ?? -1
const [sourceLast, ...sourceInitial] = source.split('.').toReversed()
const [_targetLast, ...targetInitial] = (dep.targetField as string).split('.').toReversed()
if (index >= 0 && sourceInitial.join(',') === targetInitial.join(',')) {
const [_currentLast, ...currentInitial] = fieldName.split('.').toReversed()
return getFromPath(form.value, currentInitial.join('.') + sourceLast)
}
return getFromPath(form.value, source)
}
const sourceFieldValues = computed(() => currentFieldDependencies.value?.map(dep => getSourceValue(dep)))
const resetConditionState = () => {
isDisabled.value = false
isHidden.value = false
isRequired.value = false
overrideOptions.value = undefined
}
watch([sourceFieldValues, dependencies], () => {
resetConditionState()
currentFieldDependencies.value?.forEach((dep) => {
const sourceValue = getSourceValue(dep)
const conditionMet = dep.when(sourceValue, currentFieldValue.value)
switch (dep.type) {
case DependencyType.DISABLES:
if (conditionMet)
isDisabled.value = true
break
case DependencyType.REQUIRES:
if (conditionMet)
isRequired.value = true
break
case DependencyType.HIDES:
if (conditionMet)
isHidden.value = true
break
case DependencyType.SETS_OPTIONS:
if (conditionMet)
overrideOptions.value = dep.options
break
}
})
}, { immediate: true, deep: true })
return {
isDisabled,
isHidden,
isRequired,
overrideOptions,
}
}

View File

@ -0,0 +1,15 @@
export { getObjectFormSchema, getBaseSchema, getBaseType } from './utils'
export type { Config, ConfigItem, FieldProps } from './interface'
export { default as AutoForm } from './AutoForm.vue'
export { default as AutoFormField } from './AutoFormField.vue'
export { default as AutoFormLabel } from './AutoFormLabel.vue'
export { default as AutoFormFieldArray } from './AutoFormFieldArray.vue'
export { default as AutoFormFieldBoolean } from './AutoFormFieldBoolean.vue'
export { default as AutoFormFieldDate } from './AutoFormFieldDate.vue'
export { default as AutoFormFieldEnum } from './AutoFormFieldEnum.vue'
export { default as AutoFormFieldFile } from './AutoFormFieldFile.vue'
export { default as AutoFormFieldInput } from './AutoFormFieldInput.vue'
export { default as AutoFormFieldNumber } from './AutoFormFieldNumber.vue'
export { default as AutoFormFieldObject } from './AutoFormFieldObject.vue'

View File

@ -0,0 +1,81 @@
import type { Component, 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 | Component
/** Hide `FormLabel`. */
hideLabel?: boolean
inputProps?: InputHTMLAttributes
}
// 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>

View File

@ -0,0 +1,171 @@
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>
}
function isIndex(value: unknown): value is number {
return Number(value) >= 0
}
/**
* Constructs a path with dot paths for arrays to use brackets to be compatible with vee-validate path syntax
*/
export function normalizeFormPath(path: string): string {
const pathArr = path.split('.')
if (!pathArr.length)
return ''
let fullPath = String(pathArr[0])
for (let i = 1; i < pathArr.length; i++) {
if (isIndex(pathArr[i])) {
fullPath += `[${pathArr[i]}]`
continue
}
fullPath += `.${pathArr[i]}`
}
return fullPath
}
type NestedRecord = Record<string, unknown> | { [k: string]: NestedRecord }
/**
* Checks if the path opted out of nested fields using `[fieldName]` syntax
*/
export function isNotNestedPath(path: string) {
return /^\[.+\]$/i.test(path)
}
function isObject(obj: unknown): obj is Record<string, unknown> {
return obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj)
}
function isContainerValue(value: unknown): value is Record<string, unknown> {
return isObject(value) || Array.isArray(value)
}
function cleanupNonNestedPath(path: string) {
if (isNotNestedPath(path))
return path.replace(/\[|\]/gi, '')
return path
}
/**
* Gets a nested property value from an object
*/
export function getFromPath<TValue = unknown>(object: NestedRecord | undefined, path: string): TValue | undefined
export function getFromPath<TValue = unknown, TFallback = TValue>(
object: NestedRecord | undefined,
path: string,
fallback?: TFallback,
): TValue | TFallback
export function getFromPath<TValue = unknown, TFallback = TValue>(
object: NestedRecord | undefined,
path: string,
fallback?: TFallback,
): TValue | TFallback | undefined {
if (!object)
return fallback
if (isNotNestedPath(path))
return object[cleanupNonNestedPath(path)] as TValue | undefined
const resolvedValue = (path || '')
.split(/\.|\[(\d+)\]/)
.filter(Boolean)
.reduce((acc, propKey) => {
if (isContainerValue(acc) && propKey in acc)
return acc[propKey]
return fallback
}, object as unknown)
return resolvedValue as TValue | undefined
}

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import * as z from 'zod'
import { h, onMounted, ref, shallowRef } from 'vue'
import { Button } from '@/lib/registry/new-york/ui/button'
import { toast } from '@/lib/registry/new-york/ui/toast'
import { AutoForm } from '@/lib/registry/new-york/ui/auto-form'
const schema = shallowRef<z.ZodObject< any, any, any > | null>(null)
onMounted(() => {
fetch('https://jsonplaceholder.typicode.com/users')
.then(response => response.json())
.then((data) => {
schema.value = z.object({
user: z.enum(data.map((user: any) => user.name)),
})
})
})
function onSubmit(values: Record<string, any>) {
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>
<div class="flex justify-center w-full">
<AutoForm
v-if="schema"
class="w-2/3 space-y-6"
:schema="schema"
@submit="onSubmit"
>
<Button type="submit">
Submit
</Button>
</AutoForm>
<div v-else>
Loading...
</div>
</div>
</template>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import * as z from 'zod'
import { h } from 'vue'
import { Button } from '@/lib/registry/new-york/ui/button'
import { toast } from '@/lib/registry/new-york/ui/toast'
import { AutoForm } from '@/lib/registry/new-york/ui/auto-form'
const schema = z.object({
guestListName: z.string(),
invitedGuests: z
.array(
z.object({
name: z.string(),
age: z.coerce.number(),
}),
)
.describe('Guests invited to the party'),
})
function onSubmit(values: Record<string, any>) {
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"
@submit="onSubmit"
>
<Button type="submit">
Submit
</Button>
</AutoForm>
</template>

View File

@ -0,0 +1,161 @@
<script setup lang="ts">
import * as z from 'zod'
import { h, reactive, ref } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { DependencyType } from '../ui/auto-form/interface'
import { Button } from '@/lib/registry/new-york/ui/button'
import { toast } from '@/lib/registry/new-york/ui/toast'
import type { Config } from '@/lib/registry/new-york/ui/auto-form'
import { AutoForm, AutoFormField } 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.',
})
.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(),
})
function onSubmit(values: Record<string, any>) {
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="{
password: {
label: 'Your secure password',
inputProps: {
type: 'password',
placeholder: '••••••••',
},
},
favouriteNumber: {
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">
Submit
</Button>
</AutoForm>
</template>

View File

@ -0,0 +1,36 @@
<script setup lang="ts">
import * as z from 'zod'
import { h } from 'vue'
import { Button } from '@/lib/registry/new-york/ui/button'
import { toast } from '@/lib/registry/new-york/ui/toast'
import { AutoForm } from '@/lib/registry/new-york/ui/auto-form'
const schema = z
.object({
password: z.string(),
confirm: z.string(),
})
.refine(data => data.password === data.confirm, {
message: 'Passwords must match.',
path: ['confirm'],
})
function onSubmit(values: Record<string, any>) {
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"
@submit="onSubmit"
>
<Button type="submit">
Submit
</Button>
</AutoForm>
</template>

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import * as z from 'zod'
import { h } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { Button } from '@/lib/registry/new-york/ui/button'
import { toast } from '@/lib/registry/new-york/ui/toast'
import { AutoForm } from '@/lib/registry/new-york/ui/auto-form'
const schema = z.object({
username: z.string(),
})
const form = useForm({
validationSchema: toTypedSchema(schema),
})
function onSubmit(values: Record<string, any>) {
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"
:form="form"
@submit="onSubmit"
>
<Button type="submit">
Submit
</Button>
</AutoForm>
</template>

View File

@ -0,0 +1,72 @@
<script setup lang="ts">
import * as z from 'zod'
import { h } from 'vue'
import { DependencyType } from '../ui/auto-form/interface'
import { Button } from '@/lib/registry/new-york/ui/button'
import { toast } from '@/lib/registry/new-york/ui/toast'
import { AutoForm } from '@/lib/registry/new-york/ui/auto-form'
const schema = z.object({
age: z.number(),
parentsAllowed: z.boolean().optional(),
vegetarian: z.boolean().optional(),
mealOptions: z.enum(['Pasta', 'Salad', 'Beef Wellington']).optional(),
})
function onSubmit(values: Record<string, any>) {
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="{
age: {
description:
'Setting this below 18 will require parents consent.',
},
parentsAllowed: {
label: 'Did your parents allow you to register?',
},
vegetarian: {
label: 'Are you a vegetarian?',
description:
'Setting this to true will remove non-vegetarian food options.',
},
mealOptions: {
component: 'radio',
},
}"
:dependencies="[
{
sourceField: 'age',
type: DependencyType.HIDES,
targetField: 'parentsAllowed',
when: (age) => age >= 18,
},
{
sourceField: 'age',
type: DependencyType.REQUIRES,
targetField: 'parentsAllowed',
when: (age) => age < 18,
},
{
sourceField: 'vegetarian',
type: DependencyType.SETS_OPTIONS,
targetField: 'mealOptions',
when: (vegetarian) => vegetarian,
options: ['Pasta', 'Salad'],
},
]"
@submit="onSubmit"
>
<Button type="submit">
Submit
</Button>
</AutoForm>
</template>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
import * as z from 'zod'
import { h } from 'vue'
import { Button } from '@/lib/registry/new-york/ui/button'
import { toast } from '@/lib/registry/new-york/ui/toast'
import { AutoForm, AutoFormField } from '@/lib/registry/new-york/ui/auto-form'
const schema = z.object({
username: z.string(),
})
function onSubmit(values: Record<string, any>) {
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: {
hideLabel: true,
},
}"
@submit="onSubmit"
>
<template #username="slotProps">
<div class="flex items-center gap-3">
<div class="flex-1">
<AutoFormField v-bind="slotProps" />
</div>
<div>
<Button type="submit">
Update
</Button>
</div>
</div>
</template>
</AutoForm>
</template>

View File

@ -0,0 +1,54 @@
<script setup lang="ts">
import * as z from 'zod'
import { h } from 'vue'
import { Button } from '@/lib/registry/new-york/ui/button'
import { toast } from '@/lib/registry/new-york/ui/toast'
import { AutoForm, AutoFormField } from '@/lib/registry/new-york/ui/auto-form'
const schema = z.object({
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(),
})
function onSubmit(values: Record<string, any>) {
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="{
subObject: {
numberField: {
inputProps: {
type: 'number',
},
},
},
}"
@submit="onSubmit"
>
<Button type="submit">
Submit
</Button>
</AutoForm>
</template>

View File

@ -0,0 +1,105 @@
<script setup lang="ts" generic="T extends ZodObjectOrWrapped">
import { computed, toRefs } from 'vue'
import type { ZodAny, z } from 'zod'
import { toTypedSchema } from '@vee-validate/zod'
import type { FormContext, GenericObject } from 'vee-validate'
import { type ZodObjectOrWrapped, getBaseSchema, getBaseType, getDefaultValueInZodStack, getObjectFormSchema } from './utils'
import type { Config, ConfigItem, Dependency, Shape } from './interface'
import AutoFormField from './AutoFormField.vue'
import { provideDependencies } from './dependencies'
import { Form } from '@/lib/registry/new-york/ui/form'
const props = defineProps<{
schema: T
form?: FormContext<GenericObject>
fieldConfig?: Config<z.infer<T>>
dependencies?: Dependency<z.infer<T>>[]
}>()
const emits = defineEmits<{
submit: [event: GenericObject]
}>()
const { dependencies } = toRefs(props)
provideDependencies(dependencies)
const shapes = computed(() => {
// @ts-expect-error ignore {} not assignable to object
const val: { [key in keyof T]: Shape } = {}
const baseSchema = getObjectFormSchema(props.schema)
const shape = baseSchema.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>

View File

@ -0,0 +1,45 @@
<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
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)
? typeof config.component === 'string'
? INPUT_COMPONENTS[config.component!]
: config.component
: INPUT_COMPONENTS[DEFAULT_ZOD_HANDLERS[shape.type]] "
v-if="!isHidden"
:field-name="fieldName"
:label="shape.schema?.description"
:required="isRequired || shape.required"
:options="overrideOptions || shape.options"
:disabled="isDisabled"
:config="config"
v-bind="delegatedProps"
>
<slot />
</component>
</template>

View File

@ -0,0 +1,110 @@
<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 } 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/new-york/ui/accordion'
import { Button } from '@/lib/registry/new-york/ui/button'
import { Separator } from '@/lib/registry/new-york/ui/separator'
import { FormItem, FormMessage } from '@/lib/registry/new-york/ui/form'
const props = defineProps<{
fieldName: string
required?: boolean
config?: Config<T>
schema?: z.ZodArray<T>
disabled?: boolean
}>()
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,
}
})
const fieldContext = useField(props.fieldName)
// @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" as-child>
<FormItem>
<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-1">
<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>
</FormItem>
</Accordion>
</slot>
</FieldArray>
</template>

View File

@ -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/new-york/ui/form'
import { Switch } from '@/lib/registry/new-york/ui/switch'
import { Checkbox } from '@/lib/registry/new-york/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>

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import { DateFormatter, getLocalTimeZone } from '@internationalized/date'
import { CalendarIcon } from '@radix-icons/vue'
import { beautifyObjectName } from './utils'
import AutoFormLabel from './AutoFormLabel.vue'
import type { FieldProps } from './interface'
import { FormControl, FormDescription, FormField, FormItem, 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<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" />
{{ 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>

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from 'vue'
import AutoFormLabel from './AutoFormLabel.vue'
import { beautifyObjectName } from './utils'
import type { FieldProps } from './interface'
import { FormControl, FormDescription, FormField, FormItem, 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'
defineProps<FieldProps & {
options?: string[]
}>()
</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 options" :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?.inputProps?.placeholder" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in options" :key="option" :value="option">
{{ beautifyObjectName(option) }}
</SelectItem>
</SelectContent>
</Select>
</slot>
</FormControl>
<FormDescription v-if="config?.description">
{{ config.description }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</template>

View File

@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref } from 'vue'
import { TrashIcon } from '@radix-icons/vue'
import { beautifyObjectName } from './utils'
import type { FieldProps } 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'
import { Button } from '@/lib/registry/new-york/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-9 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 />
</Button>
</div>
</slot>
</FormControl>
<FormDescription v-if="config?.description">
{{ config.description }}
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</template>

View File

@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import AutoFormLabel from './AutoFormLabel.vue'
import { beautifyObjectName } from './utils'
import type { FieldProps } from './interface'
import { FormControl, FormDescription, FormField, FormItem, 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'
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>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import AutoFormLabel from './AutoFormLabel.vue'
import { beautifyObjectName } from './utils'
import type { FieldProps } from './interface'
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<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>

View File

@ -0,0 +1,77 @@
<script setup lang="ts" generic="T extends ZodRawShape">
import type { ZodAny, ZodObject, ZodRawShape } from 'zod'
import { computed, provide } from 'vue'
import { FieldContextKey, useField } from 'vee-validate'
import AutoFormField from './AutoFormField.vue'
import type { Config, ConfigItem, Shape } from './interface'
import { beautifyObjectName, getBaseSchema, getBaseType, getDefaultValueInZodStack } from './utils'
import AutoFormLabel from './AutoFormLabel.vue'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/lib/registry/new-york/ui/accordion'
import { FormItem } from '@/lib/registry/new-york/ui/form'
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
})
const fieldContext = useField(props.fieldName)
// @ts-expect-error ignore missing `id`
provide(FieldContextKey, fieldContext)
</script>
<template>
<section>
<slot v-bind="props">
<Accordion type="single" as-child class="w-full" collapsible :disabled="disabled">
<FormItem>
<AccordionItem :value="fieldName" class="border-none">
<AccordionTrigger>
<AutoFormLabel class="text-base" :required="required">
{{ schema?.description || beautifyObjectName(fieldName) }}
</AutoFormLabel>
</AccordionTrigger>
<AccordionContent class="p-1 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>
</FormItem>
</Accordion>
</slot>
</section>
</template>

View File

@ -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>

View File

@ -0,0 +1,39 @@
import AutoFormFieldArray from './AutoFormFieldArray.vue'
import AutoFormFieldBoolean from './AutoFormFieldBoolean.vue'
import AutoFormFieldDate from './AutoFormFieldDate.vue'
import AutoFormFieldEnum from './AutoFormFieldEnum.vue'
import AutoFormFieldFile from './AutoFormFieldFile.vue'
import AutoFormFieldInput from './AutoFormFieldInput.vue'
import AutoFormFieldNumber from './AutoFormFieldNumber.vue'
import AutoFormFieldObject from './AutoFormFieldObject.vue'
export const INPUT_COMPONENTS = {
date: AutoFormFieldDate,
select: AutoFormFieldEnum,
radio: AutoFormFieldEnum,
checkbox: AutoFormFieldBoolean,
switch: AutoFormFieldBoolean,
textarea: AutoFormFieldInput,
number: AutoFormFieldNumber,
string: AutoFormFieldInput,
file: AutoFormFieldFile,
array: AutoFormFieldArray,
object: AutoFormFieldObject,
}
/**
* 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',
}

View File

@ -0,0 +1,92 @@
import type * as z from 'zod'
import type { Ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useFieldValue, useFormValues } from 'vee-validate'
import { createContext } from 'radix-vue'
import { type Dependency, DependencyType, type EnumValues } from './interface'
import { getFromPath, getIndexIfArray } from './utils'
export const [injectDependencies, provideDependencies] = createContext<Ref<Dependency<z.infer<z.ZodObject<any>>>[] | undefined>>('AutoFormDependencies')
export default function useDependencies(
fieldName: string,
) {
const form = useFormValues()
// parsed test[0].age => test.age
const currentFieldName = fieldName.replace(/\[\d+\]/g, '')
const currentFieldValue = useFieldValue<any>(fieldName)
if (!form)
throw new Error('useDependencies should be used within <AutoForm>')
const dependencies = injectDependencies()
const isDisabled = ref(false)
const isHidden = ref(false)
const isRequired = ref(false)
const overrideOptions = ref<EnumValues | undefined>()
const currentFieldDependencies = computed(() => dependencies.value?.filter(
dependency => dependency.targetField === currentFieldName,
))
function getSourceValue(dep: Dependency<any>) {
const source = dep.sourceField as string
const index = getIndexIfArray(fieldName) ?? -1
const [sourceLast, ...sourceInitial] = source.split('.').toReversed()
const [_targetLast, ...targetInitial] = (dep.targetField as string).split('.').toReversed()
if (index >= 0 && sourceInitial.join(',') === targetInitial.join(',')) {
const [_currentLast, ...currentInitial] = fieldName.split('.').toReversed()
return getFromPath(form.value, currentInitial.join('.') + sourceLast)
}
return getFromPath(form.value, source)
}
const sourceFieldValues = computed(() => currentFieldDependencies.value?.map(dep => getSourceValue(dep)))
const resetConditionState = () => {
isDisabled.value = false
isHidden.value = false
isRequired.value = false
overrideOptions.value = undefined
}
watch([sourceFieldValues, dependencies], () => {
resetConditionState()
currentFieldDependencies.value?.forEach((dep) => {
const sourceValue = getSourceValue(dep)
const conditionMet = dep.when(sourceValue, currentFieldValue.value)
switch (dep.type) {
case DependencyType.DISABLES:
if (conditionMet)
isDisabled.value = true
break
case DependencyType.REQUIRES:
if (conditionMet)
isRequired.value = true
break
case DependencyType.HIDES:
if (conditionMet)
isHidden.value = true
break
case DependencyType.SETS_OPTIONS:
if (conditionMet)
overrideOptions.value = dep.options
break
}
})
}, { immediate: true, deep: true })
return {
isDisabled,
isHidden,
isRequired,
overrideOptions,
}
}

View File

@ -0,0 +1,15 @@
export { getObjectFormSchema, getBaseSchema, getBaseType } from './utils'
export type { Config, ConfigItem, FieldProps } from './interface'
export { default as AutoForm } from './AutoForm.vue'
export { default as AutoFormField } from './AutoFormField.vue'
export { default as AutoFormLabel } from './AutoFormLabel.vue'
export { default as AutoFormFieldArray } from './AutoFormFieldArray.vue'
export { default as AutoFormFieldBoolean } from './AutoFormFieldBoolean.vue'
export { default as AutoFormFieldDate } from './AutoFormFieldDate.vue'
export { default as AutoFormFieldEnum } from './AutoFormFieldEnum.vue'
export { default as AutoFormFieldFile } from './AutoFormFieldFile.vue'
export { default as AutoFormFieldInput } from './AutoFormFieldInput.vue'
export { default as AutoFormFieldNumber } from './AutoFormFieldNumber.vue'
export { default as AutoFormFieldObject } from './AutoFormFieldObject.vue'

View File

@ -0,0 +1,81 @@
import type { Component, 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 | Component
/** Hide `FormLabel`. */
hideLabel?: boolean
inputProps?: InputHTMLAttributes
}
// 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>

View File

@ -0,0 +1,171 @@
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>
}
function isIndex(value: unknown): value is number {
return Number(value) >= 0
}
/**
* Constructs a path with dot paths for arrays to use brackets to be compatible with vee-validate path syntax
*/
export function normalizeFormPath(path: string): string {
const pathArr = path.split('.')
if (!pathArr.length)
return ''
let fullPath = String(pathArr[0])
for (let i = 1; i < pathArr.length; i++) {
if (isIndex(pathArr[i])) {
fullPath += `[${pathArr[i]}]`
continue
}
fullPath += `.${pathArr[i]}`
}
return fullPath
}
type NestedRecord = Record<string, unknown> | { [k: string]: NestedRecord }
/**
* Checks if the path opted out of nested fields using `[fieldName]` syntax
*/
export function isNotNestedPath(path: string) {
return /^\[.+\]$/i.test(path)
}
function isObject(obj: unknown): obj is Record<string, unknown> {
return obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj)
}
function isContainerValue(value: unknown): value is Record<string, unknown> {
return isObject(value) || Array.isArray(value)
}
function cleanupNonNestedPath(path: string) {
if (isNotNestedPath(path))
return path.replace(/\[|\]/gi, '')
return path
}
/**
* Gets a nested property value from an object
*/
export function getFromPath<TValue = unknown>(object: NestedRecord | undefined, path: string): TValue | undefined
export function getFromPath<TValue = unknown, TFallback = TValue>(
object: NestedRecord | undefined,
path: string,
fallback?: TFallback,
): TValue | TFallback
export function getFromPath<TValue = unknown, TFallback = TValue>(
object: NestedRecord | undefined,
path: string,
fallback?: TFallback,
): TValue | TFallback | undefined {
if (!object)
return fallback
if (isNotNestedPath(path))
return object[cleanupNonNestedPath(path)] as TValue | undefined
const resolvedValue = (path || '')
.split(/\.|\[(\d+)\]/)
.filter(Boolean)
.reduce((acc, propKey) => {
if (isContainerValue(acc) && propKey in acc)
return acc[propKey]
return fallback
}, object as unknown)
return resolvedValue as TValue | undefined
}

View File

@ -1,4 +1,4 @@
export { Form, Field as FormField } from 'vee-validate'
export { Form, Field as FormField, FieldArray as FormFieldArray } from 'vee-validate'
export { default as FormItem } from './FormItem.vue'
export { default as FormLabel } from './FormLabel.vue'
export { default as FormControl } from './FormControl.vue'

View File

@ -5,7 +5,6 @@ import { FORM_ITEM_INJECTION_KEY } from './FormItem.vue'
export function useFormField() {
const fieldContext = inject(FieldContextKey)
const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY)
const fieldState = {
valid: useIsFieldValid(),
isDirty: useIsFieldDirty(),

View File

@ -59,6 +59,49 @@
],
"type": "components:ui"
},
{
"name": "auto-form",
"dependencies": [
"vee-validate",
"@vee-validate/zod",
"zod"
],
"registryDependencies": [
"form",
"accordion",
"button",
"separator",
"switch",
"checkbox",
"calendar",
"popover",
"utils",
"select",
"label",
"radio-group",
"input",
"textarea"
],
"files": [
"ui/auto-form/AutoForm.vue",
"ui/auto-form/AutoFormField.vue",
"ui/auto-form/AutoFormFieldArray.vue",
"ui/auto-form/AutoFormFieldBoolean.vue",
"ui/auto-form/AutoFormFieldDate.vue",
"ui/auto-form/AutoFormFieldEnum.vue",
"ui/auto-form/AutoFormFieldFile.vue",
"ui/auto-form/AutoFormFieldInput.vue",
"ui/auto-form/AutoFormFieldNumber.vue",
"ui/auto-form/AutoFormFieldObject.vue",
"ui/auto-form/AutoFormLabel.vue",
"ui/auto-form/constant.ts",
"ui/auto-form/dependencies.ts",
"ui/auto-form/index.ts",
"ui/auto-form/interface.ts",
"ui/auto-form/utils.ts"
],
"type": "components:ui"
},
{
"name": "avatar",
"dependencies": [],

View File

@ -0,0 +1,91 @@
{
"name": "auto-form",
"dependencies": [
"vee-validate",
"@vee-validate/zod",
"zod"
],
"registryDependencies": [
"form",
"accordion",
"button",
"separator",
"switch",
"checkbox",
"calendar",
"popover",
"utils",
"select",
"label",
"radio-group",
"input",
"textarea"
],
"files": [
{
"name": "AutoForm.vue",
"content": "<script setup lang=\"ts\" generic=\"T extends ZodObjectOrWrapped\">\nimport { computed, toRefs } from 'vue'\nimport type { ZodAny, z } from 'zod'\nimport { toTypedSchema } from '@vee-validate/zod'\nimport type { FormContext, GenericObject } from 'vee-validate'\nimport { type ZodObjectOrWrapped, getBaseSchema, getBaseType, getDefaultValueInZodStack, getObjectFormSchema } from './utils'\nimport type { Config, ConfigItem, Dependency, Shape } from './interface'\nimport AutoFormField from './AutoFormField.vue'\nimport { provideDependencies } from './dependencies'\nimport { Form } from '@/lib/registry/default/ui/form'\n\nconst props = defineProps<{\n schema: T\n form?: FormContext<GenericObject>\n fieldConfig?: Config<z.infer<T>>\n dependencies?: Dependency<z.infer<T>>[]\n}>()\n\nconst emits = defineEmits<{\n submit: [event: GenericObject]\n}>()\n\nconst { dependencies } = toRefs(props)\nprovideDependencies(dependencies)\n\nconst shapes = computed(() => {\n // @ts-expect-error ignore {} not assignable to object\n const val: { [key in keyof T]: Shape } = {}\n const baseSchema = getObjectFormSchema(props.schema)\n const shape = baseSchema.shape\n Object.keys(shape).forEach((name) => {\n const item = shape[name] as ZodAny\n const baseItem = getBaseSchema(item) as ZodAny\n let options = (baseItem && 'values' in baseItem._def) ? baseItem._def.values as string[] : undefined\n if (!Array.isArray(options) && typeof options === 'object')\n options = Object.values(options)\n\n val[name as keyof T] = {\n type: getBaseType(item),\n default: getDefaultValueInZodStack(item),\n options,\n required: !['ZodOptional', 'ZodNullable'].includes(item._def.typeName),\n schema: baseItem,\n }\n })\n return val\n})\n\nconst fields = computed(() => {\n // @ts-expect-error ignore {} not assignable to object\n const val: { [key in keyof z.infer<T>]: { shape: Shape, fieldName: string, config: ConfigItem } } = {}\n for (const key in shapes.value) {\n const shape = shapes.value[key]\n val[key as keyof z.infer<T>] = {\n shape,\n config: props.fieldConfig?.[key] as ConfigItem,\n fieldName: key,\n }\n }\n return val\n})\n\nconst formComponent = computed(() => props.form ? 'form' : Form)\nconst formComponentProps = computed(() => {\n if (props.form) {\n return {\n onSubmit: props.form.handleSubmit(val => emits('submit', val)),\n }\n }\n else {\n const formSchema = toTypedSchema(props.schema)\n return {\n keepValues: true,\n validationSchema: formSchema,\n onSubmit: (val: GenericObject) => emits('submit', val),\n }\n }\n})\n</script>\n\n<template>\n <component\n :is=\"formComponent\"\n v-bind=\"formComponentProps\"\n >\n <slot name=\"customAutoForm\" :fields=\"fields\">\n <template v-for=\"(shape, key) of shapes\" :key=\"key\">\n <slot\n :shape=\"shape\"\n :name=\"key.toString() as keyof z.infer<T>\"\n :field-name=\"key.toString()\"\n :config=\"fieldConfig?.[key as keyof typeof fieldConfig] as ConfigItem\"\n >\n <AutoFormField\n :config=\"fieldConfig?.[key as keyof typeof fieldConfig] as ConfigItem\"\n :field-name=\"key.toString()\"\n :shape=\"shape\"\n />\n </slot>\n </template>\n </slot>\n\n <slot :shapes=\"shapes\" />\n </component>\n</template>\n"
},
{
"name": "AutoFormField.vue",
"content": "<script setup lang=\"ts\" generic=\"U extends ZodAny\">\nimport type { ZodAny } from 'zod'\nimport { computed } from 'vue'\nimport type { Config, ConfigItem, Shape } from './interface'\nimport { DEFAULT_ZOD_HANDLERS, INPUT_COMPONENTS } from './constant'\nimport useDependencies from './dependencies'\n\nconst props = defineProps<{\n fieldName: string\n shape: Shape\n config?: ConfigItem | Config<U>\n}>()\n\nfunction isValidConfig(config: any): config is ConfigItem {\n return !!config?.component\n}\n\nconst delegatedProps = computed(() => {\n if (['ZodObject', 'ZodArray'].includes(props.shape?.type))\n return { schema: props.shape?.schema }\n return undefined\n})\n\nconst { isDisabled, isHidden, isRequired, overrideOptions } = useDependencies(props.fieldName)\n</script>\n\n<template>\n <component\n :is=\"isValidConfig(config)\n ? typeof config.component === 'string'\n ? INPUT_COMPONENTS[config.component!]\n : config.component\n : INPUT_COMPONENTS[DEFAULT_ZOD_HANDLERS[shape.type]] \"\n v-if=\"!isHidden\"\n :field-name=\"fieldName\"\n :label=\"shape.schema?.description\"\n :required=\"isRequired || shape.required\"\n :options=\"overrideOptions || shape.options\"\n :disabled=\"isDisabled\"\n :config=\"config\"\n v-bind=\"delegatedProps\"\n >\n <slot />\n </component>\n</template>\n"
},
{
"name": "AutoFormFieldArray.vue",
"content": "<script setup lang=\"ts\" generic=\"T extends z.ZodAny\">\nimport * as z from 'zod'\nimport { computed, provide } from 'vue'\nimport { PlusIcon, TrashIcon } from 'lucide-vue-next'\nimport { FieldArray, FieldContextKey, useField } from 'vee-validate'\nimport type { Config, ConfigItem } from './interface'\nimport { beautifyObjectName, getBaseType } from './utils'\nimport AutoFormField from './AutoFormField.vue'\nimport AutoFormLabel from './AutoFormLabel.vue'\nimport { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/lib/registry/default/ui/accordion'\nimport { Button } from '@/lib/registry/default/ui/button'\nimport { Separator } from '@/lib/registry/default/ui/separator'\nimport { FormItem, FormMessage } from '@/lib/registry/default/ui/form'\n\nconst props = defineProps<{\n fieldName: string\n required?: boolean\n config?: Config<T>\n schema?: z.ZodArray<T>\n disabled?: boolean\n}>()\n\nfunction isZodArray(\n item: z.ZodArray<any> | z.ZodDefault<any>,\n): item is z.ZodArray<any> {\n return item instanceof z.ZodArray\n}\n\nfunction isZodDefault(\n item: z.ZodArray<any> | z.ZodDefault<any>,\n): item is z.ZodDefault<any> {\n return item instanceof z.ZodDefault\n}\n\nconst itemShape = computed(() => {\n if (!props.schema)\n return\n\n const schema: z.ZodAny = isZodArray(props.schema)\n ? props.schema._def.type\n : isZodDefault(props.schema)\n // @ts-expect-error missing schema\n ? props.schema._def.innerType._def.type\n : null\n\n return {\n type: getBaseType(schema),\n schema,\n }\n})\n\nconst fieldContext = useField(props.fieldName)\n// @ts-expect-error ignore missing `id`\nprovide(FieldContextKey, fieldContext)\n</script>\n\n<template>\n <FieldArray v-slot=\"{ fields, remove, push }\" as=\"section\" :name=\"fieldName\">\n <slot v-bind=\"props\">\n <Accordion type=\"multiple\" class=\"w-full\" collapsible :disabled=\"disabled\" as-child>\n <FormItem>\n <AccordionItem :value=\"fieldName\" class=\"border-none\">\n <AccordionTrigger>\n <AutoFormLabel class=\"text-base\" :required=\"required\">\n {{ schema?.description || beautifyObjectName(fieldName) }}\n </AutoFormLabel>\n </AccordionTrigger>\n\n <AccordionContent>\n <template v-for=\"(field, index) of fields\" :key=\"field.key\">\n <div class=\"mb-4 p-1\">\n <AutoFormField\n :field-name=\"`${fieldName}[${index}]`\"\n :label=\"fieldName\"\n :shape=\"itemShape!\"\n :config=\"config as ConfigItem\"\n />\n\n <div class=\"!my-4 flex justify-end\">\n <Button\n type=\"button\"\n size=\"icon\"\n variant=\"secondary\"\n @click=\"remove(index)\"\n >\n <TrashIcon :size=\"16\" />\n </Button>\n </div>\n <Separator v-if=\"!field.isLast\" />\n </div>\n </template>\n\n <Button\n type=\"button\"\n variant=\"secondary\"\n class=\"mt-4 flex items-center\"\n @click=\"push(null)\"\n >\n <PlusIcon class=\"mr-2\" :size=\"16\" />\n Add\n </Button>\n </AccordionContent>\n\n <FormMessage />\n </AccordionItem>\n </FormItem>\n </Accordion>\n </slot>\n </FieldArray>\n</template>\n"
},
{
"name": "AutoFormFieldBoolean.vue",
"content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { beautifyObjectName } from './utils'\nimport type { FieldProps } from './interface'\nimport AutoFormLabel from './AutoFormLabel.vue'\nimport { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/default/ui/form'\nimport { Switch } from '@/lib/registry/default/ui/switch'\nimport { Checkbox } from '@/lib/registry/default/ui/checkbox'\n\nconst props = defineProps<FieldProps>()\n\nconst booleanComponent = computed(() => props.config?.component === 'switch' ? Switch : Checkbox)\n</script>\n\n<template>\n <FormField v-slot=\"slotProps\" :name=\"fieldName\">\n <FormItem>\n <div class=\"space-y-0 mb-3 flex items-center gap-3\">\n <FormControl>\n <slot v-bind=\"slotProps\">\n <component\n :is=\"booleanComponent\"\n v-bind=\"{ ...slotProps.componentField }\"\n :disabled=\"disabled\"\n :checked=\"slotProps.componentField.modelValue\"\n @update:checked=\"slotProps.componentField['onUpdate:modelValue']\"\n />\n </slot>\n </FormControl>\n <AutoFormLabel v-if=\"!config?.hideLabel\" :required=\"required\">\n {{ config?.label || beautifyObjectName(label ?? fieldName) }}\n </AutoFormLabel>\n </div>\n\n <FormDescription v-if=\"config?.description\">\n {{ config.description }}\n </FormDescription>\n <FormMessage />\n </FormItem>\n </FormField>\n</template>\n"
},
{
"name": "AutoFormFieldDate.vue",
"content": "<script setup lang=\"ts\">\nimport { DateFormatter, getLocalTimeZone } from '@internationalized/date'\nimport { CalendarIcon } from 'lucide-vue-next'\nimport { beautifyObjectName } from './utils'\nimport AutoFormLabel from './AutoFormLabel.vue'\nimport type { FieldProps } from './interface'\nimport { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/default/ui/form'\n\nimport { Calendar } from '@/lib/registry/default/ui/calendar'\nimport { Button } from '@/lib/registry/default/ui/button'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/lib/registry/default/ui/popover'\nimport { cn } from '@/lib/utils'\n\ndefineProps<FieldProps>()\n\nconst df = new DateFormatter('en-US', {\n dateStyle: 'long',\n})\n</script>\n\n<template>\n <FormField v-slot=\"slotProps\" :name=\"fieldName\">\n <FormItem>\n <AutoFormLabel v-if=\"!config?.hideLabel\" :required=\"required\">\n {{ config?.label || beautifyObjectName(label ?? fieldName) }}\n </AutoFormLabel>\n <FormControl>\n <slot v-bind=\"slotProps\">\n <div>\n <Popover>\n <PopoverTrigger as-child :disabled=\"disabled\">\n <Button\n variant=\"outline\"\n :class=\"cn(\n 'w-full justify-start text-left font-normal',\n !slotProps.componentField.modelValue && 'text-muted-foreground',\n )\"\n >\n <CalendarIcon class=\"mr-2 h-4 w-4\" :size=\"16\" />\n {{ slotProps.componentField.modelValue ? df.format(slotProps.componentField.modelValue.toDate(getLocalTimeZone())) : \"Pick a date\" }}\n </Button>\n </PopoverTrigger>\n <PopoverContent class=\"w-auto p-0\">\n <Calendar initial-focus v-bind=\"slotProps.componentField\" />\n </PopoverContent>\n </Popover>\n </div>\n </slot>\n </FormControl>\n\n <FormDescription v-if=\"config?.description\">\n {{ config.description }}\n </FormDescription>\n <FormMessage />\n </FormItem>\n </FormField>\n</template>\n"
},
{
"name": "AutoFormFieldEnum.vue",
"content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport AutoFormLabel from './AutoFormLabel.vue'\nimport { beautifyObjectName } from './utils'\nimport type { FieldProps } from './interface'\nimport { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/default/ui/form'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/lib/registry/default/ui/select'\nimport { Label } from '@/lib/registry/default/ui/label'\nimport { RadioGroup, RadioGroupItem } from '@/lib/registry/default/ui/radio-group'\n\ndefineProps<FieldProps & {\n options?: string[]\n}>()\n</script>\n\n<template>\n <FormField v-slot=\"slotProps\" :name=\"fieldName\">\n <FormItem>\n <AutoFormLabel v-if=\"!config?.hideLabel\" :required=\"required\">\n {{ config?.label || beautifyObjectName(label ?? fieldName) }}\n </AutoFormLabel>\n <FormControl>\n <slot v-bind=\"slotProps\">\n <RadioGroup v-if=\"config?.component === 'radio'\" :disabled=\"disabled\" :orientation=\"'vertical'\" v-bind=\"{ ...slotProps.componentField }\">\n <div v-for=\"(option, index) in options\" :key=\"option\" class=\"mb-2 flex items-center gap-3 space-y-0\">\n <RadioGroupItem :id=\"`${option}-${index}`\" :value=\"option\" />\n <Label :for=\"`${option}-${index}`\">{{ beautifyObjectName(option) }}</Label>\n </div>\n </RadioGroup>\n\n <Select v-else :disabled=\"disabled\" v-bind=\"{ ...slotProps.componentField }\">\n <SelectTrigger class=\"w-full\">\n <SelectValue :placeholder=\"config?.inputProps?.placeholder\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem v-for=\"option in options\" :key=\"option\" :value=\"option\">\n {{ beautifyObjectName(option) }}\n </SelectItem>\n </SelectContent>\n </Select>\n </slot>\n </FormControl>\n\n <FormDescription v-if=\"config?.description\">\n {{ config.description }}\n </FormDescription>\n <FormMessage />\n </FormItem>\n </FormField>\n</template>\n"
},
{
"name": "AutoFormFieldFile.vue",
"content": "<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport { TrashIcon } from 'lucide-vue-next'\nimport AutoFormLabel from './AutoFormLabel.vue'\nimport { beautifyObjectName } from './utils'\nimport type { FieldProps } from './interface'\nimport { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/default/ui/form'\nimport { Input } from '@/lib/registry/default/ui/input'\nimport { Button } from '@/lib/registry/default/ui/button'\n\ndefineProps<FieldProps>()\n\nconst inputFile = ref<File>()\nasync function parseFileAsString(file: File | undefined): Promise<string> {\n return new Promise((resolve, reject) => {\n if (file) {\n const reader = new FileReader()\n reader.onloadend = () => {\n resolve(reader.result as string)\n }\n reader.onerror = (err) => {\n reject(err)\n }\n reader.readAsDataURL(file)\n }\n })\n}\n</script>\n\n<template>\n <FormField v-slot=\"slotProps\" :name=\"fieldName\">\n <FormItem v-bind=\"$attrs\">\n <AutoFormLabel v-if=\"!config?.hideLabel\" :required=\"required\">\n {{ config?.label || beautifyObjectName(label ?? fieldName) }}\n </AutoFormLabel>\n <FormControl>\n <slot v-bind=\"slotProps\">\n <Input\n v-if=\"!inputFile\"\n type=\"file\"\n v-bind=\"{ ...config?.inputProps }\"\n :disabled=\"disabled\"\n @change=\"async (ev: InputEvent) => {\n const file = (ev.target as HTMLInputElement).files?.[0]\n inputFile = file\n const parsed = await parseFileAsString(file)\n slotProps.componentField.onInput(parsed)\n }\"\n />\n <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\">\n <p>{{ inputFile?.name }}</p>\n <Button\n :size=\"'icon'\"\n :variant=\"'ghost'\"\n class=\"h-[26px] w-[26px]\"\n aria-label=\"Remove file\"\n type=\"button\"\n @click=\"() => {\n inputFile = undefined\n slotProps.componentField.onInput(undefined)\n }\"\n >\n <TrashIcon :size=\"16\" />\n </Button>\n </div>\n </slot>\n </FormControl>\n <FormDescription v-if=\"config?.description\">\n {{ config.description }}\n </FormDescription>\n <FormMessage />\n </FormItem>\n </FormField>\n</template>\n"
},
{
"name": "AutoFormFieldInput.vue",
"content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport AutoFormLabel from './AutoFormLabel.vue'\nimport { beautifyObjectName } from './utils'\nimport type { FieldProps } from './interface'\nimport { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/default/ui/form'\nimport { Input } from '@/lib/registry/default/ui/input'\nimport { Textarea } from '@/lib/registry/default/ui/textarea'\n\nconst props = defineProps<FieldProps>()\nconst inputComponent = computed(() => props.config?.component === 'textarea' ? Textarea : Input)\n</script>\n\n<template>\n <FormField v-slot=\"slotProps\" :name=\"fieldName\">\n <FormItem v-bind=\"$attrs\">\n <AutoFormLabel v-if=\"!config?.hideLabel\" :required=\"required\">\n {{ config?.label || beautifyObjectName(label ?? fieldName) }}\n </AutoFormLabel>\n <FormControl>\n <slot v-bind=\"slotProps\">\n <component\n :is=\"inputComponent\"\n type=\"text\"\n v-bind=\"{ ...slotProps.componentField, ...config?.inputProps }\"\n :disabled=\"disabled\"\n />\n </slot>\n </FormControl>\n <FormDescription v-if=\"config?.description\">\n {{ config.description }}\n </FormDescription>\n <FormMessage />\n </FormItem>\n </FormField>\n</template>\n"
},
{
"name": "AutoFormFieldNumber.vue",
"content": "<script setup lang=\"ts\">\nimport AutoFormLabel from './AutoFormLabel.vue'\nimport { beautifyObjectName } from './utils'\nimport type { FieldProps } from './interface'\nimport { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/default/ui/form'\nimport { Input } from '@/lib/registry/default/ui/input'\n\ndefineOptions({\n inheritAttrs: false,\n})\n\ndefineProps<FieldProps>()\n</script>\n\n<template>\n <FormField v-slot=\"slotProps\" :name=\"fieldName\">\n <FormItem>\n <AutoFormLabel v-if=\"!config?.hideLabel\" :required=\"required\">\n {{ config?.label || beautifyObjectName(label ?? fieldName) }}\n </AutoFormLabel>\n <FormControl>\n <slot v-bind=\"slotProps\">\n <Input type=\"number\" v-bind=\"{ ...slotProps.componentField, ...config?.inputProps }\" :disabled=\"disabled\" />\n </slot>\n </FormControl>\n <FormDescription v-if=\"config?.description\">\n {{ config.description }}\n </FormDescription>\n <FormMessage />\n </FormItem>\n </FormField>\n</template>\n"
},
{
"name": "AutoFormFieldObject.vue",
"content": "<script setup lang=\"ts\" generic=\"T extends ZodRawShape\">\nimport type { ZodAny, ZodObject, ZodRawShape } from 'zod'\nimport { computed, provide } from 'vue'\nimport { FieldContextKey, useField } from 'vee-validate'\nimport AutoFormField from './AutoFormField.vue'\nimport type { Config, ConfigItem, Shape } from './interface'\nimport { beautifyObjectName, getBaseSchema, getBaseType, getDefaultValueInZodStack } from './utils'\nimport AutoFormLabel from './AutoFormLabel.vue'\nimport { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/lib/registry/default/ui/accordion'\nimport { FormItem } from '@/lib/registry/default/ui/form'\n\nconst props = defineProps<{\n fieldName: string\n required?: boolean\n config?: Config<T>\n schema?: ZodObject<T>\n disabled?: boolean\n}>()\n\nconst shapes = computed(() => {\n // @ts-expect-error ignore {} not assignable to object\n const val: { [key in keyof T]: Shape } = {}\n\n if (!props.schema)\n return\n const shape = getBaseSchema(props.schema)?.shape\n if (!shape)\n return\n Object.keys(shape).forEach((name) => {\n const item = shape[name] as ZodAny\n let options = 'values' in item._def ? item._def.values as string[] : undefined\n if (!Array.isArray(options) && typeof options === 'object')\n options = Object.values(options)\n\n val[name as keyof T] = {\n type: getBaseType(item),\n default: getDefaultValueInZodStack(item),\n options,\n required: !['ZodOptional', 'ZodNullable'].includes(item._def.typeName),\n schema: item,\n }\n })\n return val\n})\n\nconst fieldContext = useField(props.fieldName)\n// @ts-expect-error ignore missing `id`\nprovide(FieldContextKey, fieldContext)\n</script>\n\n<template>\n <section>\n <slot v-bind=\"props\">\n <Accordion type=\"single\" as-child class=\"w-full\" collapsible :disabled=\"disabled\">\n <FormItem>\n <AccordionItem :value=\"fieldName\" class=\"border-none\">\n <AccordionTrigger>\n <AutoFormLabel class=\"text-base\" :required=\"required\">\n {{ schema?.description || beautifyObjectName(fieldName) }}\n </AutoFormLabel>\n </AccordionTrigger>\n <AccordionContent class=\"p-1 space-y-5\">\n <template v-for=\"(shape, key) in shapes\" :key=\"key\">\n <AutoFormField\n :config=\"config?.[key as keyof typeof config] as ConfigItem\"\n :field-name=\"`${fieldName}.${key.toString()}`\"\n :label=\"key.toString()\"\n :shape=\"shape\"\n />\n </template>\n </AccordionContent>\n </AccordionItem>\n </FormItem>\n </Accordion>\n </slot>\n </section>\n</template>\n"
},
{
"name": "AutoFormLabel.vue",
"content": "<script setup lang=\"ts\">\nimport { FormLabel } from '@/lib/registry/default/ui/form'\n\ndefineProps<{\n required?: boolean\n}>()\n</script>\n\n<template>\n <FormLabel>\n <slot />\n <span v-if=\"required\" class=\"text-destructive\"> *</span>\n </FormLabel>\n</template>\n"
},
{
"name": "constant.ts",
"content": "import AutoFormFieldArray from './AutoFormFieldArray.vue'\nimport AutoFormFieldBoolean from './AutoFormFieldBoolean.vue'\nimport AutoFormFieldDate from './AutoFormFieldDate.vue'\nimport AutoFormFieldEnum from './AutoFormFieldEnum.vue'\nimport AutoFormFieldFile from './AutoFormFieldFile.vue'\nimport AutoFormFieldInput from './AutoFormFieldInput.vue'\nimport AutoFormFieldNumber from './AutoFormFieldNumber.vue'\nimport AutoFormFieldObject from './AutoFormFieldObject.vue'\n\nexport const INPUT_COMPONENTS = {\n date: AutoFormFieldDate,\n select: AutoFormFieldEnum,\n radio: AutoFormFieldEnum,\n checkbox: AutoFormFieldBoolean,\n switch: AutoFormFieldBoolean,\n textarea: AutoFormFieldInput,\n number: AutoFormFieldNumber,\n string: AutoFormFieldInput,\n file: AutoFormFieldFile,\n array: AutoFormFieldArray,\n object: AutoFormFieldObject,\n}\n\n/**\n * Define handlers for specific Zod types.\n * You can expand this object to support more types.\n */\nexport const DEFAULT_ZOD_HANDLERS: {\n [key: string]: keyof typeof INPUT_COMPONENTS\n} = {\n ZodString: 'string',\n ZodBoolean: 'checkbox',\n ZodDate: 'date',\n ZodEnum: 'select',\n ZodNativeEnum: 'select',\n ZodNumber: 'number',\n ZodArray: 'array',\n ZodObject: 'object',\n}\n"
},
{
"name": "dependencies.ts",
"content": "import type * as z from 'zod'\nimport type { Ref } from 'vue'\nimport { computed, ref, watch } from 'vue'\nimport { useFieldValue, useFormValues } from 'vee-validate'\nimport { createContext } from 'radix-vue'\nimport { type Dependency, DependencyType, type EnumValues } from './interface'\nimport { getFromPath, getIndexIfArray } from './utils'\n\nexport const [injectDependencies, provideDependencies] = createContext<Ref<Dependency<z.infer<z.ZodObject<any>>>[] | undefined>>('AutoFormDependencies')\n\nexport default function useDependencies(\n fieldName: string,\n) {\n const form = useFormValues()\n // parsed test[0].age => test.age\n const currentFieldName = fieldName.replace(/\\[\\d+\\]/g, '')\n const currentFieldValue = useFieldValue<any>(fieldName)\n\n if (!form)\n throw new Error('useDependencies should be used within <AutoForm>')\n\n const dependencies = injectDependencies()\n const isDisabled = ref(false)\n const isHidden = ref(false)\n const isRequired = ref(false)\n const overrideOptions = ref<EnumValues | undefined>()\n\n const currentFieldDependencies = computed(() => dependencies.value?.filter(\n dependency => dependency.targetField === currentFieldName,\n ))\n\n function getSourceValue(dep: Dependency<any>) {\n const source = dep.sourceField as string\n const index = getIndexIfArray(fieldName) ?? -1\n const [sourceLast, ...sourceInitial] = source.split('.').toReversed()\n const [_targetLast, ...targetInitial] = (dep.targetField as string).split('.').toReversed()\n\n if (index >= 0 && sourceInitial.join(',') === targetInitial.join(',')) {\n const [_currentLast, ...currentInitial] = fieldName.split('.').toReversed()\n return getFromPath(form.value, currentInitial.join('.') + sourceLast)\n }\n\n return getFromPath(form.value, source)\n }\n\n const sourceFieldValues = computed(() => currentFieldDependencies.value?.map(dep => getSourceValue(dep)))\n\n const resetConditionState = () => {\n isDisabled.value = false\n isHidden.value = false\n isRequired.value = false\n overrideOptions.value = undefined\n }\n\n watch([sourceFieldValues, dependencies], () => {\n resetConditionState()\n currentFieldDependencies.value?.forEach((dep) => {\n const sourceValue = getSourceValue(dep)\n const conditionMet = dep.when(sourceValue, currentFieldValue.value)\n\n switch (dep.type) {\n case DependencyType.DISABLES:\n if (conditionMet)\n isDisabled.value = true\n\n break\n case DependencyType.REQUIRES:\n if (conditionMet)\n isRequired.value = true\n\n break\n case DependencyType.HIDES:\n if (conditionMet)\n isHidden.value = true\n\n break\n case DependencyType.SETS_OPTIONS:\n if (conditionMet)\n overrideOptions.value = dep.options\n\n break\n }\n })\n }, { immediate: true, deep: true })\n\n return {\n isDisabled,\n isHidden,\n isRequired,\n overrideOptions,\n }\n}\n"
},
{
"name": "index.ts",
"content": "export { getObjectFormSchema, getBaseSchema, getBaseType } from './utils'\nexport type { Config, ConfigItem, FieldProps } from './interface'\n\nexport { default as AutoForm } from './AutoForm.vue'\nexport { default as AutoFormField } from './AutoFormField.vue'\nexport { default as AutoFormLabel } from './AutoFormLabel.vue'\n\nexport { default as AutoFormFieldArray } from './AutoFormFieldArray.vue'\nexport { default as AutoFormFieldBoolean } from './AutoFormFieldBoolean.vue'\nexport { default as AutoFormFieldDate } from './AutoFormFieldDate.vue'\nexport { default as AutoFormFieldEnum } from './AutoFormFieldEnum.vue'\nexport { default as AutoFormFieldFile } from './AutoFormFieldFile.vue'\nexport { default as AutoFormFieldInput } from './AutoFormFieldInput.vue'\nexport { default as AutoFormFieldNumber } from './AutoFormFieldNumber.vue'\nexport { default as AutoFormFieldObject } from './AutoFormFieldObject.vue'\n"
},
{
"name": "interface.ts",
"content": "import type { Component, InputHTMLAttributes, SelectHTMLAttributes } from 'vue'\nimport type { ZodAny, z } from 'zod'\nimport type { INPUT_COMPONENTS } from './constant'\n\nexport interface FieldProps {\n fieldName: string\n label?: string\n required?: boolean\n config?: ConfigItem\n disabled?: boolean\n}\n\nexport interface Shape {\n type: string\n default?: any\n required?: boolean\n options?: string[]\n schema?: ZodAny\n}\n\nexport interface ConfigItem {\n /** Value for the `FormLabel` */\n label?: string\n /** Value for the `FormDescription` */\n description?: string\n /** Pick which component to be rendered. */\n component?: keyof typeof INPUT_COMPONENTS | Component\n /** Hide `FormLabel`. */\n hideLabel?: boolean\n inputProps?: InputHTMLAttributes\n}\n\n// Define a type to unwrap an array\ntype UnwrapArray<T> = T extends (infer U)[] ? U : never\n\nexport type Config<SchemaType extends object> = {\n // If SchemaType.key is an object, create a nested Config, otherwise ConfigItem\n [Key in keyof SchemaType]?:\n SchemaType[Key] extends any[]\n ? UnwrapArray<Config<SchemaType[Key]>>\n : SchemaType[Key] extends object\n ? Config<SchemaType[Key]>\n : ConfigItem;\n}\n\nexport enum DependencyType {\n DISABLES,\n REQUIRES,\n HIDES,\n SETS_OPTIONS,\n}\n\ninterface BaseDependency<SchemaType extends z.infer<z.ZodObject<any, any>>> {\n sourceField: keyof SchemaType\n type: DependencyType\n targetField: keyof SchemaType\n when: (sourceFieldValue: any, targetFieldValue: any) => boolean\n}\n\nexport type ValueDependency<SchemaType extends z.infer<z.ZodObject<any, any>>> =\n BaseDependency<SchemaType> & {\n type:\n | DependencyType.DISABLES\n | DependencyType.REQUIRES\n | DependencyType.HIDES\n }\n\nexport type EnumValues = readonly [string, ...string[]]\n\nexport type OptionsDependency<\n SchemaType extends z.infer<z.ZodObject<any, any>>,\n> = BaseDependency<SchemaType> & {\n type: DependencyType.SETS_OPTIONS\n\n // Partial array of values from sourceField that will trigger the dependency\n options: EnumValues\n}\n\nexport type Dependency<SchemaType extends z.infer<z.ZodObject<any, any>>> =\n | ValueDependency<SchemaType>\n | OptionsDependency<SchemaType>\n"
},
{
"name": "utils.ts",
"content": "import type { z } from 'zod'\n\n// TODO: This should support recursive ZodEffects but TypeScript doesn't allow circular type definitions.\nexport type ZodObjectOrWrapped =\n | z.ZodObject<any, any>\n | z.ZodEffects<z.ZodObject<any, any>>\n\n/**\n * Beautify a camelCase string.\n * e.g. \"myString\" -> \"My String\"\n */\nexport function beautifyObjectName(string: string) {\n // Remove bracketed indices\n // if numbers only return the string\n let output = string.replace(/\\[\\d+\\]/g, '').replace(/([A-Z])/g, ' $1')\n output = output.charAt(0).toUpperCase() + output.slice(1)\n return output\n}\n\n/**\n * Parse string and extract the index\n * @param string\n * @returns index or undefined\n */\nexport function getIndexIfArray(string: string) {\n const indexRegex = /\\[(\\d+)\\]/\n // Match the index\n const match = string.match(indexRegex)\n // Extract the index (number)\n const index = match ? Number.parseInt(match[1]) : undefined\n return index\n}\n\n/**\n * Get the lowest level Zod type.\n * This will unpack optionals, refinements, etc.\n */\nexport function getBaseSchema<\n ChildType extends z.ZodAny | z.AnyZodObject = z.ZodAny,\n>(schema: ChildType | z.ZodEffects<ChildType>): ChildType | null {\n if (!schema)\n return null\n if ('innerType' in schema._def)\n return getBaseSchema(schema._def.innerType as ChildType)\n\n if ('schema' in schema._def)\n return getBaseSchema(schema._def.schema as ChildType)\n\n return schema as ChildType\n}\n\n/**\n * Get the type name of the lowest level Zod type.\n * This will unpack optionals, refinements, etc.\n */\nexport function getBaseType(schema: z.ZodAny) {\n const baseSchema = getBaseSchema(schema)\n return baseSchema ? baseSchema._def.typeName : ''\n}\n\n/**\n * Search for a \"ZodDefault\" in the Zod stack and return its value.\n */\nexport function getDefaultValueInZodStack(schema: z.ZodAny): any {\n const typedSchema = schema as unknown as z.ZodDefault<\n z.ZodNumber | z.ZodString\n >\n\n if (typedSchema._def.typeName === 'ZodDefault')\n return typedSchema._def.defaultValue()\n\n if ('innerType' in typedSchema._def) {\n return getDefaultValueInZodStack(\n typedSchema._def.innerType as unknown as z.ZodAny,\n )\n }\n if ('schema' in typedSchema._def) {\n return getDefaultValueInZodStack(\n (typedSchema._def as any).schema as z.ZodAny,\n )\n }\n\n return undefined\n}\n\nexport function getObjectFormSchema(\n schema: ZodObjectOrWrapped,\n): z.ZodObject<any, any> {\n if (schema?._def.typeName === 'ZodEffects') {\n const typedSchema = schema as z.ZodEffects<z.ZodObject<any, any>>\n return getObjectFormSchema(typedSchema._def.schema)\n }\n return schema as z.ZodObject<any, any>\n}\n\nfunction isIndex(value: unknown): value is number {\n return Number(value) >= 0\n}\n/**\n * Constructs a path with dot paths for arrays to use brackets to be compatible with vee-validate path syntax\n */\nexport function normalizeFormPath(path: string): string {\n const pathArr = path.split('.')\n if (!pathArr.length)\n return ''\n\n let fullPath = String(pathArr[0])\n for (let i = 1; i < pathArr.length; i++) {\n if (isIndex(pathArr[i])) {\n fullPath += `[${pathArr[i]}]`\n continue\n }\n\n fullPath += `.${pathArr[i]}`\n }\n\n return fullPath\n}\n\ntype NestedRecord = Record<string, unknown> | { [k: string]: NestedRecord }\n/**\n * Checks if the path opted out of nested fields using `[fieldName]` syntax\n */\nexport function isNotNestedPath(path: string) {\n return /^\\[.+\\]$/i.test(path)\n}\nfunction isObject(obj: unknown): obj is Record<string, unknown> {\n return obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj)\n}\nfunction isContainerValue(value: unknown): value is Record<string, unknown> {\n return isObject(value) || Array.isArray(value)\n}\nfunction cleanupNonNestedPath(path: string) {\n if (isNotNestedPath(path))\n return path.replace(/\\[|\\]/gi, '')\n\n return path\n}\n\n/**\n * Gets a nested property value from an object\n */\nexport function getFromPath<TValue = unknown>(object: NestedRecord | undefined, path: string): TValue | undefined\nexport function getFromPath<TValue = unknown, TFallback = TValue>(\n object: NestedRecord | undefined,\n path: string,\n fallback?: TFallback,\n): TValue | TFallback\nexport function getFromPath<TValue = unknown, TFallback = TValue>(\n object: NestedRecord | undefined,\n path: string,\n fallback?: TFallback,\n): TValue | TFallback | undefined {\n if (!object)\n return fallback\n\n if (isNotNestedPath(path))\n return object[cleanupNonNestedPath(path)] as TValue | undefined\n\n const resolvedValue = (path || '')\n .split(/\\.|\\[(\\d+)\\]/)\n .filter(Boolean)\n .reduce((acc, propKey) => {\n if (isContainerValue(acc) && propKey in acc)\n return acc[propKey]\n\n return fallback\n }, object as unknown)\n\n return resolvedValue as TValue | undefined\n}\n"
}
],
"type": "components:ui"
}

View File

@ -0,0 +1,91 @@
{
"name": "auto-form",
"dependencies": [
"vee-validate",
"@vee-validate/zod",
"zod"
],
"registryDependencies": [
"form",
"accordion",
"button",
"separator",
"switch",
"checkbox",
"calendar",
"popover",
"utils",
"select",
"label",
"radio-group",
"input",
"textarea"
],
"files": [
{
"name": "AutoForm.vue",
"content": "<script setup lang=\"ts\" generic=\"T extends ZodObjectOrWrapped\">\nimport { computed, toRefs } from 'vue'\nimport type { ZodAny, z } from 'zod'\nimport { toTypedSchema } from '@vee-validate/zod'\nimport type { FormContext, GenericObject } from 'vee-validate'\nimport { type ZodObjectOrWrapped, getBaseSchema, getBaseType, getDefaultValueInZodStack, getObjectFormSchema } from './utils'\nimport type { Config, ConfigItem, Dependency, Shape } from './interface'\nimport AutoFormField from './AutoFormField.vue'\nimport { provideDependencies } from './dependencies'\nimport { Form } from '@/lib/registry/new-york/ui/form'\n\nconst props = defineProps<{\n schema: T\n form?: FormContext<GenericObject>\n fieldConfig?: Config<z.infer<T>>\n dependencies?: Dependency<z.infer<T>>[]\n}>()\n\nconst emits = defineEmits<{\n submit: [event: GenericObject]\n}>()\n\nconst { dependencies } = toRefs(props)\nprovideDependencies(dependencies)\n\nconst shapes = computed(() => {\n // @ts-expect-error ignore {} not assignable to object\n const val: { [key in keyof T]: Shape } = {}\n const baseSchema = getObjectFormSchema(props.schema)\n const shape = baseSchema.shape\n Object.keys(shape).forEach((name) => {\n const item = shape[name] as ZodAny\n const baseItem = getBaseSchema(item) as ZodAny\n let options = (baseItem && 'values' in baseItem._def) ? baseItem._def.values as string[] : undefined\n if (!Array.isArray(options) && typeof options === 'object')\n options = Object.values(options)\n\n val[name as keyof T] = {\n type: getBaseType(item),\n default: getDefaultValueInZodStack(item),\n options,\n required: !['ZodOptional', 'ZodNullable'].includes(item._def.typeName),\n schema: baseItem,\n }\n })\n return val\n})\n\nconst fields = computed(() => {\n // @ts-expect-error ignore {} not assignable to object\n const val: { [key in keyof z.infer<T>]: { shape: Shape, fieldName: string, config: ConfigItem } } = {}\n for (const key in shapes.value) {\n const shape = shapes.value[key]\n val[key as keyof z.infer<T>] = {\n shape,\n config: props.fieldConfig?.[key] as ConfigItem,\n fieldName: key,\n }\n }\n return val\n})\n\nconst formComponent = computed(() => props.form ? 'form' : Form)\nconst formComponentProps = computed(() => {\n if (props.form) {\n return {\n onSubmit: props.form.handleSubmit(val => emits('submit', val)),\n }\n }\n else {\n const formSchema = toTypedSchema(props.schema)\n return {\n keepValues: true,\n validationSchema: formSchema,\n onSubmit: (val: GenericObject) => emits('submit', val),\n }\n }\n})\n</script>\n\n<template>\n <component\n :is=\"formComponent\"\n v-bind=\"formComponentProps\"\n >\n <slot name=\"customAutoForm\" :fields=\"fields\">\n <template v-for=\"(shape, key) of shapes\" :key=\"key\">\n <slot\n :shape=\"shape\"\n :name=\"key.toString() as keyof z.infer<T>\"\n :field-name=\"key.toString()\"\n :config=\"fieldConfig?.[key as keyof typeof fieldConfig] as ConfigItem\"\n >\n <AutoFormField\n :config=\"fieldConfig?.[key as keyof typeof fieldConfig] as ConfigItem\"\n :field-name=\"key.toString()\"\n :shape=\"shape\"\n />\n </slot>\n </template>\n </slot>\n\n <slot :shapes=\"shapes\" />\n </component>\n</template>\n"
},
{
"name": "AutoFormField.vue",
"content": "<script setup lang=\"ts\" generic=\"U extends ZodAny\">\nimport type { ZodAny } from 'zod'\nimport { computed } from 'vue'\nimport type { Config, ConfigItem, Shape } from './interface'\nimport { DEFAULT_ZOD_HANDLERS, INPUT_COMPONENTS } from './constant'\nimport useDependencies from './dependencies'\n\nconst props = defineProps<{\n fieldName: string\n shape: Shape\n config?: ConfigItem | Config<U>\n}>()\n\nfunction isValidConfig(config: any): config is ConfigItem {\n return !!config?.component\n}\n\nconst delegatedProps = computed(() => {\n if (['ZodObject', 'ZodArray'].includes(props.shape?.type))\n return { schema: props.shape?.schema }\n return undefined\n})\n\nconst { isDisabled, isHidden, isRequired, overrideOptions } = useDependencies(props.fieldName)\n</script>\n\n<template>\n <component\n :is=\"isValidConfig(config)\n ? typeof config.component === 'string'\n ? INPUT_COMPONENTS[config.component!]\n : config.component\n : INPUT_COMPONENTS[DEFAULT_ZOD_HANDLERS[shape.type]] \"\n v-if=\"!isHidden\"\n :field-name=\"fieldName\"\n :label=\"shape.schema?.description\"\n :required=\"isRequired || shape.required\"\n :options=\"overrideOptions || shape.options\"\n :disabled=\"isDisabled\"\n :config=\"config\"\n v-bind=\"delegatedProps\"\n >\n <slot />\n </component>\n</template>\n"
},
{
"name": "AutoFormFieldArray.vue",
"content": "<script setup lang=\"ts\" generic=\"T extends z.ZodAny\">\nimport * as z from 'zod'\nimport { computed, provide } from 'vue'\nimport { PlusIcon, TrashIcon } from 'lucide-vue-next'\nimport { FieldArray, FieldContextKey, useField } from 'vee-validate'\nimport type { Config, ConfigItem } from './interface'\nimport { beautifyObjectName, getBaseType } from './utils'\nimport AutoFormField from './AutoFormField.vue'\nimport AutoFormLabel from './AutoFormLabel.vue'\nimport { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/lib/registry/new-york/ui/accordion'\nimport { Button } from '@/lib/registry/new-york/ui/button'\nimport { Separator } from '@/lib/registry/new-york/ui/separator'\nimport { FormItem, FormMessage } from '@/lib/registry/new-york/ui/form'\n\nconst props = defineProps<{\n fieldName: string\n required?: boolean\n config?: Config<T>\n schema?: z.ZodArray<T>\n disabled?: boolean\n}>()\n\nfunction isZodArray(\n item: z.ZodArray<any> | z.ZodDefault<any>,\n): item is z.ZodArray<any> {\n return item instanceof z.ZodArray\n}\n\nfunction isZodDefault(\n item: z.ZodArray<any> | z.ZodDefault<any>,\n): item is z.ZodDefault<any> {\n return item instanceof z.ZodDefault\n}\n\nconst itemShape = computed(() => {\n if (!props.schema)\n return\n\n const schema: z.ZodAny = isZodArray(props.schema)\n ? props.schema._def.type\n : isZodDefault(props.schema)\n // @ts-expect-error missing schema\n ? props.schema._def.innerType._def.type\n : null\n\n return {\n type: getBaseType(schema),\n schema,\n }\n})\n\nconst fieldContext = useField(props.fieldName)\n// @ts-expect-error ignore missing `id`\nprovide(FieldContextKey, fieldContext)\n</script>\n\n<template>\n <FieldArray v-slot=\"{ fields, remove, push }\" as=\"section\" :name=\"fieldName\">\n <slot v-bind=\"props\">\n <Accordion type=\"multiple\" class=\"w-full\" collapsible :disabled=\"disabled\" as-child>\n <FormItem>\n <AccordionItem :value=\"fieldName\" class=\"border-none\">\n <AccordionTrigger>\n <AutoFormLabel class=\"text-base\" :required=\"required\">\n {{ schema?.description || beautifyObjectName(fieldName) }}\n </AutoFormLabel>\n </AccordionTrigger>\n\n <AccordionContent>\n <template v-for=\"(field, index) of fields\" :key=\"field.key\">\n <div class=\"mb-4 p-1\">\n <AutoFormField\n :field-name=\"`${fieldName}[${index}]`\"\n :label=\"fieldName\"\n :shape=\"itemShape!\"\n :config=\"config as ConfigItem\"\n />\n\n <div class=\"!my-4 flex justify-end\">\n <Button\n type=\"button\"\n size=\"icon\"\n variant=\"secondary\"\n @click=\"remove(index)\"\n >\n <TrashIcon :size=\"16\" />\n </Button>\n </div>\n <Separator v-if=\"!field.isLast\" />\n </div>\n </template>\n\n <Button\n type=\"button\"\n variant=\"secondary\"\n class=\"mt-4 flex items-center\"\n @click=\"push(null)\"\n >\n <PlusIcon class=\"mr-2\" :size=\"16\" />\n Add\n </Button>\n </AccordionContent>\n\n <FormMessage />\n </AccordionItem>\n </FormItem>\n </Accordion>\n </slot>\n </FieldArray>\n</template>\n"
},
{
"name": "AutoFormFieldBoolean.vue",
"content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { beautifyObjectName } from './utils'\nimport type { FieldProps } from './interface'\nimport AutoFormLabel from './AutoFormLabel.vue'\nimport { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/new-york/ui/form'\nimport { Switch } from '@/lib/registry/new-york/ui/switch'\nimport { Checkbox } from '@/lib/registry/new-york/ui/checkbox'\n\nconst props = defineProps<FieldProps>()\n\nconst booleanComponent = computed(() => props.config?.component === 'switch' ? Switch : Checkbox)\n</script>\n\n<template>\n <FormField v-slot=\"slotProps\" :name=\"fieldName\">\n <FormItem>\n <div class=\"space-y-0 mb-3 flex items-center gap-3\">\n <FormControl>\n <slot v-bind=\"slotProps\">\n <component\n :is=\"booleanComponent\"\n v-bind=\"{ ...slotProps.componentField }\"\n :disabled=\"disabled\"\n :checked=\"slotProps.componentField.modelValue\"\n @update:checked=\"slotProps.componentField['onUpdate:modelValue']\"\n />\n </slot>\n </FormControl>\n <AutoFormLabel v-if=\"!config?.hideLabel\" :required=\"required\">\n {{ config?.label || beautifyObjectName(label ?? fieldName) }}\n </AutoFormLabel>\n </div>\n\n <FormDescription v-if=\"config?.description\">\n {{ config.description }}\n </FormDescription>\n <FormMessage />\n </FormItem>\n </FormField>\n</template>\n"
},
{
"name": "AutoFormFieldDate.vue",
"content": "<script setup lang=\"ts\">\nimport { DateFormatter, getLocalTimeZone } from '@internationalized/date'\nimport { CalendarIcon } from '@radix-icons/vue'\nimport { beautifyObjectName } from './utils'\nimport AutoFormLabel from './AutoFormLabel.vue'\nimport type { FieldProps } from './interface'\nimport { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/new-york/ui/form'\n\nimport { Calendar } from '@/lib/registry/new-york/ui/calendar'\nimport { Button } from '@/lib/registry/new-york/ui/button'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/lib/registry/new-york/ui/popover'\nimport { cn } from '@/lib/utils'\n\ndefineProps<FieldProps>()\n\nconst df = new DateFormatter('en-US', {\n dateStyle: 'long',\n})\n</script>\n\n<template>\n <FormField v-slot=\"slotProps\" :name=\"fieldName\">\n <FormItem>\n <AutoFormLabel v-if=\"!config?.hideLabel\" :required=\"required\">\n {{ config?.label || beautifyObjectName(label ?? fieldName) }}\n </AutoFormLabel>\n <FormControl>\n <slot v-bind=\"slotProps\">\n <div>\n <Popover>\n <PopoverTrigger as-child :disabled=\"disabled\">\n <Button\n variant=\"outline\"\n :class=\"cn(\n 'w-full justify-start text-left font-normal',\n !slotProps.componentField.modelValue && 'text-muted-foreground',\n )\"\n >\n <CalendarIcon class=\"mr-2 h-4 w-4\" />\n {{ slotProps.componentField.modelValue ? df.format(slotProps.componentField.modelValue.toDate(getLocalTimeZone())) : \"Pick a date\" }}\n </Button>\n </PopoverTrigger>\n <PopoverContent class=\"w-auto p-0\">\n <Calendar initial-focus v-bind=\"slotProps.componentField\" />\n </PopoverContent>\n </Popover>\n </div>\n </slot>\n </FormControl>\n\n <FormDescription v-if=\"config?.description\">\n {{ config.description }}\n </FormDescription>\n <FormMessage />\n </FormItem>\n </FormField>\n</template>\n"
},
{
"name": "AutoFormFieldEnum.vue",
"content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport AutoFormLabel from './AutoFormLabel.vue'\nimport { beautifyObjectName } from './utils'\nimport type { FieldProps } from './interface'\nimport { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/new-york/ui/form'\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/lib/registry/new-york/ui/select'\nimport { Label } from '@/lib/registry/new-york/ui/label'\nimport { RadioGroup, RadioGroupItem } from '@/lib/registry/new-york/ui/radio-group'\n\ndefineProps<FieldProps & {\n options?: string[]\n}>()\n</script>\n\n<template>\n <FormField v-slot=\"slotProps\" :name=\"fieldName\">\n <FormItem>\n <AutoFormLabel v-if=\"!config?.hideLabel\" :required=\"required\">\n {{ config?.label || beautifyObjectName(label ?? fieldName) }}\n </AutoFormLabel>\n <FormControl>\n <slot v-bind=\"slotProps\">\n <RadioGroup v-if=\"config?.component === 'radio'\" :disabled=\"disabled\" :orientation=\"'vertical'\" v-bind=\"{ ...slotProps.componentField }\">\n <div v-for=\"(option, index) in options\" :key=\"option\" class=\"mb-2 flex items-center gap-3 space-y-0\">\n <RadioGroupItem :id=\"`${option}-${index}`\" :value=\"option\" />\n <Label :for=\"`${option}-${index}`\">{{ beautifyObjectName(option) }}</Label>\n </div>\n </RadioGroup>\n\n <Select v-else :disabled=\"disabled\" v-bind=\"{ ...slotProps.componentField }\">\n <SelectTrigger class=\"w-full\">\n <SelectValue :placeholder=\"config?.inputProps?.placeholder\" />\n </SelectTrigger>\n <SelectContent>\n <SelectItem v-for=\"option in options\" :key=\"option\" :value=\"option\">\n {{ beautifyObjectName(option) }}\n </SelectItem>\n </SelectContent>\n </Select>\n </slot>\n </FormControl>\n\n <FormDescription v-if=\"config?.description\">\n {{ config.description }}\n </FormDescription>\n <FormMessage />\n </FormItem>\n </FormField>\n</template>\n"
},
{
"name": "AutoFormFieldFile.vue",
"content": "<script setup lang=\"ts\">\nimport { ref } from 'vue'\nimport { TrashIcon } from '@radix-icons/vue'\nimport { beautifyObjectName } from './utils'\nimport type { FieldProps } from './interface'\nimport AutoFormLabel from './AutoFormLabel.vue'\nimport { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/new-york/ui/form'\nimport { Input } from '@/lib/registry/new-york/ui/input'\nimport { Button } from '@/lib/registry/new-york/ui/button'\n\ndefineProps<FieldProps>()\n\nconst inputFile = ref<File>()\nasync function parseFileAsString(file: File | undefined): Promise<string> {\n return new Promise((resolve, reject) => {\n if (file) {\n const reader = new FileReader()\n reader.onloadend = () => {\n resolve(reader.result as string)\n }\n reader.onerror = (err) => {\n reject(err)\n }\n reader.readAsDataURL(file)\n }\n })\n}\n</script>\n\n<template>\n <FormField v-slot=\"slotProps\" :name=\"fieldName\">\n <FormItem v-bind=\"$attrs\">\n <AutoFormLabel v-if=\"!config?.hideLabel\" :required=\"required\">\n {{ config?.label || beautifyObjectName(label ?? fieldName) }}\n </AutoFormLabel>\n <FormControl>\n <slot v-bind=\"slotProps\">\n <Input\n v-if=\"!inputFile\"\n type=\"file\"\n v-bind=\"{ ...config?.inputProps }\"\n :disabled=\"disabled\"\n @change=\"async (ev: InputEvent) => {\n const file = (ev.target as HTMLInputElement).files?.[0]\n inputFile = file\n const parsed = await parseFileAsString(file)\n slotProps.componentField.onInput(parsed)\n }\"\n />\n <div v-else class=\"flex h-9 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\">\n <p>{{ inputFile?.name }}</p>\n <Button\n :size=\"'icon'\"\n :variant=\"'ghost'\"\n class=\"h-[26px] w-[26px]\"\n aria-label=\"Remove file\"\n type=\"button\"\n @click=\"() => {\n inputFile = undefined\n slotProps.componentField.onInput(undefined)\n }\"\n >\n <TrashIcon />\n </Button>\n </div>\n </slot>\n </FormControl>\n <FormDescription v-if=\"config?.description\">\n {{ config.description }}\n </FormDescription>\n <FormMessage />\n </FormItem>\n </FormField>\n</template>\n"
},
{
"name": "AutoFormFieldInput.vue",
"content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport AutoFormLabel from './AutoFormLabel.vue'\nimport { beautifyObjectName } from './utils'\nimport type { FieldProps } from './interface'\nimport { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/new-york/ui/form'\nimport { Input } from '@/lib/registry/new-york/ui/input'\nimport { Textarea } from '@/lib/registry/new-york/ui/textarea'\n\nconst props = defineProps<FieldProps>()\nconst inputComponent = computed(() => props.config?.component === 'textarea' ? Textarea : Input)\n</script>\n\n<template>\n <FormField v-slot=\"slotProps\" :name=\"fieldName\">\n <FormItem v-bind=\"$attrs\">\n <AutoFormLabel v-if=\"!config?.hideLabel\" :required=\"required\">\n {{ config?.label || beautifyObjectName(label ?? fieldName) }}\n </AutoFormLabel>\n <FormControl>\n <slot v-bind=\"slotProps\">\n <component\n :is=\"inputComponent\"\n type=\"text\"\n v-bind=\"{ ...slotProps.componentField, ...config?.inputProps }\"\n :disabled=\"disabled\"\n />\n </slot>\n </FormControl>\n <FormDescription v-if=\"config?.description\">\n {{ config.description }}\n </FormDescription>\n <FormMessage />\n </FormItem>\n </FormField>\n</template>\n"
},
{
"name": "AutoFormFieldNumber.vue",
"content": "<script setup lang=\"ts\">\nimport AutoFormLabel from './AutoFormLabel.vue'\nimport { beautifyObjectName } from './utils'\nimport type { FieldProps } from './interface'\nimport { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/new-york/ui/form'\nimport { Input } from '@/lib/registry/new-york/ui/input'\n\ndefineOptions({\n inheritAttrs: false,\n})\n\ndefineProps<FieldProps>()\n</script>\n\n<template>\n <FormField v-slot=\"slotProps\" :name=\"fieldName\">\n <FormItem>\n <AutoFormLabel v-if=\"!config?.hideLabel\" :required=\"required\">\n {{ config?.label || beautifyObjectName(label ?? fieldName) }}\n </AutoFormLabel>\n <FormControl>\n <slot v-bind=\"slotProps\">\n <Input type=\"number\" v-bind=\"{ ...slotProps.componentField, ...config?.inputProps }\" :disabled=\"disabled\" />\n </slot>\n </FormControl>\n <FormDescription v-if=\"config?.description\">\n {{ config.description }}\n </FormDescription>\n <FormMessage />\n </FormItem>\n </FormField>\n</template>\n"
},
{
"name": "AutoFormFieldObject.vue",
"content": "<script setup lang=\"ts\" generic=\"T extends ZodRawShape\">\nimport type { ZodAny, ZodObject, ZodRawShape } from 'zod'\nimport { computed, provide } from 'vue'\nimport { FieldContextKey, useField } from 'vee-validate'\nimport AutoFormField from './AutoFormField.vue'\nimport type { Config, ConfigItem, Shape } from './interface'\nimport { beautifyObjectName, getBaseSchema, getBaseType, getDefaultValueInZodStack } from './utils'\nimport AutoFormLabel from './AutoFormLabel.vue'\nimport { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/lib/registry/new-york/ui/accordion'\nimport { FormItem } from '@/lib/registry/new-york/ui/form'\n\nconst props = defineProps<{\n fieldName: string\n required?: boolean\n config?: Config<T>\n schema?: ZodObject<T>\n disabled?: boolean\n}>()\n\nconst shapes = computed(() => {\n // @ts-expect-error ignore {} not assignable to object\n const val: { [key in keyof T]: Shape } = {}\n\n if (!props.schema)\n return\n const shape = getBaseSchema(props.schema)?.shape\n if (!shape)\n return\n Object.keys(shape).forEach((name) => {\n const item = shape[name] as ZodAny\n let options = 'values' in item._def ? item._def.values as string[] : undefined\n if (!Array.isArray(options) && typeof options === 'object')\n options = Object.values(options)\n\n val[name as keyof T] = {\n type: getBaseType(item),\n default: getDefaultValueInZodStack(item),\n options,\n required: !['ZodOptional', 'ZodNullable'].includes(item._def.typeName),\n schema: item,\n }\n })\n return val\n})\n\nconst fieldContext = useField(props.fieldName)\n// @ts-expect-error ignore missing `id`\nprovide(FieldContextKey, fieldContext)\n</script>\n\n<template>\n <section>\n <slot v-bind=\"props\">\n <Accordion type=\"single\" as-child class=\"w-full\" collapsible :disabled=\"disabled\">\n <FormItem>\n <AccordionItem :value=\"fieldName\" class=\"border-none\">\n <AccordionTrigger>\n <AutoFormLabel class=\"text-base\" :required=\"required\">\n {{ schema?.description || beautifyObjectName(fieldName) }}\n </AutoFormLabel>\n </AccordionTrigger>\n <AccordionContent class=\"p-1 space-y-5\">\n <template v-for=\"(shape, key) in shapes\" :key=\"key\">\n <AutoFormField\n :config=\"config?.[key as keyof typeof config] as ConfigItem\"\n :field-name=\"`${fieldName}.${key.toString()}`\"\n :label=\"key.toString()\"\n :shape=\"shape\"\n />\n </template>\n </AccordionContent>\n </AccordionItem>\n </FormItem>\n </Accordion>\n </slot>\n </section>\n</template>\n"
},
{
"name": "AutoFormLabel.vue",
"content": "<script setup lang=\"ts\">\nimport { FormLabel } from '@/lib/registry/new-york/ui/form'\n\ndefineProps<{\n required?: boolean\n}>()\n</script>\n\n<template>\n <FormLabel>\n <slot />\n <span v-if=\"required\" class=\"text-destructive\"> *</span>\n </FormLabel>\n</template>\n"
},
{
"name": "constant.ts",
"content": "import AutoFormFieldArray from './AutoFormFieldArray.vue'\nimport AutoFormFieldBoolean from './AutoFormFieldBoolean.vue'\nimport AutoFormFieldDate from './AutoFormFieldDate.vue'\nimport AutoFormFieldEnum from './AutoFormFieldEnum.vue'\nimport AutoFormFieldFile from './AutoFormFieldFile.vue'\nimport AutoFormFieldInput from './AutoFormFieldInput.vue'\nimport AutoFormFieldNumber from './AutoFormFieldNumber.vue'\nimport AutoFormFieldObject from './AutoFormFieldObject.vue'\n\nexport const INPUT_COMPONENTS = {\n date: AutoFormFieldDate,\n select: AutoFormFieldEnum,\n radio: AutoFormFieldEnum,\n checkbox: AutoFormFieldBoolean,\n switch: AutoFormFieldBoolean,\n textarea: AutoFormFieldInput,\n number: AutoFormFieldNumber,\n string: AutoFormFieldInput,\n file: AutoFormFieldFile,\n array: AutoFormFieldArray,\n object: AutoFormFieldObject,\n}\n\n/**\n * Define handlers for specific Zod types.\n * You can expand this object to support more types.\n */\nexport const DEFAULT_ZOD_HANDLERS: {\n [key: string]: keyof typeof INPUT_COMPONENTS\n} = {\n ZodString: 'string',\n ZodBoolean: 'checkbox',\n ZodDate: 'date',\n ZodEnum: 'select',\n ZodNativeEnum: 'select',\n ZodNumber: 'number',\n ZodArray: 'array',\n ZodObject: 'object',\n}\n"
},
{
"name": "dependencies.ts",
"content": "import type * as z from 'zod'\nimport type { Ref } from 'vue'\nimport { computed, ref, watch } from 'vue'\nimport { useFieldValue, useFormValues } from 'vee-validate'\nimport { createContext } from 'radix-vue'\nimport { type Dependency, DependencyType, type EnumValues } from './interface'\nimport { getFromPath, getIndexIfArray } from './utils'\n\nexport const [injectDependencies, provideDependencies] = createContext<Ref<Dependency<z.infer<z.ZodObject<any>>>[] | undefined>>('AutoFormDependencies')\n\nexport default function useDependencies(\n fieldName: string,\n) {\n const form = useFormValues()\n // parsed test[0].age => test.age\n const currentFieldName = fieldName.replace(/\\[\\d+\\]/g, '')\n const currentFieldValue = useFieldValue<any>(fieldName)\n\n if (!form)\n throw new Error('useDependencies should be used within <AutoForm>')\n\n const dependencies = injectDependencies()\n const isDisabled = ref(false)\n const isHidden = ref(false)\n const isRequired = ref(false)\n const overrideOptions = ref<EnumValues | undefined>()\n\n const currentFieldDependencies = computed(() => dependencies.value?.filter(\n dependency => dependency.targetField === currentFieldName,\n ))\n\n function getSourceValue(dep: Dependency<any>) {\n const source = dep.sourceField as string\n const index = getIndexIfArray(fieldName) ?? -1\n const [sourceLast, ...sourceInitial] = source.split('.').toReversed()\n const [_targetLast, ...targetInitial] = (dep.targetField as string).split('.').toReversed()\n\n if (index >= 0 && sourceInitial.join(',') === targetInitial.join(',')) {\n const [_currentLast, ...currentInitial] = fieldName.split('.').toReversed()\n return getFromPath(form.value, currentInitial.join('.') + sourceLast)\n }\n\n return getFromPath(form.value, source)\n }\n\n const sourceFieldValues = computed(() => currentFieldDependencies.value?.map(dep => getSourceValue(dep)))\n\n const resetConditionState = () => {\n isDisabled.value = false\n isHidden.value = false\n isRequired.value = false\n overrideOptions.value = undefined\n }\n\n watch([sourceFieldValues, dependencies], () => {\n resetConditionState()\n currentFieldDependencies.value?.forEach((dep) => {\n const sourceValue = getSourceValue(dep)\n const conditionMet = dep.when(sourceValue, currentFieldValue.value)\n\n switch (dep.type) {\n case DependencyType.DISABLES:\n if (conditionMet)\n isDisabled.value = true\n\n break\n case DependencyType.REQUIRES:\n if (conditionMet)\n isRequired.value = true\n\n break\n case DependencyType.HIDES:\n if (conditionMet)\n isHidden.value = true\n\n break\n case DependencyType.SETS_OPTIONS:\n if (conditionMet)\n overrideOptions.value = dep.options\n\n break\n }\n })\n }, { immediate: true, deep: true })\n\n return {\n isDisabled,\n isHidden,\n isRequired,\n overrideOptions,\n }\n}\n"
},
{
"name": "index.ts",
"content": "export { getObjectFormSchema, getBaseSchema, getBaseType } from './utils'\nexport type { Config, ConfigItem, FieldProps } from './interface'\n\nexport { default as AutoForm } from './AutoForm.vue'\nexport { default as AutoFormField } from './AutoFormField.vue'\nexport { default as AutoFormLabel } from './AutoFormLabel.vue'\n\nexport { default as AutoFormFieldArray } from './AutoFormFieldArray.vue'\nexport { default as AutoFormFieldBoolean } from './AutoFormFieldBoolean.vue'\nexport { default as AutoFormFieldDate } from './AutoFormFieldDate.vue'\nexport { default as AutoFormFieldEnum } from './AutoFormFieldEnum.vue'\nexport { default as AutoFormFieldFile } from './AutoFormFieldFile.vue'\nexport { default as AutoFormFieldInput } from './AutoFormFieldInput.vue'\nexport { default as AutoFormFieldNumber } from './AutoFormFieldNumber.vue'\nexport { default as AutoFormFieldObject } from './AutoFormFieldObject.vue'\n"
},
{
"name": "interface.ts",
"content": "import type { Component, InputHTMLAttributes, SelectHTMLAttributes } from 'vue'\nimport type { ZodAny, z } from 'zod'\nimport type { INPUT_COMPONENTS } from './constant'\n\nexport interface FieldProps {\n fieldName: string\n label?: string\n required?: boolean\n config?: ConfigItem\n disabled?: boolean\n}\n\nexport interface Shape {\n type: string\n default?: any\n required?: boolean\n options?: string[]\n schema?: ZodAny\n}\n\nexport interface ConfigItem {\n /** Value for the `FormLabel` */\n label?: string\n /** Value for the `FormDescription` */\n description?: string\n /** Pick which component to be rendered. */\n component?: keyof typeof INPUT_COMPONENTS | Component\n /** Hide `FormLabel`. */\n hideLabel?: boolean\n inputProps?: InputHTMLAttributes\n}\n\n// Define a type to unwrap an array\ntype UnwrapArray<T> = T extends (infer U)[] ? U : never\n\nexport type Config<SchemaType extends object> = {\n // If SchemaType.key is an object, create a nested Config, otherwise ConfigItem\n [Key in keyof SchemaType]?:\n SchemaType[Key] extends any[]\n ? UnwrapArray<Config<SchemaType[Key]>>\n : SchemaType[Key] extends object\n ? Config<SchemaType[Key]>\n : ConfigItem;\n}\n\nexport enum DependencyType {\n DISABLES,\n REQUIRES,\n HIDES,\n SETS_OPTIONS,\n}\n\ninterface BaseDependency<SchemaType extends z.infer<z.ZodObject<any, any>>> {\n sourceField: keyof SchemaType\n type: DependencyType\n targetField: keyof SchemaType\n when: (sourceFieldValue: any, targetFieldValue: any) => boolean\n}\n\nexport type ValueDependency<SchemaType extends z.infer<z.ZodObject<any, any>>> =\n BaseDependency<SchemaType> & {\n type:\n | DependencyType.DISABLES\n | DependencyType.REQUIRES\n | DependencyType.HIDES\n }\n\nexport type EnumValues = readonly [string, ...string[]]\n\nexport type OptionsDependency<\n SchemaType extends z.infer<z.ZodObject<any, any>>,\n> = BaseDependency<SchemaType> & {\n type: DependencyType.SETS_OPTIONS\n\n // Partial array of values from sourceField that will trigger the dependency\n options: EnumValues\n}\n\nexport type Dependency<SchemaType extends z.infer<z.ZodObject<any, any>>> =\n | ValueDependency<SchemaType>\n | OptionsDependency<SchemaType>\n"
},
{
"name": "utils.ts",
"content": "import type { z } from 'zod'\n\n// TODO: This should support recursive ZodEffects but TypeScript doesn't allow circular type definitions.\nexport type ZodObjectOrWrapped =\n | z.ZodObject<any, any>\n | z.ZodEffects<z.ZodObject<any, any>>\n\n/**\n * Beautify a camelCase string.\n * e.g. \"myString\" -> \"My String\"\n */\nexport function beautifyObjectName(string: string) {\n // Remove bracketed indices\n // if numbers only return the string\n let output = string.replace(/\\[\\d+\\]/g, '').replace(/([A-Z])/g, ' $1')\n output = output.charAt(0).toUpperCase() + output.slice(1)\n return output\n}\n\n/**\n * Parse string and extract the index\n * @param string\n * @returns index or undefined\n */\nexport function getIndexIfArray(string: string) {\n const indexRegex = /\\[(\\d+)\\]/\n // Match the index\n const match = string.match(indexRegex)\n // Extract the index (number)\n const index = match ? Number.parseInt(match[1]) : undefined\n return index\n}\n\n/**\n * Get the lowest level Zod type.\n * This will unpack optionals, refinements, etc.\n */\nexport function getBaseSchema<\n ChildType extends z.ZodAny | z.AnyZodObject = z.ZodAny,\n>(schema: ChildType | z.ZodEffects<ChildType>): ChildType | null {\n if (!schema)\n return null\n if ('innerType' in schema._def)\n return getBaseSchema(schema._def.innerType as ChildType)\n\n if ('schema' in schema._def)\n return getBaseSchema(schema._def.schema as ChildType)\n\n return schema as ChildType\n}\n\n/**\n * Get the type name of the lowest level Zod type.\n * This will unpack optionals, refinements, etc.\n */\nexport function getBaseType(schema: z.ZodAny) {\n const baseSchema = getBaseSchema(schema)\n return baseSchema ? baseSchema._def.typeName : ''\n}\n\n/**\n * Search for a \"ZodDefault\" in the Zod stack and return its value.\n */\nexport function getDefaultValueInZodStack(schema: z.ZodAny): any {\n const typedSchema = schema as unknown as z.ZodDefault<\n z.ZodNumber | z.ZodString\n >\n\n if (typedSchema._def.typeName === 'ZodDefault')\n return typedSchema._def.defaultValue()\n\n if ('innerType' in typedSchema._def) {\n return getDefaultValueInZodStack(\n typedSchema._def.innerType as unknown as z.ZodAny,\n )\n }\n if ('schema' in typedSchema._def) {\n return getDefaultValueInZodStack(\n (typedSchema._def as any).schema as z.ZodAny,\n )\n }\n\n return undefined\n}\n\nexport function getObjectFormSchema(\n schema: ZodObjectOrWrapped,\n): z.ZodObject<any, any> {\n if (schema?._def.typeName === 'ZodEffects') {\n const typedSchema = schema as z.ZodEffects<z.ZodObject<any, any>>\n return getObjectFormSchema(typedSchema._def.schema)\n }\n return schema as z.ZodObject<any, any>\n}\n\nfunction isIndex(value: unknown): value is number {\n return Number(value) >= 0\n}\n/**\n * Constructs a path with dot paths for arrays to use brackets to be compatible with vee-validate path syntax\n */\nexport function normalizeFormPath(path: string): string {\n const pathArr = path.split('.')\n if (!pathArr.length)\n return ''\n\n let fullPath = String(pathArr[0])\n for (let i = 1; i < pathArr.length; i++) {\n if (isIndex(pathArr[i])) {\n fullPath += `[${pathArr[i]}]`\n continue\n }\n\n fullPath += `.${pathArr[i]}`\n }\n\n return fullPath\n}\n\ntype NestedRecord = Record<string, unknown> | { [k: string]: NestedRecord }\n/**\n * Checks if the path opted out of nested fields using `[fieldName]` syntax\n */\nexport function isNotNestedPath(path: string) {\n return /^\\[.+\\]$/i.test(path)\n}\nfunction isObject(obj: unknown): obj is Record<string, unknown> {\n return obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj)\n}\nfunction isContainerValue(value: unknown): value is Record<string, unknown> {\n return isObject(value) || Array.isArray(value)\n}\nfunction cleanupNonNestedPath(path: string) {\n if (isNotNestedPath(path))\n return path.replace(/\\[|\\]/gi, '')\n\n return path\n}\n\n/**\n * Gets a nested property value from an object\n */\nexport function getFromPath<TValue = unknown>(object: NestedRecord | undefined, path: string): TValue | undefined\nexport function getFromPath<TValue = unknown, TFallback = TValue>(\n object: NestedRecord | undefined,\n path: string,\n fallback?: TFallback,\n): TValue | TFallback\nexport function getFromPath<TValue = unknown, TFallback = TValue>(\n object: NestedRecord | undefined,\n path: string,\n fallback?: TFallback,\n): TValue | TFallback | undefined {\n if (!object)\n return fallback\n\n if (isNotNestedPath(path))\n return object[cleanupNonNestedPath(path)] as TValue | undefined\n\n const resolvedValue = (path || '')\n .split(/\\.|\\[(\\d+)\\]/)\n .filter(Boolean)\n .reduce((acc, propKey) => {\n if (isContainerValue(acc) && propKey in acc)\n return acc[propKey]\n\n return fallback\n }, object as unknown)\n\n return resolvedValue as TValue | undefined\n}\n"
}
],
"type": "components:ui"
}

View File

@ -32,12 +32,12 @@
},
{
"name": "index.ts",
"content": "export { Form, Field as FormField } from 'vee-validate'\nexport { default as FormItem } from './FormItem.vue'\nexport { default as FormLabel } from './FormLabel.vue'\nexport { default as FormControl } from './FormControl.vue'\nexport { default as FormMessage } from './FormMessage.vue'\nexport { default as FormDescription } from './FormDescription.vue'\n"
"content": "export { Form, Field as FormField, FieldArray as FormFieldArray } from 'vee-validate'\nexport { default as FormItem } from './FormItem.vue'\nexport { default as FormLabel } from './FormLabel.vue'\nexport { default as FormControl } from './FormControl.vue'\nexport { default as FormMessage } from './FormMessage.vue'\nexport { default as FormDescription } from './FormDescription.vue'\n"
},
{
"name": "useFormField.ts",
"content": "import { FieldContextKey, useFieldError, useIsFieldDirty, useIsFieldTouched, useIsFieldValid } from 'vee-validate'\nimport { inject } from 'vue'\nimport { FORM_ITEM_INJECTION_KEY } from './FormItem.vue'\n\nexport function useFormField() {\n const fieldContext = inject(FieldContextKey)\n const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY)\n\n const fieldState = {\n valid: useIsFieldValid(),\n isDirty: useIsFieldDirty(),\n isTouched: useIsFieldTouched(),\n error: useFieldError(),\n }\n\n if (!fieldContext)\n throw new Error('useFormField should be used within <FormField>')\n\n const { name } = fieldContext\n const id = fieldItemContext\n\n return {\n id,\n name,\n formItemId: `${id}-form-item`,\n formDescriptionId: `${id}-form-item-description`,\n formMessageId: `${id}-form-item-message`,\n ...fieldState,\n }\n}\n"
"content": "import { FieldContextKey, useFieldError, useIsFieldDirty, useIsFieldTouched, useIsFieldValid } from 'vee-validate'\nimport { inject } from 'vue'\nimport { FORM_ITEM_INJECTION_KEY } from './FormItem.vue'\n\nexport function useFormField() {\n const fieldContext = inject(FieldContextKey)\n const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY)\n const fieldState = {\n valid: useIsFieldValid(),\n isDirty: useIsFieldDirty(),\n isTouched: useIsFieldTouched(),\n error: useFieldError(),\n }\n\n if (!fieldContext)\n throw new Error('useFormField should be used within <FormField>')\n\n const { name } = fieldContext\n const id = fieldItemContext\n\n return {\n id,\n name,\n formItemId: `${id}-form-item`,\n formDescriptionId: `${id}-form-item-description`,\n formMessageId: `${id}-form-item-message`,\n ...fieldState,\n }\n}\n"
}
],
"type": "components:ui"
}
}

View File

@ -108,8 +108,8 @@ importers:
specifier: ^0.1.0
version: 0.1.0(typescript@5.4.5)
vee-validate:
specifier: 4.12.5
version: 4.12.5(vue@3.4.24(typescript@5.4.5))
specifier: 4.12.6
version: 4.12.6(vue@3.4.24(typescript@5.4.5))
vue:
specifier: ^3.4.24
version: 3.4.24(typescript@5.4.5)
@ -6976,11 +6976,6 @@ packages:
vaul-vue@0.1.0:
resolution: {integrity: sha512-3PYWMbN3cSdsciv3fzewskxZFnX61PYq1uNsbvizXDo/8sN4SMrWkYDqWaPdTD3GTEm6wpx7j5flRLg7A5ZXbQ==}
vee-validate@4.12.5:
resolution: {integrity: sha512-rvaDfLPSLwTk+mf016XWE4drB8yXzOsKXiKHTb9gNXNLTtQSZ0Ww26O0/xbIFQe+n3+u8Wv1Y8uO/aLDX4fxOg==}
peerDependencies:
vue: ^3.3.11
vee-validate@4.12.6:
resolution: {integrity: sha512-EKM3YHy8t1miPh30d5X6xOrfG/Ctq0nbN4eMpCK7ezvI6T98/S66vswP+ihL4QqAK/k5KqreWOxof09+JG7N/A==}
peerDependencies:
@ -15256,12 +15251,6 @@ snapshots:
- '@vue/composition-api'
- typescript
vee-validate@4.12.5(vue@3.4.24(typescript@5.4.5)):
dependencies:
'@vue/devtools-api': 6.6.1
type-fest: 4.16.0
vue: 3.4.24(typescript@5.4.5)
vee-validate@4.12.6(vue@3.4.24(typescript@5.4.5)):
dependencies:
'@vue/devtools-api': 6.6.1