diff --git a/apps/www/src/lib/registry/default/example/AutoForm.vue b/apps/www/src/lib/registry/default/example/AutoForm.vue index f0d28536..0b7d763a 100644 --- a/apps/www/src/lib/registry/default/example/AutoForm.vue +++ b/apps/www/src/lib/registry/default/example/AutoForm.vue @@ -1,53 +1,161 @@ 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 00000000..7da60774 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/AutoForm.vue @@ -0,0 +1,104 @@ + + + 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 00000000..950e005a --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/AutoFormField.vue @@ -0,0 +1,42 @@ + + + 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 00000000..b82e9edb --- /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 00000000..7a9026ce --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/constant.ts @@ -0,0 +1,39 @@ +import FieldArray from './fields/Array.vue' +import FieldBoolean from './fields/Boolean.vue' +import FieldDate from './fields/Date.vue' +import FieldEnum from './fields/Enum.vue' +import FieldFile from './fields/File.vue' +import FieldInput from './fields/Input.vue' +import FieldNumber from './fields/Number.vue' +import FieldObject from './fields/Object.vue' + +export const INPUT_COMPONENTS = { + date: FieldDate, + select: FieldEnum, + radio: FieldEnum, + checkbox: FieldBoolean, + switch: FieldBoolean, + textarea: FieldInput, + number: FieldNumber, + string: FieldInput, + file: FieldFile, + array: FieldArray, + object: FieldObject, +} + +/** + * 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 00000000..28791a6e --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/dependencies.ts @@ -0,0 +1,110 @@ +import type * as z from 'zod' +import type { Ref } from 'vue' +import { computed, inject, ref, watch } from 'vue' +import { FieldContextKey, FormContextKey } from 'vee-validate' +import { createContext } from 'radix-vue' +import { type Dependency, DependencyType, type EnumValues } from './interface' +import { getIndexIfArray } from './utils' + +export const [injectDependencies, provideDependencies] = createContext>>[] | undefined>>('AutoFormDependencies') + +function getValueByPath>(obj: T, path: string): any { + const keys = path.split('.') + let value = obj + + for (const key of keys) { + if (value && typeof value === 'object' && key in value) + value = value[key] + else + return undefined + } + + return value +} + +export default function useDependencies( + fieldName: string, +) { + const form = inject(FormContextKey) + const field = inject(FieldContextKey) + + const currentFieldName = fieldName.replace(/\[\d+\]/g, '') + + if (!form) + throw new Error('useDependencies should be used within ') + + const { controlledValues } = form + const dependencies = injectDependencies() + const isDisabled = ref(false) + const isHidden = ref(false) + const isRequired = ref(false) + const overrideOptions = ref() + + const currentFieldValue = computed(() => field?.value.value) + const currentFieldDependencies = computed(() => dependencies.value?.filter( + dependency => dependency.targetField === currentFieldName, + )) + + function getSourceValue(dep: Dependency) { + const source = dep.sourceField as string + const lastKey = source.split('.').at(-1) + if (source.includes('.') && lastKey) { + if (Array.isArray(field?.value.value)) { + const index = getIndexIfArray(fieldName) ?? -1 + return field?.value.value[index][lastKey] + } + + return getValueByPath(form!.values, source) + } + + return controlledValues.value[source as string] + } + + 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/fields/Array.vue b/apps/www/src/lib/registry/default/ui/auto-form/fields/Array.vue new file mode 100644 index 00000000..76cf7b73 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/fields/Array.vue @@ -0,0 +1,109 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/fields/Boolean.vue b/apps/www/src/lib/registry/default/ui/auto-form/fields/Boolean.vue new file mode 100644 index 00000000..2ff22919 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/fields/Boolean.vue @@ -0,0 +1,41 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/fields/Date.vue b/apps/www/src/lib/registry/default/ui/auto-form/fields/Date.vue new file mode 100644 index 00000000..b6f6c0c3 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/fields/Date.vue @@ -0,0 +1,57 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/fields/Enum.vue b/apps/www/src/lib/registry/default/ui/auto-form/fields/Enum.vue new file mode 100644 index 00000000..ff2646b8 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/fields/Enum.vue @@ -0,0 +1,52 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/fields/File.vue b/apps/www/src/lib/registry/default/ui/auto-form/fields/File.vue new file mode 100644 index 00000000..fec26d0e --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/fields/File.vue @@ -0,0 +1,74 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/fields/Input.vue b/apps/www/src/lib/registry/default/ui/auto-form/fields/Input.vue new file mode 100644 index 00000000..cde3a0cf --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/fields/Input.vue @@ -0,0 +1,36 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/fields/Number.vue b/apps/www/src/lib/registry/default/ui/auto-form/fields/Number.vue new file mode 100644 index 00000000..70068d14 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/fields/Number.vue @@ -0,0 +1,32 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/fields/Object.vue b/apps/www/src/lib/registry/default/ui/auto-form/fields/Object.vue new file mode 100644 index 00000000..c6ecec8d --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/fields/Object.vue @@ -0,0 +1,66 @@ + + + diff --git a/apps/www/src/lib/registry/default/ui/auto-form/fields/index.ts b/apps/www/src/lib/registry/default/ui/auto-form/fields/index.ts new file mode 100644 index 00000000..8d37d9d2 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/fields/index.ts @@ -0,0 +1,8 @@ +export { default as AutoFormFieldArray } from './Array.vue' +export { default as AutoFormFieldBoolean } from './Boolean.vue' +export { default as AutoFormFieldDate } from './Date.vue' +export { default as AutoFormFieldEnum } from './Enum.vue' +export { default as AutoFormFieldFile } from './File.vue' +export { default as AutoFormFieldInput } from './Input.vue' +export { default as AutoFormFieldNumber } from './Number.vue' +export { default as AutoFormFieldObject } from './Object.vue' 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 00000000..68f12817 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/index.ts @@ -0,0 +1,5 @@ +export { default as AutoForm } from './AutoForm.vue' +export { default as AutoFormField } from './AutoFormField.vue' +export { getObjectFormSchema, getBaseSchema, getBaseType } from './utils' +export type { Config, ConfigItem } from './interface' +export * from './fields' 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 00000000..79e281c9 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/interface.ts @@ -0,0 +1,82 @@ +import type { 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 + /** Hide `FormLabel`. */ + hideLabel?: boolean + inputProps?: InputHTMLAttributes + enumProps?: SelectHTMLAttributes & { options?: any[] } +} + +// 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 00000000..0754ee84 --- /dev/null +++ b/apps/www/src/lib/registry/default/ui/auto-form/utils.ts @@ -0,0 +1,94 @@ +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 +}