/** * Schema Fields — JSON Schema ↔ FormField conversion utilities * * Provides bidirectional conversion between JSON Schema (with x- layout extensions) * and the FormField representation used by form renderers and builders. */ import { computed, type Ref } from 'vue' // ─── Field Types ────────────────────────────────────────────────────────────── export type FormFieldType = | 'text' | 'textarea' | 'richtext' | 'markdown' | 'number' | 'date' | 'email' | 'phone' | 'url' | 'select' | 'radio' | 'checkbox' | 'toggle' | 'file' | 'signature' | 'range' | 'color' | 'json' | 'heading' | 'divider' export interface SelectOption { label: string value: string } export interface FormFieldValidation { minLength?: number maxLength?: number min?: number max?: number step?: number pattern?: string options?: SelectOption[] } export interface FormField { id: string key: string type: FormFieldType label: string placeholder?: string description?: string required?: boolean colSpan?: number colStart?: number rowSpan?: number rowStart?: number mColSpan?: number mColStart?: number mRowSpan?: number mRowStart?: number hideLabel?: boolean richTextVariant?: 'full' | 'basic' | 'simple' markdownShowFormatting?: boolean textareaRows?: number textareaAutoHeight?: boolean numberLayout?: 'default' | 'vertical' | 'horizontal' numberSpinner?: boolean phoneOnlyCountries?: string selectMultiselect?: boolean fileMultiple?: boolean fileHeight?: number fileAccept?: string fileTheme?: 'dropzone' | 'basic' fileShowIcon?: boolean fileIcon?: string dateEnableTime?: boolean dateMin?: string dateMax?: string rangeMulti?: boolean radioThin?: boolean radioHideRadio?: boolean radioAlign?: 'start' | 'center' | 'end' textIcon?: string textIconStart?: string validation?: FormFieldValidation defaultValue?: any content?: string } export interface JSONSchemaProperty { type?: string title?: string description?: string format?: string enum?: any[] minimum?: number maximum?: number minLength?: number maxLength?: number pattern?: string multipleOf?: number default?: any 'x-col-span'?: number 'x-col-start'?: number 'x-row-span'?: number 'x-row-start'?: number 'x-m-col-span'?: number 'x-m-col-start'?: number 'x-m-row-span'?: number 'x-m-row-start'?: number 'x-field-type'?: FormFieldType 'x-placeholder'?: string 'x-options'?: SelectOption[] 'x-rich-text-variant'?: 'full' | 'basic' | 'simple' 'x-textarea-rows'?: number 'x-textarea-autoheight'?: boolean 'x-number-layout'?: 'default' | 'vertical' | 'horizontal' 'x-number-spinner'?: boolean 'x-phone-only-countries'?: string 'x-select-multiselect'?: boolean 'x-file-multiple'?: boolean 'x-file-height'?: number 'x-file-accept'?: string 'x-file-theme'?: 'dropzone' | 'basic' 'x-file-show-icon'?: boolean 'x-file-icon'?: string 'x-date-enable-time'?: boolean 'x-date-min'?: string 'x-date-max'?: string 'x-range-multi'?: boolean 'x-radio-thin'?: boolean 'x-radio-hide-radio'?: boolean 'x-radio-align'?: 'start' | 'center' | 'end' 'x-text-icon'?: string 'x-text-icon-start'?: string 'x-hide-label'?: boolean 'x-markdown-show-formatting'?: boolean } export interface JSONSchemaObject { $schema?: string type: 'object' properties?: Record required?: string[] 'x-dynamic-height'?: boolean } // ─── Helpers ────────────────────────────────────────────────────────────────── export function defaultRowSpan(type: FormFieldType): number { if (type === 'richtext') return 3 if (type === 'textarea' || type === 'json' || type === 'radio' || type === 'file' || type === 'signature') return 2 return 1 } export function inferFieldType(prop: JSONSchemaProperty): FormFieldType { if (prop.format) { switch (prop.format) { case 'email': return 'email' case 'tel': return 'phone' case 'uri': case 'url': return 'url' case 'date': case 'date-time': return 'date' } } if (prop.enum) return 'select' switch (prop.type) { case 'number': case 'integer': return 'number' case 'boolean': return 'checkbox' } return 'text' } // ─── Schema → Fields ───────────────────────────────────────────────────────── export function schemaToFields(schema: JSONSchemaObject | undefined): FormField[] { if (!schema?.properties) return [] const requiredSet = new Set(schema.required ?? []) const fields: FormField[] = [] for (const [key, prop] of Object.entries(schema.properties)) { const type = prop['x-field-type'] ?? inferFieldType(prop) const validation: FormFieldValidation = {} if (prop.minLength !== undefined) validation.minLength = prop.minLength if (prop.maxLength !== undefined) validation.maxLength = prop.maxLength if (prop.minimum !== undefined) validation.min = prop.minimum if (prop.maximum !== undefined) validation.max = prop.maximum if (prop.multipleOf !== undefined) validation.step = prop.multipleOf if (prop.pattern !== undefined) validation.pattern = prop.pattern if (prop['x-options']) { validation.options = prop['x-options'] } else if (prop.enum) { validation.options = prop.enum.map((v: any) => ({ label: String(v), value: String(v) })) } const field: FormField = { id: crypto.randomUUID(), key, type, label: prop.title ?? key, placeholder: prop['x-placeholder'], description: prop.description, required: requiredSet.has(key) || undefined, colSpan: prop['x-col-span'] ?? 12, colStart: prop['x-col-start'], rowSpan: prop['x-row-span'] ?? defaultRowSpan(type), rowStart: prop['x-row-start'], mColSpan: prop['x-m-col-span'] ?? 12, mColStart: prop['x-m-col-start'], mRowSpan: prop['x-m-row-span'], mRowStart: prop['x-m-row-start'], hideLabel: prop['x-hide-label'] ?? false, richTextVariant: prop['x-rich-text-variant'], markdownShowFormatting: prop['x-markdown-show-formatting'], textareaRows: prop['x-textarea-rows'], textareaAutoHeight: prop['x-textarea-autoheight'], numberLayout: prop['x-number-layout'], numberSpinner: prop['x-number-spinner'] ?? true, phoneOnlyCountries: prop['x-phone-only-countries'], selectMultiselect: prop['x-select-multiselect'], fileMultiple: prop['x-file-multiple'], fileHeight: prop['x-file-height'], fileAccept: prop['x-file-accept'], fileTheme: prop['x-file-theme'], fileShowIcon: prop['x-file-show-icon'] === false ? false : undefined, fileIcon: prop['x-file-icon'], dateEnableTime: prop['x-date-enable-time'], dateMin: prop['x-date-min'], dateMax: prop['x-date-max'], rangeMulti: prop['x-range-multi'], radioThin: prop['x-radio-thin'], radioHideRadio: prop['x-radio-hide-radio'], radioAlign: prop['x-radio-align'], textIcon: prop['x-text-icon'], textIconStart: prop['x-text-icon-start'], validation: Object.keys(validation).length > 0 ? validation : undefined, defaultValue: prop.default, content: prop['x-field-type'] === 'heading' ? prop.title : undefined, } fields.push(field) } return fields } // ─── Fields → Schema ───────────────────────────────────────────────────────── export function fieldsToSchema(fields: FormField[], options?: { dynamicHeight?: boolean }): JSONSchemaObject { const properties: Record = {} const required: string[] = [] for (const field of fields) { const prop: JSONSchemaProperty = {} // Layout-only fields if (field.type === 'heading' || field.type === 'divider') { prop.type = 'string' prop.title = field.content || field.label prop['x-field-type'] = field.type } else { // Map field type to JSON Schema type switch (field.type) { case 'text': case 'textarea': case 'richtext': case 'markdown': case 'email': case 'phone': case 'url': case 'date': case 'file': case 'signature': case 'color': prop.type = 'string' break case 'number': case 'range': prop.type = 'number' break case 'checkbox': case 'toggle': prop.type = 'boolean' break case 'json': prop.type = 'object' break case 'select': case 'radio': prop.type = 'string' if (field.validation?.options) { prop.enum = field.validation.options.map(o => o.value) } break } // Format hints if (field.type === 'email') prop.format = 'email' if (field.type === 'phone') prop.format = 'tel' if (field.type === 'url') prop.format = 'uri' if (field.type === 'date') prop.format = field.dateEnableTime ? 'date-time' : 'date' if (field.type === 'color') prop.format = 'color' // Title & description prop.title = field.label if (field.description) prop.description = field.description if (field.defaultValue !== undefined) prop.default = field.defaultValue // x-field-type (skip for types that can be inferred) if (field.type !== 'text') { prop['x-field-type'] = field.type } // Validation constraints if (field.validation?.minLength !== undefined) prop.minLength = field.validation.minLength if (field.validation?.maxLength !== undefined) prop.maxLength = field.validation.maxLength if (field.validation?.min !== undefined) prop.minimum = field.validation.min if (field.validation?.max !== undefined) prop.maximum = field.validation.max if (field.validation?.step !== undefined) prop.multipleOf = field.validation.step if (field.validation?.pattern !== undefined) prop.pattern = field.validation.pattern // Required if (field.required) required.push(field.key) } // Placeholder if (field.placeholder) prop['x-placeholder'] = field.placeholder // Options if (field.validation?.options) prop['x-options'] = field.validation.options // Layout extensions — only write when non-default if (field.colSpan !== undefined && field.colSpan !== 12) prop['x-col-span'] = field.colSpan if (field.colStart !== undefined) prop['x-col-start'] = field.colStart if (field.rowSpan !== undefined && field.rowSpan !== defaultRowSpan(field.type)) prop['x-row-span'] = field.rowSpan if (field.rowStart !== undefined) prop['x-row-start'] = field.rowStart if (field.mColSpan !== undefined && field.mColSpan !== 12) prop['x-m-col-span'] = field.mColSpan if (field.mColStart !== undefined) prop['x-m-col-start'] = field.mColStart if (field.mRowSpan !== undefined) prop['x-m-row-span'] = field.mRowSpan if (field.mRowStart !== undefined) prop['x-m-row-start'] = field.mRowStart // Boolean/enum x- extensions — only write when non-default if (field.hideLabel === true) prop['x-hide-label'] = true if (field.richTextVariant) prop['x-rich-text-variant'] = field.richTextVariant if (field.markdownShowFormatting === true) prop['x-markdown-show-formatting'] = true if (field.textareaRows !== undefined) prop['x-textarea-rows'] = field.textareaRows if (field.textareaAutoHeight === true) prop['x-textarea-autoheight'] = true if (field.numberLayout && field.numberLayout !== 'default') prop['x-number-layout'] = field.numberLayout if (field.numberSpinner === false) prop['x-number-spinner'] = false if (field.phoneOnlyCountries) prop['x-phone-only-countries'] = field.phoneOnlyCountries if (field.selectMultiselect === true) prop['x-select-multiselect'] = true if (field.fileMultiple === true) prop['x-file-multiple'] = true if (field.fileHeight !== undefined) prop['x-file-height'] = field.fileHeight if (field.fileAccept) prop['x-file-accept'] = field.fileAccept if (field.fileTheme) prop['x-file-theme'] = field.fileTheme if (field.fileShowIcon === false) prop['x-file-show-icon'] = false if (field.fileIcon) prop['x-file-icon'] = field.fileIcon if (field.dateEnableTime === true) prop['x-date-enable-time'] = true if (field.dateMin) prop['x-date-min'] = field.dateMin if (field.dateMax) prop['x-date-max'] = field.dateMax if (field.rangeMulti === true) prop['x-range-multi'] = true if (field.radioThin === true) prop['x-radio-thin'] = true if (field.radioHideRadio === true) prop['x-radio-hide-radio'] = true if (field.radioAlign) prop['x-radio-align'] = field.radioAlign if (field.textIcon) prop['x-text-icon'] = field.textIcon if (field.textIconStart) prop['x-text-icon-start'] = field.textIconStart properties[field.key] = prop } const schema: JSONSchemaObject = { $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', properties, } if (required.length > 0) schema.required = required if (options?.dynamicHeight) schema['x-dynamic-height'] = true return schema } // ─── Form Data Utilities ───────────────────────────────────────────────────── export function initFormData(fields: FormField[], existing?: Record): Record { const data: Record = {} for (const field of fields) { if (field.type === 'heading' || field.type === 'divider') continue if (existing && field.key in existing) { data[field.key] = existing[field.key] continue } if (field.defaultValue !== undefined) { data[field.key] = field.defaultValue continue } switch (field.type) { case 'checkbox': case 'toggle': data[field.key] = false break case 'number': case 'range': data[field.key] = field.validation?.min ?? 0 break default: data[field.key] = '' } } return data } export function validateFormData(fields: FormField[], data: Record): Record { const errors: Record = {} for (const field of fields) { if (field.type === 'heading' || field.type === 'divider') continue const value = data[field.key] // Required check if (field.required && (value === undefined || value === null || value === '')) { errors[field.key] = `${field.label} is required` continue } // Skip further validation if empty and not required if (value === undefined || value === null || value === '') continue const v = field.validation // String length checks if (typeof value === 'string') { if (v?.minLength !== undefined && value.length < v.minLength) { errors[field.key] = `${field.label} must be at least ${v.minLength} characters` continue } if (v?.maxLength !== undefined && value.length > v.maxLength) { errors[field.key] = `${field.label} must be at most ${v.maxLength} characters` continue } if (v?.pattern) { const regex = new RegExp(v.pattern) if (!regex.test(value)) { errors[field.key] = `${field.label} format is invalid` continue } } } // Number range checks if (typeof value === 'number') { if (v?.min !== undefined && value < v.min) { errors[field.key] = `${field.label} must be at least ${v.min}` continue } if (v?.max !== undefined && value > v.max) { errors[field.key] = `${field.label} must be at most ${v.max}` continue } } } return errors } // ─── Vue Composable ────────────────────────────────────────────────────────── export function useSchemaToFields(schema: Ref) { const fields = computed(() => schemaToFields(schema.value)) return { fields } }