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:
parent
18e40cf002
commit
32d7b9ca4a
|
|
@ -19,7 +19,7 @@ defineProps<CalloutProps>()
|
|||
<AlertTitle v-if="title">
|
||||
{{ title }}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<AlertDescription class="[&_a]:underline">
|
||||
<slot />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
550
apps/www/src/content/docs/components/auto-form.md
Normal file
550
apps/www/src/content/docs/components/auto-form.md
Normal 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 Zod’s `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" />
|
||||
45
apps/www/src/lib/registry/default/example/AutoFormApi.vue
Normal file
45
apps/www/src/lib/registry/default/example/AutoFormApi.vue
Normal 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>
|
||||
38
apps/www/src/lib/registry/default/example/AutoFormArray.vue
Normal file
38
apps/www/src/lib/registry/default/example/AutoFormArray.vue
Normal 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>
|
||||
161
apps/www/src/lib/registry/default/example/AutoFormBasic.vue
Normal file
161
apps/www/src/lib/registry/default/example/AutoFormBasic.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
105
apps/www/src/lib/registry/default/ui/auto-form/AutoForm.vue
Normal file
105
apps/www/src/lib/registry/default/ui/auto-form/AutoForm.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { beautifyObjectName } from './utils'
|
||||
import type { FieldProps } from './interface'
|
||||
import AutoFormLabel from './AutoFormLabel.vue'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/default/ui/form'
|
||||
import { Switch } from '@/lib/registry/default/ui/switch'
|
||||
import { Checkbox } from '@/lib/registry/default/ui/checkbox'
|
||||
|
||||
const props = defineProps<FieldProps>()
|
||||
|
||||
const booleanComponent = computed(() => props.config?.component === 'switch' ? Switch : Checkbox)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField v-slot="slotProps" :name="fieldName">
|
||||
<FormItem>
|
||||
<div class="space-y-0 mb-3 flex items-center gap-3">
|
||||
<FormControl>
|
||||
<slot v-bind="slotProps">
|
||||
<component
|
||||
:is="booleanComponent"
|
||||
v-bind="{ ...slotProps.componentField }"
|
||||
:disabled="disabled"
|
||||
:checked="slotProps.componentField.modelValue"
|
||||
@update:checked="slotProps.componentField['onUpdate:modelValue']"
|
||||
/>
|
||||
</slot>
|
||||
</FormControl>
|
||||
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||
</AutoFormLabel>
|
||||
</div>
|
||||
|
||||
<FormDescription v-if="config?.description">
|
||||
{{ config.description }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<script setup lang="ts">
|
||||
import { DateFormatter, getLocalTimeZone } from '@internationalized/date'
|
||||
import { CalendarIcon } from 'lucide-vue-next'
|
||||
import { beautifyObjectName } from './utils'
|
||||
import AutoFormLabel from './AutoFormLabel.vue'
|
||||
import type { FieldProps } from './interface'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/lib/registry/default/ui/form'
|
||||
|
||||
import { Calendar } from '@/lib/registry/default/ui/calendar'
|
||||
import { Button } from '@/lib/registry/default/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/lib/registry/default/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineProps<FieldProps>()
|
||||
|
||||
const df = new DateFormatter('en-US', {
|
||||
dateStyle: 'long',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormField v-slot="slotProps" :name="fieldName">
|
||||
<FormItem>
|
||||
<AutoFormLabel v-if="!config?.hideLabel" :required="required">
|
||||
{{ config?.label || beautifyObjectName(label ?? fieldName) }}
|
||||
</AutoFormLabel>
|
||||
<FormControl>
|
||||
<slot v-bind="slotProps">
|
||||
<div>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child :disabled="disabled">
|
||||
<Button
|
||||
variant="outline"
|
||||
:class="cn(
|
||||
'w-full justify-start text-left font-normal',
|
||||
!slotProps.componentField.modelValue && 'text-muted-foreground',
|
||||
)"
|
||||
>
|
||||
<CalendarIcon class="mr-2 h-4 w-4" :size="16" />
|
||||
{{ slotProps.componentField.modelValue ? df.format(slotProps.componentField.modelValue.toDate(getLocalTimeZone())) : "Pick a date" }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-0">
|
||||
<Calendar initial-focus v-bind="slotProps.componentField" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</slot>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription v-if="config?.description">
|
||||
{{ config.description }}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { FormLabel } from '@/lib/registry/default/ui/form'
|
||||
|
||||
defineProps<{
|
||||
required?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormLabel>
|
||||
<slot />
|
||||
<span v-if="required" class="text-destructive"> *</span>
|
||||
</FormLabel>
|
||||
</template>
|
||||
39
apps/www/src/lib/registry/default/ui/auto-form/constant.ts
Normal file
39
apps/www/src/lib/registry/default/ui/auto-form/constant.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import 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',
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
15
apps/www/src/lib/registry/default/ui/auto-form/index.ts
Normal file
15
apps/www/src/lib/registry/default/ui/auto-form/index.ts
Normal 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'
|
||||
81
apps/www/src/lib/registry/default/ui/auto-form/interface.ts
Normal file
81
apps/www/src/lib/registry/default/ui/auto-form/interface.ts
Normal 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>
|
||||
171
apps/www/src/lib/registry/default/ui/auto-form/utils.ts
Normal file
171
apps/www/src/lib/registry/default/ui/auto-form/utils.ts
Normal 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
|
||||
}
|
||||
45
apps/www/src/lib/registry/new-york/example/AutoFormApi.vue
Normal file
45
apps/www/src/lib/registry/new-york/example/AutoFormApi.vue
Normal 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>
|
||||
38
apps/www/src/lib/registry/new-york/example/AutoFormArray.vue
Normal file
38
apps/www/src/lib/registry/new-york/example/AutoFormArray.vue
Normal 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>
|
||||
161
apps/www/src/lib/registry/new-york/example/AutoFormBasic.vue
Normal file
161
apps/www/src/lib/registry/new-york/example/AutoFormBasic.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
105
apps/www/src/lib/registry/new-york/ui/auto-form/AutoForm.vue
Normal file
105
apps/www/src/lib/registry/new-york/ui/auto-form/AutoForm.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
39
apps/www/src/lib/registry/new-york/ui/auto-form/constant.ts
Normal file
39
apps/www/src/lib/registry/new-york/ui/auto-form/constant.ts
Normal 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',
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
15
apps/www/src/lib/registry/new-york/ui/auto-form/index.ts
Normal file
15
apps/www/src/lib/registry/new-york/ui/auto-form/index.ts
Normal 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'
|
||||
81
apps/www/src/lib/registry/new-york/ui/auto-form/interface.ts
Normal file
81
apps/www/src/lib/registry/new-york/ui/auto-form/interface.ts
Normal 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>
|
||||
171
apps/www/src/lib/registry/new-york/ui/auto-form/utils.ts
Normal file
171
apps/www/src/lib/registry/new-york/ui/auto-form/utils.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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": [],
|
||||
|
|
|
|||
91
apps/www/src/public/registry/styles/default/auto-form.json
Normal file
91
apps/www/src/public/registry/styles/default/auto-form.json
Normal 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"
|
||||
}
|
||||
91
apps/www/src/public/registry/styles/new-york/auto-form.json
Normal file
91
apps/www/src/public/registry/styles/new-york/auto-form.json
Normal 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"
|
||||
}
|
||||
|
|
@ -32,11 +32,11 @@
|
|||
},
|
||||
{
|
||||
"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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user