diff --git a/apps/www/.vitepress/theme/config/docs.ts b/apps/www/.vitepress/theme/config/docs.ts index 71fce55a..af1da0bd 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: 'Auto Form', + items: [ + { + title: 'Test', + href: '/docs/components/auto-form', + items: [], + }, + ], + }, { title: 'Components', items: [ diff --git a/apps/www/__registry__/index.ts b/apps/www/__registry__/index.ts index 6d6a5395..95423f44 100644 --- a/apps/www/__registry__/index.ts +++ b/apps/www/__registry__/index.ts @@ -38,6 +38,13 @@ export const Index = { component: () => import("../src/lib/registry/default/example/AspectRatioDemo.vue").then((m) => m.default), files: ["../src/lib/registry/default/example/AspectRatioDemo.vue"], }, + "AutoForm": { + name: "AutoForm", + type: "components:example", + registryDependencies: ["button","form","input","toast"], + component: () => import("../src/lib/registry/default/example/AutoForm.vue").then((m) => m.default), + files: ["../src/lib/registry/default/example/AutoForm.vue"], + }, "AvatarDemo": { name: "AvatarDemo", type: "components:example", @@ -1278,6 +1285,13 @@ export const Index = { component: () => import("../src/lib/registry/new-york/example/AspectRatioDemo.vue").then((m) => m.default), files: ["../src/lib/registry/new-york/example/AspectRatioDemo.vue"], }, + "AutoForm": { + name: "AutoForm", + type: "components:example", + registryDependencies: ["button","form","input","toast"], + component: () => import("../src/lib/registry/new-york/example/AutoForm.vue").then((m) => m.default), + files: ["../src/lib/registry/new-york/example/AutoForm.vue"], + }, "AvatarDemo": { name: "AvatarDemo", type: "components:example", 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 00000000..62909ac0 --- /dev/null +++ b/apps/www/src/content/docs/components/auto-form.md @@ -0,0 +1,7 @@ +--- +title: Auto Form +description: Building forms with VeeValidate and Zod. +primitive: https://vee-validate.logaretm.com/v4/guide/overview/ +--- + + diff --git a/apps/www/src/lib/registry/default/example/AutoForm.vue b/apps/www/src/lib/registry/default/example/AutoForm.vue new file mode 100644 index 00000000..f0d28536 --- /dev/null +++ b/apps/www/src/lib/registry/default/example/AutoForm.vue @@ -0,0 +1,53 @@ + + + + + + + Username + + + + + This is your public display name. + + + + + + Submit + + + diff --git a/apps/www/src/lib/registry/new-york/example/AutoForm.vue b/apps/www/src/lib/registry/new-york/example/AutoForm.vue new file mode 100644 index 00000000..52925b3b --- /dev/null +++ b/apps/www/src/lib/registry/new-york/example/AutoForm.vue @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + Submit + + + 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 00000000..0fd3cb65 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoForm.vue @@ -0,0 +1,63 @@ + + + + + + + + + + + + + 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 00000000..7fd847a2 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormField.vue @@ -0,0 +1,62 @@ + + + + + + + + + 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 00000000..aaddb7c5 --- /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/AutoFormObject.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormObject.vue new file mode 100644 index 00000000..b8525d60 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/AutoFormObject.vue @@ -0,0 +1,6 @@ + + + + obkect + 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 00000000..f35a2a0d --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/constant.ts @@ -0,0 +1,31 @@ +import FieldNumber from './fields/Number.vue' +import FieldInput from './fields/Input.vue' +import FieldBoolean from './fields/Boolean.vue' +import FieldDate from './fields/Date.vue' +import FieldEnum from './fields/Enum.vue' + +export const INPUT_COMPONENTS = { + date: FieldDate, + select: FieldEnum, + radio: FieldEnum, + checkbox: FieldBoolean, + switch: FieldBoolean, + textarea: FieldInput, + number: FieldNumber, + string: FieldInput, +} + +/** + * Define handlers for specific Zod types. + * You can expand this object to support more types. + */ +export const DEFAULT_ZOD_HANDLERS: { + [key: string]: keyof typeof INPUT_COMPONENTS +} = { + ZodString: 'string', + ZodBoolean: 'checkbox', + ZodDate: 'date', + ZodEnum: 'select', + ZodNativeEnum: 'select', + ZodNumber: 'number', +} diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/fields/Boolean.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/fields/Boolean.vue new file mode 100644 index 00000000..d1ad1a9d --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/fields/Boolean.vue @@ -0,0 +1,35 @@ + + + + + + + + + + + + {{ config?.label || beautifyObjectName(name) }} + + + + + {{ config.description }} + + + + + diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/fields/Date.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/fields/Date.vue new file mode 100644 index 00000000..2127fdbf --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/fields/Date.vue @@ -0,0 +1,59 @@ + + + + + + + {{ config?.label || beautifyObjectName(name) }} + + + + + + + + {{ componentField.modelValue ? df.format(componentField.modelValue.toDate(getLocalTimeZone())) : "Pick a date" }} + + + + + + + + + + + {{ config.description }} + + + + + diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/fields/Enum.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/fields/Enum.vue new file mode 100644 index 00000000..2489f6c8 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/fields/Enum.vue @@ -0,0 +1,53 @@ + + + + + + + {{ config?.label || beautifyObjectName(name) }} + + + + + + {{ beautifyObjectName(option) }} + + + + + + + + + + {{ beautifyObjectName(option) }} + + + + + + + {{ config.description }} + + + + + diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/fields/Input.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/fields/Input.vue new file mode 100644 index 00000000..9e400265 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/fields/Input.vue @@ -0,0 +1,36 @@ + + + + + + + {{ config?.label || beautifyObjectName(name) }} + + + + + + + {{ config.description }} + + + + + diff --git a/apps/www/src/lib/registry/new-york/ui/auto-form/fields/Number.vue b/apps/www/src/lib/registry/new-york/ui/auto-form/fields/Number.vue new file mode 100644 index 00000000..e292c598 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/fields/Number.vue @@ -0,0 +1,34 @@ + + + + + + + {{ config?.label || beautifyObjectName(name) }} + + + + + + {{ config.description }} + + + + + 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 00000000..6d3ca863 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/index.ts @@ -0,0 +1,2 @@ +export { default as AutoForm } from './AutoForm.vue' +export { getObjectFormSchema, getBaseSchema, getBaseType, getDefaultValues } from './utils' 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 00000000..139152e0 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/interface.ts @@ -0,0 +1,55 @@ +import type { InputHTMLAttributes, SelectHTMLAttributes } from 'vue' +import type * as z from 'zod' +import type { INPUT_COMPONENTS } from './constant' + +export interface Config { + label?: string + description?: string + component?: keyof typeof INPUT_COMPONENTS + inputProps?: InputHTMLAttributes + enumProps?: SelectHTMLAttributes & { options?: any[] } +} + +export type FieldConfig>> = { + // If SchemaType.key is an object, create a nested FieldConfig, otherwise FieldConfigItem + [Key in keyof SchemaType]?: SchemaType[Key] extends object + ? FieldConfig> + : FieldConfig> // TODO; +} + +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 00000000..d2b2e605 --- /dev/null +++ b/apps/www/src/lib/registry/new-york/ui/auto-form/utils.ts @@ -0,0 +1,129 @@ +import { ZodFirstPartyTypeKind, type z } from 'zod' +import type { FieldConfig } from './interface' + +// TODO: This should support recursive ZodEffects but TypeScript doesn't allow circular type definitions. +export type ZodObjectOrWrapped = + | z.ZodObject + | z.ZodEffects> + +/** + * Beautify a camelCase string. + * e.g. "myString" -> "My String" + */ +export function beautifyObjectName(string: string) { + // if numbers only return the string + let output = string.replace(/([A-Z])/g, ' $1') + output = output.charAt(0).toUpperCase() + output.slice(1) + return output +} + +/** + * Get the lowest level Zod type. + * This will unpack optionals, refinements, etc. + */ +export function getBaseSchema< + ChildType extends z.ZodAny | z.AnyZodObject = z.ZodAny, +>(schema: ChildType | z.ZodEffects): ChildType | null { + if (!schema) + return null + if ('innerType' in schema._def) + return getBaseSchema(schema._def.innerType as ChildType) + + if ('schema' in schema._def) + return getBaseSchema(schema._def.schema as ChildType) + + return schema as ChildType +} + +/** + * Get the type name of the lowest level Zod type. + * This will unpack optionals, refinements, etc. + */ +export function getBaseType(schema: z.ZodAny) { + const baseSchema = getBaseSchema(schema) + return baseSchema ? baseSchema._def.typeName : '' +} + +/** + * Search for a "ZodDefault" in the Zod stack and return its value. + */ +export function getDefaultValueInZodStack(schema: z.ZodAny): any { + const typedSchema = schema as unknown as z.ZodDefault< + z.ZodNumber | z.ZodString + > + + if (typedSchema._def.typeName === 'ZodDefault') + return typedSchema._def.defaultValue() + + if ('innerType' in typedSchema._def) { + return getDefaultValueInZodStack( + typedSchema._def.innerType as unknown as z.ZodAny, + ) + } + if ('schema' in typedSchema._def) { + return getDefaultValueInZodStack( + (typedSchema._def as any).schema as z.ZodAny, + ) + } + + return undefined +} + +/** + * Get all default values from a Zod schema. + */ +export function getDefaultValues>( + schema: Schema, + fieldConfig?: FieldConfig>, +) { + if (!schema) + return null + const { shape } = schema + type DefaultValuesType = Partial> + const defaultValues = {} as DefaultValuesType + if (!shape) + return defaultValues + + for (const key of Object.keys(shape)) { + const item = shape[key] as z.ZodAny + + // @ts-expect-error weird + if (getBaseType(item) === ZodFirstPartyTypeKind.ZodObject) { + const defaultItems = getDefaultValues( + getBaseSchema(item) as unknown as z.ZodObject, + fieldConfig?.[key] as FieldConfig>, + ) + + if (defaultItems !== null) { + for (const defaultItemKey of Object.keys(defaultItems)) { + const pathKey = `${key}.${defaultItemKey}` as keyof DefaultValuesType + defaultValues[pathKey] = defaultItems[defaultItemKey] + } + } + } + else { + let defaultValue = getDefaultValueInZodStack(item) + if ( + (defaultValue === null || defaultValue === '') + && fieldConfig?.[key]?.inputProps + ) { + defaultValue = (fieldConfig?.[key]?.inputProps as unknown as any) + .defaultValue + } + if (defaultValue !== undefined) + defaultValues[key as keyof DefaultValuesType] = defaultValue + } + } + + return defaultValues +} + +export function getObjectFormSchema( + schema: ZodObjectOrWrapped, +): z.ZodObject { + if (schema?._def.typeName === 'ZodEffects') { + const typedSchema = schema as z.ZodEffects> + return getObjectFormSchema(typedSchema._def.schema) + } + return schema as z.ZodObject +}