diff --git a/apps/www/.vitepress/theme/components/Callout.vue b/apps/www/.vitepress/theme/components/Callout.vue index b2d5f1cea..d0381be01 100644 --- a/apps/www/.vitepress/theme/components/Callout.vue +++ b/apps/www/.vitepress/theme/components/Callout.vue @@ -19,7 +19,7 @@ defineProps() {{ title }} - + diff --git a/apps/www/.vitepress/theme/config/docs.ts b/apps/www/.vitepress/theme/config/docs.ts index 71fce55af..e09074687 100644 --- a/apps/www/.vitepress/theme/config/docs.ts +++ b/apps/www/.vitepress/theme/config/docs.ts @@ -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: [ diff --git a/apps/www/__registry__/index.ts b/apps/www/__registry__/index.ts index 6d6a53951..6218dbe59 100644 --- a/apps/www/__registry__/index.ts +++ b/apps/www/__registry__/index.ts @@ -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", diff --git a/apps/www/package.json b/apps/www/package.json index 9683536b8..49224f5ee 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -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", diff --git a/apps/www/src/content/docs/components/auto-form.md b/apps/www/src/content/docs/components/auto-form.md new file mode 100644 index 000000000..49913db2f --- /dev/null +++ b/apps/www/src/content/docs/components/auto-form.md @@ -0,0 +1,550 @@ +--- +title: AutoForm +description: Automatically generate a form from Zod schema. +primitive: https://vee-validate.logaretm.com/v4/guide/overview/ +--- + + + +Credit: Heavily inspired by [AutoForm](https://github.com/vantezzen/auto-form) by Vantezzen + + + +## 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 + + + +### Run the following command + +```bash +npx shadcn-vue@latest update form +npx shadcn-vue@latest add auto-form +``` + + + +## 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. + + + +⚠️ 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) + + + +### 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 + +``` + +### 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 + +``` + +### Description + +You can use the `description` property to add a description below the field. + +```vue + +``` + +### 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 + + +// This will be rendered as: + +``` + +Disabling the label of an input can be done by using the `showLabel` property in `inputProps`. + +```vue + +``` + +### 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 + +``` + +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 + + + +``` + +Pass the above component in `fieldConfig`. + +```vue + +``` + +### Named slot + +You can use Vue named slot to customize the rendered `AutoFormField`. + +```vue + +``` + +### 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 + +``` + +### Controlled form + +By passing the `form` as props, you can control and use the method provided by `Form`. + +```vue + + + +``` + +### Submitting the form + +You can use any `button` component to create a submit button. Most importantly is to add attributes `type="submit"`. + +```vue + +``` + +### Adding other elements + +All children passed to the `AutoForm` component will be rendered below the form. + +```vue + +``` + +### 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 + +``` + +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 + + + +### Input Without Label +This example shows how to use AutoForm input without label. + + + +### Sub Object +Automatically generate a form from a Zod schema. + + + +### Controlled +This example shows how to use AutoForm in a controlled way. + + + +### Confirm Password +Refined schema to validate that two fields match. + + + +### API Example +The form select options are fetched from an API. + + + +### Array support +You can use arrays in your schemas to create dynamic forms. + + + +### Dependencies +Create dependencies between fields. + + diff --git a/apps/www/src/lib/registry/default/example/AutoFormApi.vue b/apps/www/src/lib/registry/default/example/AutoFormApi.vue new file mode 100644 index 000000000..0e8bd37b1 --- /dev/null +++ b/apps/www/src/lib/registry/default/example/AutoFormApi.vue @@ -0,0 +1,45 @@ + + + diff --git a/apps/www/src/lib/registry/default/example/AutoFormArray.vue b/apps/www/src/lib/registry/default/example/AutoFormArray.vue new file mode 100644 index 000000000..8b97ddc4f --- /dev/null +++ b/apps/www/src/lib/registry/default/example/AutoFormArray.vue @@ -0,0 +1,38 @@ + + + diff --git a/apps/www/src/lib/registry/default/example/AutoFormBasic.vue b/apps/www/src/lib/registry/default/example/AutoFormBasic.vue new file mode 100644 index 000000000..0b7d763ac --- /dev/null +++ b/apps/www/src/lib/registry/default/example/AutoFormBasic.vue @@ -0,0 +1,161 @@ + + + diff --git a/apps/www/src/lib/registry/default/example/AutoFormConfirmPassword.vue b/apps/www/src/lib/registry/default/example/AutoFormConfirmPassword.vue new file mode 100644 index 000000000..740824e7e --- /dev/null +++ b/apps/www/src/lib/registry/default/example/AutoFormConfirmPassword.vue @@ -0,0 +1,36 @@ + + + diff --git a/apps/www/src/lib/registry/default/example/AutoFormControlled.vue b/apps/www/src/lib/registry/default/example/AutoFormControlled.vue new file mode 100644 index 000000000..14d002a12 --- /dev/null +++ b/apps/www/src/lib/registry/default/example/AutoFormControlled.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/www/src/lib/registry/default/example/AutoFormDependencies.vue b/apps/www/src/lib/registry/default/example/AutoFormDependencies.vue new file mode 100644 index 000000000..a242d9b02 --- /dev/null +++ b/apps/www/src/lib/registry/default/example/AutoFormDependencies.vue @@ -0,0 +1,72 @@ + + + diff --git a/apps/www/src/lib/registry/default/example/AutoFormInputWithoutLabel.vue b/apps/www/src/lib/registry/default/example/AutoFormInputWithoutLabel.vue new file mode 100644 index 000000000..287df014b --- /dev/null +++ b/apps/www/src/lib/registry/default/example/AutoFormInputWithoutLabel.vue @@ -0,0 +1,44 @@ + + + diff --git a/apps/www/src/lib/registry/default/example/AutoFormSubObject.vue b/apps/www/src/lib/registry/default/example/AutoFormSubObject.vue new file mode 100644 index 000000000..4c6a5a11d --- /dev/null +++ b/apps/www/src/lib/registry/default/example/AutoFormSubObject.vue @@ -0,0 +1,54 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/AutoForm.vue b/apps/www/src/lib/registry/default/ui/auto-form/AutoForm.vue new file mode 100644 index 000000000..3cdf2c89c --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/AutoForm.vue @@ -0,0 +1,105 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/AutoFormField.vue b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormField.vue new file mode 100644 index 000000000..5c31d8b47 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormField.vue @@ -0,0 +1,45 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldArray.vue b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldArray.vue new file mode 100644 index 000000000..7f777bc0f --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldArray.vue @@ -0,0 +1,110 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldBoolean.vue b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldBoolean.vue new file mode 100644 index 000000000..24926a20e --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldBoolean.vue @@ -0,0 +1,41 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldDate.vue b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldDate.vue new file mode 100644 index 000000000..a3489e526 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldDate.vue @@ -0,0 +1,57 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldEnum.vue b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldEnum.vue new file mode 100644 index 000000000..5f7d79222 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldEnum.vue @@ -0,0 +1,50 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldFile.vue b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldFile.vue new file mode 100644 index 000000000..bde54b2cd --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldFile.vue @@ -0,0 +1,74 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldInput.vue b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldInput.vue new file mode 100644 index 000000000..066f0d5c5 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldInput.vue @@ -0,0 +1,36 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldNumber.vue b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldNumber.vue new file mode 100644 index 000000000..7e281e579 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldNumber.vue @@ -0,0 +1,32 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldObject.vue b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldObject.vue new file mode 100644 index 000000000..98d1a5f5d --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormFieldObject.vue @@ -0,0 +1,77 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/AutoFormLabel.vue b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormLabel.vue new file mode 100644 index 000000000..b82e9edb1 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormLabel.vue @@ -0,0 +1,14 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/constant.ts b/apps/www/src/lib/registry/default/ui/auto-form/constant.ts new file mode 100644 index 000000000..33fee6876 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/constant.ts @@ -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', +} diff --git a/apps/www/src/lib/registry/default/ui/auto-form/dependencies.ts b/apps/www/src/lib/registry/default/ui/auto-form/dependencies.ts new file mode 100644 index 000000000..ea761633e --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/dependencies.ts @@ -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>>[] | 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(fieldName) + + if (!form) + throw new Error('useDependencies should be used within ') + + const dependencies = injectDependencies() + const isDisabled = ref(false) + const isHidden = ref(false) + const isRequired = ref(false) + const overrideOptions = ref() + + const currentFieldDependencies = computed(() => dependencies.value?.filter( + dependency => dependency.targetField === currentFieldName, + )) + + function getSourceValue(dep: Dependency) { + 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, + } +} diff --git a/apps/www/src/lib/registry/default/ui/auto-form/index.ts b/apps/www/src/lib/registry/default/ui/auto-form/index.ts new file mode 100644 index 000000000..0fb843840 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/index.ts @@ -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' diff --git a/apps/www/src/lib/registry/default/ui/auto-form/interface.ts b/apps/www/src/lib/registry/default/ui/auto-form/interface.ts new file mode 100644 index 000000000..0d41daf4b --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/interface.ts @@ -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 extends (infer U)[] ? U : never + +export type Config = { + // If SchemaType.key is an object, create a nested Config, otherwise ConfigItem + [Key in keyof SchemaType]?: + SchemaType[Key] extends any[] + ? UnwrapArray> + : SchemaType[Key] extends object + ? Config + : ConfigItem; +} + +export enum DependencyType { + DISABLES, + REQUIRES, + HIDES, + SETS_OPTIONS, +} + +interface BaseDependency>> { + sourceField: keyof SchemaType + type: DependencyType + targetField: keyof SchemaType + when: (sourceFieldValue: any, targetFieldValue: any) => boolean +} + +export type ValueDependency>> = + BaseDependency & { + type: + | DependencyType.DISABLES + | DependencyType.REQUIRES + | DependencyType.HIDES + } + +export type EnumValues = readonly [string, ...string[]] + +export type OptionsDependency< + SchemaType extends z.infer>, +> = BaseDependency & { + type: DependencyType.SETS_OPTIONS + + // Partial array of values from sourceField that will trigger the dependency + options: EnumValues +} + +export type Dependency>> = + | ValueDependency + | OptionsDependency diff --git a/apps/www/src/lib/registry/default/ui/auto-form/utils.ts b/apps/www/src/lib/registry/default/ui/auto-form/utils.ts new file mode 100644 index 000000000..da3d33f22 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/utils.ts @@ -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 + | z.ZodEffects> + +/** + * 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 | 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 { + if (schema?._def.typeName === 'ZodEffects') { + const typedSchema = schema as z.ZodEffects> + return getObjectFormSchema(typedSchema._def.schema) + } + return schema as z.ZodObject +} + +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 | { [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 { + return obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj) +} +function isContainerValue(value: unknown): value is Record { + 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(object: NestedRecord | undefined, path: string): TValue | undefined +export function getFromPath( + object: NestedRecord | undefined, + path: string, + fallback?: TFallback, +): TValue | TFallback +export function getFromPath( + 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 +} diff --git a/apps/www/src/lib/registry/new-york/example/AutoFormApi.vue b/apps/www/src/lib/registry/new-york/example/AutoFormApi.vue new file mode 100644 index 000000000..742b5fb5f --- /dev/null +++ b/apps/www/src/lib/registry/new-york/example/AutoFormApi.vue @@ -0,0 +1,45 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/example/AutoFormArray.vue b/apps/www/src/lib/registry/new-york/example/AutoFormArray.vue new file mode 100644 index 000000000..833256f4a --- /dev/null +++ b/apps/www/src/lib/registry/new-york/example/AutoFormArray.vue @@ -0,0 +1,38 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/example/AutoFormBasic.vue b/apps/www/src/lib/registry/new-york/example/AutoFormBasic.vue new file mode 100644 index 000000000..5ca6ef8bb --- /dev/null +++ b/apps/www/src/lib/registry/new-york/example/AutoFormBasic.vue @@ -0,0 +1,161 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/example/AutoFormConfirmPassword.vue b/apps/www/src/lib/registry/new-york/example/AutoFormConfirmPassword.vue new file mode 100644 index 000000000..9f96b9088 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/example/AutoFormConfirmPassword.vue @@ -0,0 +1,36 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/example/AutoFormControlled.vue b/apps/www/src/lib/registry/new-york/example/AutoFormControlled.vue new file mode 100644 index 000000000..eb3b3acbf --- /dev/null +++ b/apps/www/src/lib/registry/new-york/example/AutoFormControlled.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/example/AutoFormDependencies.vue b/apps/www/src/lib/registry/new-york/example/AutoFormDependencies.vue new file mode 100644 index 000000000..bdd5f5057 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/example/AutoFormDependencies.vue @@ -0,0 +1,72 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/example/AutoFormInputWithoutLabel.vue b/apps/www/src/lib/registry/new-york/example/AutoFormInputWithoutLabel.vue new file mode 100644 index 000000000..67cad8d0b --- /dev/null +++ b/apps/www/src/lib/registry/new-york/example/AutoFormInputWithoutLabel.vue @@ -0,0 +1,44 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/example/AutoFormSubObject.vue b/apps/www/src/lib/registry/new-york/example/AutoFormSubObject.vue new file mode 100644 index 000000000..c83aa2704 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/example/AutoFormSubObject.vue @@ -0,0 +1,54 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/AutoForm.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoForm.vue new file mode 100644 index 000000000..6eb3ce5fc --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoForm.vue @@ -0,0 +1,105 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormField.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormField.vue new file mode 100644 index 000000000..5c31d8b47 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormField.vue @@ -0,0 +1,45 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldArray.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldArray.vue new file mode 100644 index 000000000..f044abb79 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldArray.vue @@ -0,0 +1,110 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldBoolean.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldBoolean.vue new file mode 100644 index 000000000..c7d2cd3be --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldBoolean.vue @@ -0,0 +1,41 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldDate.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldDate.vue new file mode 100644 index 000000000..d4b1874a0 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldDate.vue @@ -0,0 +1,57 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldEnum.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldEnum.vue new file mode 100644 index 000000000..46308736e --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldEnum.vue @@ -0,0 +1,50 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldFile.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldFile.vue new file mode 100644 index 000000000..316b794f1 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldFile.vue @@ -0,0 +1,74 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldInput.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldInput.vue new file mode 100644 index 000000000..43b004364 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldInput.vue @@ -0,0 +1,36 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldNumber.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldNumber.vue new file mode 100644 index 000000000..9589ee7de --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldNumber.vue @@ -0,0 +1,32 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldObject.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldObject.vue new file mode 100644 index 000000000..e38ca8c95 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormFieldObject.vue @@ -0,0 +1,77 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormLabel.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormLabel.vue new file mode 100644 index 000000000..aaddb7c5e --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormLabel.vue @@ -0,0 +1,14 @@ + + + diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/constant.ts b/apps/www/src/lib/registry/new-york/ui/auto-form/constant.ts new file mode 100644 index 000000000..33fee6876 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/constant.ts @@ -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', +} diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/dependencies.ts b/apps/www/src/lib/registry/new-york/ui/auto-form/dependencies.ts new file mode 100644 index 000000000..ea761633e --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/dependencies.ts @@ -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>>[] | 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(fieldName) + + if (!form) + throw new Error('useDependencies should be used within ') + + const dependencies = injectDependencies() + const isDisabled = ref(false) + const isHidden = ref(false) + const isRequired = ref(false) + const overrideOptions = ref() + + const currentFieldDependencies = computed(() => dependencies.value?.filter( + dependency => dependency.targetField === currentFieldName, + )) + + function getSourceValue(dep: Dependency) { + 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, + } +} diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/index.ts b/apps/www/src/lib/registry/new-york/ui/auto-form/index.ts new file mode 100644 index 000000000..0fb843840 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/index.ts @@ -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' diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/interface.ts b/apps/www/src/lib/registry/new-york/ui/auto-form/interface.ts new file mode 100644 index 000000000..0d41daf4b --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/interface.ts @@ -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 extends (infer U)[] ? U : never + +export type Config = { + // If SchemaType.key is an object, create a nested Config, otherwise ConfigItem + [Key in keyof SchemaType]?: + SchemaType[Key] extends any[] + ? UnwrapArray> + : SchemaType[Key] extends object + ? Config + : ConfigItem; +} + +export enum DependencyType { + DISABLES, + REQUIRES, + HIDES, + SETS_OPTIONS, +} + +interface BaseDependency>> { + sourceField: keyof SchemaType + type: DependencyType + targetField: keyof SchemaType + when: (sourceFieldValue: any, targetFieldValue: any) => boolean +} + +export type ValueDependency>> = + BaseDependency & { + type: + | DependencyType.DISABLES + | DependencyType.REQUIRES + | DependencyType.HIDES + } + +export type EnumValues = readonly [string, ...string[]] + +export type OptionsDependency< + SchemaType extends z.infer>, +> = BaseDependency & { + type: DependencyType.SETS_OPTIONS + + // Partial array of values from sourceField that will trigger the dependency + options: EnumValues +} + +export type Dependency>> = + | ValueDependency + | OptionsDependency diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/utils.ts b/apps/www/src/lib/registry/new-york/ui/auto-form/utils.ts new file mode 100644 index 000000000..da3d33f22 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/utils.ts @@ -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 + | z.ZodEffects> + +/** + * 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 | 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 { + if (schema?._def.typeName === 'ZodEffects') { + const typedSchema = schema as z.ZodEffects> + return getObjectFormSchema(typedSchema._def.schema) + } + return schema as z.ZodObject +} + +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 | { [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 { + return obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj) +} +function isContainerValue(value: unknown): value is Record { + 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(object: NestedRecord | undefined, path: string): TValue | undefined +export function getFromPath( + object: NestedRecord | undefined, + path: string, + fallback?: TFallback, +): TValue | TFallback +export function getFromPath( + 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 +} diff --git a/apps/www/src/lib/registry/new-york/ui/form/index.ts b/apps/www/src/lib/registry/new-york/ui/form/index.ts index 30a30a6c2..e711a38eb 100644 --- a/apps/www/src/lib/registry/new-york/ui/form/index.ts +++ b/apps/www/src/lib/registry/new-york/ui/form/index.ts @@ -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' diff --git a/apps/www/src/lib/registry/new-york/ui/form/useFormField.ts b/apps/www/src/lib/registry/new-york/ui/form/useFormField.ts index 73eeee3e7..0aae4207e 100644 --- a/apps/www/src/lib/registry/new-york/ui/form/useFormField.ts +++ b/apps/www/src/lib/registry/new-york/ui/form/useFormField.ts @@ -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(), diff --git a/apps/www/src/public/registry/index.json b/apps/www/src/public/registry/index.json index 01d6aedff..d212a4bb6 100644 --- a/apps/www/src/public/registry/index.json +++ b/apps/www/src/public/registry/index.json @@ -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": [], diff --git a/apps/www/src/public/registry/styles/default/auto-form.json b/apps/www/src/public/registry/styles/default/auto-form.json new file mode 100644 index 000000000..19f1099f5 --- /dev/null +++ b/apps/www/src/public/registry/styles/default/auto-form.json @@ -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": "\n\n\n" + }, + { + "name": "AutoFormField.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormFieldArray.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormFieldBoolean.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormFieldDate.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormFieldEnum.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormFieldFile.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormFieldInput.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormFieldNumber.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormFieldObject.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormLabel.vue", + "content": "\n\n\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>>[] | 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(fieldName)\n\n if (!form)\n throw new Error('useDependencies should be used within ')\n\n const dependencies = injectDependencies()\n const isDisabled = ref(false)\n const isHidden = ref(false)\n const isRequired = ref(false)\n const overrideOptions = ref()\n\n const currentFieldDependencies = computed(() => dependencies.value?.filter(\n dependency => dependency.targetField === currentFieldName,\n ))\n\n function getSourceValue(dep: Dependency) {\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 extends (infer U)[] ? U : never\n\nexport type Config = {\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>\n : SchemaType[Key] extends object\n ? Config\n : ConfigItem;\n}\n\nexport enum DependencyType {\n DISABLES,\n REQUIRES,\n HIDES,\n SETS_OPTIONS,\n}\n\ninterface BaseDependency>> {\n sourceField: keyof SchemaType\n type: DependencyType\n targetField: keyof SchemaType\n when: (sourceFieldValue: any, targetFieldValue: any) => boolean\n}\n\nexport type ValueDependency>> =\n BaseDependency & {\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>,\n> = BaseDependency & {\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>> =\n | ValueDependency\n | OptionsDependency\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\n | z.ZodEffects>\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 | 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 {\n if (schema?._def.typeName === 'ZodEffects') {\n const typedSchema = schema as z.ZodEffects>\n return getObjectFormSchema(typedSchema._def.schema)\n }\n return schema as z.ZodObject\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 | { [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 {\n return obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj)\n}\nfunction isContainerValue(value: unknown): value is Record {\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(object: NestedRecord | undefined, path: string): TValue | undefined\nexport function getFromPath(\n object: NestedRecord | undefined,\n path: string,\n fallback?: TFallback,\n): TValue | TFallback\nexport function getFromPath(\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" +} diff --git a/apps/www/src/public/registry/styles/new-york/auto-form.json b/apps/www/src/public/registry/styles/new-york/auto-form.json new file mode 100644 index 000000000..f14541154 --- /dev/null +++ b/apps/www/src/public/registry/styles/new-york/auto-form.json @@ -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": "\n\n\n" + }, + { + "name": "AutoFormField.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormFieldArray.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormFieldBoolean.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormFieldDate.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormFieldEnum.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormFieldFile.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormFieldInput.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormFieldNumber.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormFieldObject.vue", + "content": "\n\n\n" + }, + { + "name": "AutoFormLabel.vue", + "content": "\n\n\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>>[] | 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(fieldName)\n\n if (!form)\n throw new Error('useDependencies should be used within ')\n\n const dependencies = injectDependencies()\n const isDisabled = ref(false)\n const isHidden = ref(false)\n const isRequired = ref(false)\n const overrideOptions = ref()\n\n const currentFieldDependencies = computed(() => dependencies.value?.filter(\n dependency => dependency.targetField === currentFieldName,\n ))\n\n function getSourceValue(dep: Dependency) {\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 extends (infer U)[] ? U : never\n\nexport type Config = {\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>\n : SchemaType[Key] extends object\n ? Config\n : ConfigItem;\n}\n\nexport enum DependencyType {\n DISABLES,\n REQUIRES,\n HIDES,\n SETS_OPTIONS,\n}\n\ninterface BaseDependency>> {\n sourceField: keyof SchemaType\n type: DependencyType\n targetField: keyof SchemaType\n when: (sourceFieldValue: any, targetFieldValue: any) => boolean\n}\n\nexport type ValueDependency>> =\n BaseDependency & {\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>,\n> = BaseDependency & {\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>> =\n | ValueDependency\n | OptionsDependency\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\n | z.ZodEffects>\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 | 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 {\n if (schema?._def.typeName === 'ZodEffects') {\n const typedSchema = schema as z.ZodEffects>\n return getObjectFormSchema(typedSchema._def.schema)\n }\n return schema as z.ZodObject\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 | { [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 {\n return obj !== null && !!obj && typeof obj === 'object' && !Array.isArray(obj)\n}\nfunction isContainerValue(value: unknown): value is Record {\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(object: NestedRecord | undefined, path: string): TValue | undefined\nexport function getFromPath(\n object: NestedRecord | undefined,\n path: string,\n fallback?: TFallback,\n): TValue | TFallback\nexport function getFromPath(\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" +} diff --git a/apps/www/src/public/registry/styles/new-york/form.json b/apps/www/src/public/registry/styles/new-york/form.json index 2c69a3a5f..e89835087 100644 --- a/apps/www/src/public/registry/styles/new-york/form.json +++ b/apps/www/src/public/registry/styles/new-york/form.json @@ -32,12 +32,12 @@ }, { "name": "index.ts", - "content": "export { Form, Field as FormField } from 'vee-validate'\nexport { default as FormItem } from './FormItem.vue'\nexport { default as FormLabel } from './FormLabel.vue'\nexport { default as FormControl } from './FormControl.vue'\nexport { default as FormMessage } from './FormMessage.vue'\nexport { default as FormDescription } from './FormDescription.vue'\n" + "content": "export { Form, Field as FormField, FieldArray as FormFieldArray } from 'vee-validate'\nexport { default as FormItem } from './FormItem.vue'\nexport { default as FormLabel } from './FormLabel.vue'\nexport { default as FormControl } from './FormControl.vue'\nexport { default as FormMessage } from './FormMessage.vue'\nexport { default as FormDescription } from './FormDescription.vue'\n" }, { "name": "useFormField.ts", - "content": "import { FieldContextKey, useFieldError, useIsFieldDirty, useIsFieldTouched, useIsFieldValid } from 'vee-validate'\nimport { inject } from 'vue'\nimport { FORM_ITEM_INJECTION_KEY } from './FormItem.vue'\n\nexport function useFormField() {\n const fieldContext = inject(FieldContextKey)\n const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY)\n\n const fieldState = {\n valid: useIsFieldValid(),\n isDirty: useIsFieldDirty(),\n isTouched: useIsFieldTouched(),\n error: useFieldError(),\n }\n\n if (!fieldContext)\n throw new Error('useFormField should be used within ')\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 ')\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" -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3861f330..c451da072 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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