import type { VNode } from 'vue' import type { Field, Attributes, Path, SchemaChild, BaseBagelField, VNodeFn } from '../types/BagelForm' import { TextInput, NumberInput, SelectInput, ToggleInput, CheckInput, RichText, UploadInput, DateInput, TabsNav, bindAttrs, classify, keyToLabel, TelInput, ColorInput, RangeInput, EmailInput, getNestedValue } from '@bagelink/vue' import { h, isVNode } from 'vue' const SLOT_VALUE_COMPONENTS = new Set(['div', 'span', 'p']) const SRC_VALUE_COMPONENTS = new Set(['img', 'iframe']) export interface UseSchemaFieldOptions> { mode?: 'form' | 'preview' | 'table' getFormData?: () => T onUpdateModelValue?: (field: BaseBagelField, value: any) => void includeUnset?: boolean } export function useSchemaField(optns: UseSchemaFieldOptions>) { const { mode = 'form', getFormData, onUpdateModelValue, includeUnset = false } = optns // Helper function to render objects recursively function renderObject(obj: T, depth = 0): string { if (obj === null || obj === undefined) { return '' } if (typeof obj !== 'object') { return String(obj) } if (Array.isArray(obj)) { return obj.map(item => renderObject(item, depth + 1)).join(', ') } // For objects, format as key: value pairs const indent = depth > 0 ? ' '.repeat(depth) : '' const nextIndent = ' '.repeat(depth + 1) const entries = Object.entries(obj) if (entries.length === 0) { return '{}' } // For nested objects, format with newlines and indentation if (depth > 0) { return `{\n${entries .map(([key, value]) => `${nextIndent}${key}: ${renderObject(value, depth + 1)}`) .join(',\n')}\n${indent}}` } // For top-level objects, format as a flat list return entries .map(([key, value]) => { const valueStr = typeof value === 'object' && value !== null ? renderObject(value, depth + 1) : String(value) return `${key}: ${valueStr}` }) .join('\n') } function getComponent(field: Field) { const componentMap = { text: TextInput, textarea: TextInput, number: NumberInput, array: 'div', color: ColorInput, tel: TelInput, select: SelectInput, toggle: ToggleInput, check: CheckInput, richtext: RichText, upload: UploadInput, file: UploadInput, date: DateInput, tabs: TabsNav, form: 'div', range: RangeInput, email: EmailInput } if (field.$el === 'textarea' && !field.attrs?.multiline) { field.attrs = { ...field.attrs, multiline: true } } return typeof field.$el === 'object' ? field.$el : (componentMap[field.$el as keyof typeof componentMap] ?? field.$el ?? 'div') } function renderChild(child: SchemaChild>, slots?: BaseBagelField>['slots']) { if (typeof child === 'string') { return child } if (isVNode(child)) { return child } return renderField( child as BaseBagelField>, slots ) } function renderField( field: BaseBagelField>, slots?: BaseBagelField>['slots'] ): VNode | undefined { const Component = getComponent(field as Field) if (!Component) { return } const rowData = (getFormData?.() || {}) as T | undefined // Check vIf condition first const condition = field.vIf ?? field['v-if'] if (condition !== undefined) { if (typeof condition === 'function') { // Compute currentValue for the vIf check const vIfCurrentValue = field.id ? ('get' in (rowData || {}) ? (rowData as any)?.get(field.id) : getNestedValue(rowData, field.id as string)) : undefined const vIfResult = field.id ? condition(vIfCurrentValue, rowData) : (condition as any)(rowData) if (!vIfResult) { return } } else if (typeof condition === 'string') { if (!getNestedValue(rowData, condition)) { return } } else if (!condition) { return } } const { $el: _$el, children, class: fieldClass, id, transform, validate, onUpdate, slots: fieldSlots, required, label, placeholder, disabled, defaultValue, ...fieldProps } = field // Use the get method if available, otherwise fall back to nested value access const currentValue = field.id ? ('get' in (rowData || {}) ? (rowData as any)?.get(field.id) : getNestedValue(rowData, field.id as string)) : undefined // Apply transform with conditional args: provide (val,row) only when id exists; otherwise provide only (row) const transformedValue = transform ? (id ? transform(currentValue, rowData) : (transform as any)(rowData)) : currentValue // First bind any function attributes with the current value and row data const boundFieldProps = bindAttrs(fieldProps as Attributes> | undefined, currentValue, rowData) // Check if this component should receive value as slot const isSlotValueComponent = typeof Component === 'string' && SLOT_VALUE_COMPONENTS.has(Component) // Check if this component should receive value as src const isSrcValueComponent = typeof Component === 'string' && SRC_VALUE_COMPONENTS.has(Component) const props: { [key: string]: any } = { ...boundFieldProps, required, label, placeholder, disabled, validate, id, getFormData, onUpdate, defaultValue, } // Wire top-level onClick with conditional args if (typeof (field as any).onClick === 'function') { const original = (field as any).onClick as (val?: any, row?: T) => void props.onClick = () => { if (id) { original(currentValue, rowData) } else { ;(original as any)(rowData) } } } // For form mode, always use the original value for modelValue if (mode === 'form') { props.modelValue = currentValue props['onUpdate:modelValue'] = (value: any) => { onUpdateModelValue?.(field, value) } } else { // For display modes (table/preview), use transformed value based on component type if (isSlotValueComponent) { // Slot components don't need a value prop } else if (isSrcValueComponent) { props.src = transformedValue } else { props.modelValue = transformedValue } } // Remove undefined props to avoid vue warnings Object.keys(props).forEach((key) => { if (props[key] === undefined) { delete props[key] } }) // Add options if they exist in the field if (field.options) { if (Array.isArray(field.options)) { props.options = field.options } else if (typeof field.options === 'function') { const fn = field.options as any // Component-aware mapping if (Component === SelectInput) { props.options = (query: string) => { const row = getFormData?.() const val = currentValue // If author expects (query) only, call directly if (typeof fn === 'function' && fn.length === 1) { return fn(query) } // If author expects (query, val, row), pass it directly if (typeof fn === 'function' && fn.length >= 3) { return fn(query, val, row) } // Otherwise evaluate (val,row) → array | (query)=>Promise | Promise const out = fn(val, row) if (Array.isArray(out)) { return out } if (typeof out === 'function') { return out(query) } if (out && typeof out.then === 'function') { return out } return [] } } else { // Non-search components (e.g., RadioGroup): return a zero-arg async loader using latest row props.options = async () => { const row = getFormData?.() const val = currentValue const out = fn(val, row) if (Array.isArray(out)) { return out } if (typeof out === 'function') { return await out('') } if (out && typeof out.then === 'function') { return await out } if (fn.length >= 3) { return await fn('', val, row) } return [] } } } else { props.options = field.options } } // Handle dynamic props and attrs if (field.attrs) { const boundAttrs = bindAttrs(field.attrs, currentValue, rowData) Object.entries(boundAttrs).forEach(([key, value]) => { // Skip $el as it's not a DOM attribute if (key === '$el') { return } if (typeof value === 'function') { if (key === 'onClick') { const original = value as (val?: any, row?: T) => void props.onClick = () => { if (id) { original(currentValue, rowData) } else { ;(original as any)(rowData) } } } else if (key.startsWith('on')) { props[key] = value } else { props[key] = value(currentValue, rowData) } } else { props[key] = value } }) } // Handle class binding last to ensure all transformations are applied props.class = classify(currentValue, rowData, fieldClass, props.class) // Handle component slots with vIf aware child rendering // const componentSlots: { [name: string]: Slot } = {} const componentSlots: Parameters[1] = {} // Add default slot if there are children if (children && children.length > 0) { componentSlots.default = () => children .map(child => renderChild(child, slots)) .filter(Boolean) // Filter out null results from vIf } // For slot value components, add the transformed value as default slot content if (isSlotValueComponent && transformedValue !== undefined) { componentSlots.default = () => String(transformedValue ?? '') } // Handle custom slots from the field if (fieldSlots) { Object.entries(fieldSlots).forEach(([name, slot]) => { componentSlots[name] = () => { if (Array.isArray(slot)) { // Handle BglFormSchemaT array return slot.map((schemaField) => { if (typeof schemaField === 'function') { // Handle function slot const slotFn = schemaField return slotFn({ row: rowData, field }) } if (isVNode(schemaField)) { // Handle VNode return schemaField } return renderField(schemaField as BaseBagelField>, slots) }) } return [] } }) } // Handle custom slot content from parent const slotContent = field.id ? (slots?.[field.id] as VNodeFn> | undefined)?.({ row: rowData, field }) : undefined // field.id && slots?.[field.id] && typeof slots[field.id] === 'function' && !Array.isArray(slots?.[field.id]) // ? (slots[field.id])({ row: rowData, field }) // : undefined if (mode === 'preview') { // Skip rendering if value is unset and includeUnset is false if (!includeUnset && (transformedValue === undefined || transformedValue === null || (typeof transformedValue === 'string' && transformedValue.length === 0))) { return } return h('div', { class: 'preview-field' }, [ h('div', { class: 'field-label' }, `${field.label || keyToLabel(field.id || '')}:`), h('div', { class: 'field-value' }, [ slotContent || (typeof field.$el === 'object' ? h(Component as any, props, componentSlots) : typeof transformedValue === 'object' && transformedValue !== null ? h('pre', { style: 'margin: 0; white-space: pre-wrap; font-family: inherit; font-size: inherit;' }, renderObject(transformedValue)) : String(transformedValue ?? '')) ]) ]) } return slotContent || h(Component as any, props, componentSlots) } return { renderField, getComponent } }