You need to sign in before continuing.

Commit 1875c0687208112452491edde86d6eac2f34044d

Authored by zichun
1 parent 60d72da8

feat(web): MetadataFormRenderer + VibeErp theme + 6 custom widgets

Add the @rjsf-based metadata-driven form renderer with a custom
Tailwind theme matching the existing SPA styling and six ERP-specific
widgets: PartnerPicker, ItemPicker, UomSelector, LocationPicker,
MoneyInput, and QuantityInput.
web/src/components/MetadataFormRenderer.tsx 0 → 100644
  1 +// Metadata-driven form renderer.
  2 +//
  3 +// Given a form definition slug, fetches the JSON Schema + UI Schema
  4 +// from the metadata API and renders a fully functional @rjsf form
  5 +// using the VibeErp theme and custom ERP widgets.
  6 +
  7 +import { useEffect, useState } from 'react'
  8 +import Form from '@rjsf/core'
  9 +import type { IChangeEvent } from '@rjsf/core'
  10 +import type { RJSFSchema, UiSchema } from '@rjsf/utils'
  11 +import validator from '@rjsf/validator-ajv8'
  12 +import { apiFetch } from '@/api/client'
  13 +import type { FormDefinition } from '@/types/api'
  14 +import { vibeWidgets } from '@/components/form-widgets'
  15 +import { vibeTemplates } from '@/components/form-widgets/vibeErpTheme'
  16 +import { Loading } from '@/components/Loading'
  17 +import { ErrorBox } from '@/components/ErrorBox'
  18 +
  19 +interface MetadataFormRendererProps {
  20 + slug: string
  21 + initialValues?: Record<string, unknown>
  22 + onSubmit?: (values: Record<string, unknown>) => void
  23 + readOnly?: boolean
  24 +}
  25 +
  26 +export function MetadataFormRenderer({
  27 + slug,
  28 + initialValues,
  29 + onSubmit,
  30 + readOnly = false,
  31 +}: MetadataFormRendererProps) {
  32 + const [def, setDef] = useState<FormDefinition | null>(null)
  33 + const [loading, setLoading] = useState(true)
  34 + const [error, setError] = useState<Error | null>(null)
  35 + const [formData, setFormData] = useState<Record<string, unknown>>(
  36 + initialValues ?? {},
  37 + )
  38 +
  39 + useEffect(() => {
  40 + setLoading(true)
  41 + setError(null)
  42 + apiFetch<FormDefinition>(`/api/v1/_meta/metadata/forms/${slug}`)
  43 + .then((data) => {
  44 + setDef(data)
  45 + if (initialValues) setFormData(initialValues)
  46 + })
  47 + .catch((err: unknown) => {
  48 + if (err instanceof Error) {
  49 + setError(err)
  50 + } else {
  51 + setError(new Error(String(err)))
  52 + }
  53 + })
  54 + .finally(() => setLoading(false))
  55 + }, [slug, initialValues])
  56 +
  57 + if (loading) return <Loading />
  58 + if (error) return <ErrorBox error={error} />
  59 + if (!def) return <ErrorBox error={new Error(`Form "${slug}" not found.`)} />
  60 +
  61 + const handleChange = (e: IChangeEvent) => {
  62 + setFormData((e.formData as Record<string, unknown>) ?? {})
  63 + }
  64 +
  65 + const handleSubmit = (e: IChangeEvent) => {
  66 + onSubmit?.((e.formData as Record<string, unknown>) ?? {})
  67 + }
  68 +
  69 + return (
  70 + <Form
  71 + schema={def.jsonSchema as RJSFSchema}
  72 + uiSchema={def.uiSchema as UiSchema}
  73 + formData={formData}
  74 + validator={validator}
  75 + widgets={vibeWidgets}
  76 + templates={vibeTemplates}
  77 + readonly={readOnly}
  78 + onChange={handleChange}
  79 + onSubmit={handleSubmit}
  80 + formContext={{ formData }}
  81 + />
  82 + )
  83 +}
web/src/components/form-widgets/ItemPicker.tsx 0 → 100644
  1 +// Item picker widget for @rjsf forms.
  2 +// Fetches catalog items from the API and renders a <select> dropdown
  3 +// showing "code -- name" for each item.
  4 +
  5 +import { useEffect, useState } from 'react'
  6 +import type { WidgetProps } from '@rjsf/utils'
  7 +import { catalog } from '@/api/client'
  8 +import type { Item } from '@/types/api'
  9 +
  10 +export function ItemPicker(props: WidgetProps) {
  11 + const { id, value, required, disabled, readonly, onChange } = props
  12 + const [items, setItems] = useState<Item[]>([])
  13 +
  14 + useEffect(() => {
  15 + catalog.listItems().then(setItems).catch(() => setItems([]))
  16 + }, [])
  17 +
  18 + return (
  19 + <select
  20 + id={id}
  21 + value={value ?? ''}
  22 + required={required}
  23 + disabled={disabled || readonly}
  24 + onChange={(e) => onChange(e.target.value || undefined)}
  25 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
  26 + >
  27 + <option value="">—</option>
  28 + {items.map((item) => (
  29 + <option key={item.id} value={item.code}>
  30 + {item.code} — {item.name}
  31 + </option>
  32 + ))}
  33 + </select>
  34 + )
  35 +}
web/src/components/form-widgets/LocationPicker.tsx 0 → 100644
  1 +// Location picker widget for @rjsf forms.
  2 +// Fetches inventory locations from the API and renders a <select> dropdown
  3 +// showing "code -- name" for each location.
  4 +
  5 +import { useEffect, useState } from 'react'
  6 +import type { WidgetProps } from '@rjsf/utils'
  7 +import { inventory } from '@/api/client'
  8 +import type { Location } from '@/types/api'
  9 +
  10 +export function LocationPicker(props: WidgetProps) {
  11 + const { id, value, required, disabled, readonly, onChange } = props
  12 + const [locations, setLocations] = useState<Location[]>([])
  13 +
  14 + useEffect(() => {
  15 + inventory.listLocations().then(setLocations).catch(() => setLocations([]))
  16 + }, [])
  17 +
  18 + return (
  19 + <select
  20 + id={id}
  21 + value={value ?? ''}
  22 + required={required}
  23 + disabled={disabled || readonly}
  24 + onChange={(e) => onChange(e.target.value || undefined)}
  25 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
  26 + >
  27 + <option value="">—</option>
  28 + {locations.map((loc) => (
  29 + <option key={loc.id} value={loc.code}>
  30 + {loc.code} — {loc.name}
  31 + </option>
  32 + ))}
  33 + </select>
  34 + )
  35 +}
web/src/components/form-widgets/MoneyInput.tsx 0 → 100644
  1 +// Money input widget for @rjsf forms.
  2 +// A simple number input with step="0.01" for currency values.
  3 +
  4 +import type { WidgetProps } from '@rjsf/utils'
  5 +
  6 +export function MoneyInput(props: WidgetProps) {
  7 + const { id, value, required, disabled, readonly, onChange } = props
  8 +
  9 + return (
  10 + <input
  11 + id={id}
  12 + type="number"
  13 + step="0.01"
  14 + value={value ?? ''}
  15 + required={required}
  16 + disabled={disabled || readonly}
  17 + onChange={(e) =>
  18 + onChange(e.target.value === '' ? undefined : Number(e.target.value))
  19 + }
  20 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
  21 + />
  22 + )
  23 +}
web/src/components/form-widgets/PartnerPicker.tsx 0 → 100644
  1 +// Partner picker widget for @rjsf forms.
  2 +// Fetches partners from the API and renders a <select> dropdown
  3 +// showing "code -- name" for each partner.
  4 +
  5 +import { useEffect, useState } from 'react'
  6 +import type { WidgetProps } from '@rjsf/utils'
  7 +import { partners } from '@/api/client'
  8 +import type { Partner } from '@/types/api'
  9 +
  10 +export function PartnerPicker(props: WidgetProps) {
  11 + const { id, value, required, disabled, readonly, onChange } = props
  12 + const [items, setItems] = useState<Partner[]>([])
  13 +
  14 + useEffect(() => {
  15 + partners.list().then(setItems).catch(() => setItems([]))
  16 + }, [])
  17 +
  18 + return (
  19 + <select
  20 + id={id}
  21 + value={value ?? ''}
  22 + required={required}
  23 + disabled={disabled || readonly}
  24 + onChange={(e) => onChange(e.target.value || undefined)}
  25 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
  26 + >
  27 + <option value="">—</option>
  28 + {items.map((p) => (
  29 + <option key={p.id} value={p.code}>
  30 + {p.code} — {p.name}
  31 + </option>
  32 + ))}
  33 + </select>
  34 + )
  35 +}
web/src/components/form-widgets/QuantityInput.tsx 0 → 100644
  1 +// Quantity input widget for @rjsf forms.
  2 +// A simple number input with step="0.01" for quantity values.
  3 +
  4 +import type { WidgetProps } from '@rjsf/utils'
  5 +
  6 +export function QuantityInput(props: WidgetProps) {
  7 + const { id, value, required, disabled, readonly, onChange } = props
  8 +
  9 + return (
  10 + <input
  11 + id={id}
  12 + type="number"
  13 + step="0.01"
  14 + value={value ?? ''}
  15 + required={required}
  16 + disabled={disabled || readonly}
  17 + onChange={(e) =>
  18 + onChange(e.target.value === '' ? undefined : Number(e.target.value))
  19 + }
  20 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
  21 + />
  22 + )
  23 +}
web/src/components/form-widgets/UomSelector.tsx 0 → 100644
  1 +// UoM selector widget for @rjsf forms.
  2 +// Fetches units of measure from the API and renders a <select> dropdown
  3 +// showing "code -- name" for each UoM.
  4 +
  5 +import { useEffect, useState } from 'react'
  6 +import type { WidgetProps } from '@rjsf/utils'
  7 +import { catalog } from '@/api/client'
  8 +import type { Uom } from '@/types/api'
  9 +
  10 +export function UomSelector(props: WidgetProps) {
  11 + const { id, value, required, disabled, readonly, onChange } = props
  12 + const [uoms, setUoms] = useState<Uom[]>([])
  13 +
  14 + useEffect(() => {
  15 + catalog.listUoms().then(setUoms).catch(() => setUoms([]))
  16 + }, [])
  17 +
  18 + return (
  19 + <select
  20 + id={id}
  21 + value={value ?? ''}
  22 + required={required}
  23 + disabled={disabled || readonly}
  24 + onChange={(e) => onChange(e.target.value || undefined)}
  25 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
  26 + >
  27 + <option value="">—</option>
  28 + {uoms.map((u) => (
  29 + <option key={u.id} value={u.code}>
  30 + {u.code} — {u.name}
  31 + </option>
  32 + ))}
  33 + </select>
  34 + )
  35 +}
web/src/components/form-widgets/index.ts 0 → 100644
  1 +// Widget registry for the @rjsf form renderer.
  2 +// Maps widget identifiers used in uiSchema to their React components.
  3 +
  4 +import type { RegistryWidgetsType } from '@rjsf/utils'
  5 +import { PartnerPicker } from './PartnerPicker'
  6 +import { ItemPicker } from './ItemPicker'
  7 +import { UomSelector } from './UomSelector'
  8 +import { LocationPicker } from './LocationPicker'
  9 +import { MoneyInput } from './MoneyInput'
  10 +import { QuantityInput } from './QuantityInput'
  11 +
  12 +export const vibeWidgets: RegistryWidgetsType = {
  13 + 'partner-picker': PartnerPicker,
  14 + 'item-picker': ItemPicker,
  15 + 'uom-selector': UomSelector,
  16 + 'location-picker': LocationPicker,
  17 + 'money-input': MoneyInput,
  18 + 'quantity-input': QuantityInput,
  19 +}
web/src/components/form-widgets/vibeErpTheme.tsx 0 → 100644
  1 +// Custom @rjsf theme for vibe_erp.
  2 +//
  3 +// Applies the same Tailwind CSS classes used throughout the existing
  4 +// SPA so that metadata-driven forms blend in seamlessly with the
  5 +// hand-coded pages.
  6 +
  7 +import type {
  8 + BaseInputTemplateProps,
  9 + ErrorListProps,
  10 + FieldTemplateProps,
  11 + ObjectFieldTemplateProps,
  12 + SubmitButtonProps,
  13 + RJSFSchema,
  14 + StrictRJSFSchema,
  15 + FormContextType,
  16 +} from '@rjsf/utils'
  17 +import {
  18 + getSubmitButtonOptions,
  19 + getInputProps,
  20 +} from '@rjsf/utils'
  21 +
  22 +// ── Visibility condition ────────────────────────────────────────────
  23 +// A field's uiSchema can declare:
  24 +// { "ui:visible": { "field": "otherField", "equals": "someValue" } }
  25 +// When the condition is not met the field is hidden entirely.
  26 +
  27 +interface VisibleCondition {
  28 + field: string
  29 + equals: unknown
  30 +}
  31 +
  32 +function isVisible(
  33 + uiSchema: Record<string, unknown> | undefined,
  34 + formData: Record<string, unknown> | undefined,
  35 +): boolean {
  36 + if (!uiSchema) return true
  37 + const cond = uiSchema['ui:visible'] as VisibleCondition | undefined
  38 + if (!cond) return true
  39 + if (!formData) return false
  40 + return formData[cond.field] === cond.equals
  41 +}
  42 +
  43 +// ── BaseInputTemplate ───────────────────────────────────────────────
  44 +
  45 +export function BaseInputTemplate<
  46 + T = unknown,
  47 + S extends StrictRJSFSchema = RJSFSchema,
  48 + F extends FormContextType = Record<string, unknown>,
  49 +>(props: BaseInputTemplateProps<T, S, F>) {
  50 + const {
  51 + id,
  52 + type,
  53 + value,
  54 + readonly,
  55 + disabled,
  56 + autofocus,
  57 + onChange,
  58 + onBlur,
  59 + onFocus,
  60 + schema,
  61 + options,
  62 + } = props
  63 +
  64 + const { type: inputType, ...restInputProps } = getInputProps<T, S, F>(schema, type, options)
  65 +
  66 + return (
  67 + <input
  68 + id={id}
  69 + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
  70 + readOnly={readonly}
  71 + disabled={disabled}
  72 + autoFocus={autofocus}
  73 + value={value == null ? '' : String(value)}
  74 + onChange={(e) => onChange(e.target.value === '' ? options.emptyValue : e.target.value)}
  75 + onBlur={(e) => onBlur(id, e.target.value)}
  76 + onFocus={(e) => onFocus(id, e.target.value)}
  77 + type={inputType ?? type}
  78 + {...restInputProps}
  79 + />
  80 + )
  81 +}
  82 +
  83 +// ── FieldTemplate ───────────────────────────────────────────────────
  84 +
  85 +export function FieldTemplate<
  86 + T = unknown,
  87 + S extends StrictRJSFSchema = RJSFSchema,
  88 + F extends FormContextType = Record<string, unknown>,
  89 +>(props: FieldTemplateProps<T, S, F>) {
  90 + const {
  91 + id,
  92 + label,
  93 + required,
  94 + children,
  95 + errors,
  96 + help,
  97 + description,
  98 + hidden,
  99 + uiSchema,
  100 + formContext,
  101 + } = props
  102 +
  103 + // Check ui:visible condition
  104 + const ctx = formContext as { formData?: Record<string, unknown> } | undefined
  105 + if (
  106 + hidden ||
  107 + !isVisible(uiSchema as Record<string, unknown> | undefined, ctx?.formData)
  108 + ) {
  109 + return null
  110 + }
  111 +
  112 + return (
  113 + <div className="mb-3">
  114 + {label && id !== 'root' && (
  115 + <label htmlFor={id} className="block text-sm font-medium text-slate-700">
  116 + {label}
  117 + {required && <span className="text-rose-500 ml-0.5">*</span>}
  118 + </label>
  119 + )}
  120 + {description && (
  121 + <p className="text-xs text-slate-500 mt-0.5">{description}</p>
  122 + )}
  123 + {children}
  124 + {errors}
  125 + {help}
  126 + </div>
  127 + )
  128 +}
  129 +
  130 +// ── ObjectFieldTemplate ─────────────────────────────────────────────
  131 +
  132 +export function ObjectFieldTemplate<
  133 + T = unknown,
  134 + S extends StrictRJSFSchema = RJSFSchema,
  135 + F extends FormContextType = Record<string, unknown>,
  136 +>(props: ObjectFieldTemplateProps<T, S, F>) {
  137 + const { title, description, properties } = props
  138 +
  139 + return (
  140 + <div className="space-y-4">
  141 + {title && (
  142 + <h3 className="text-xs font-semibold uppercase tracking-wide text-slate-400 mb-3">
  143 + {title}
  144 + </h3>
  145 + )}
  146 + {description && (
  147 + <p className="text-sm text-slate-500 mb-2">{description}</p>
  148 + )}
  149 + {properties.map((prop) => prop.content)}
  150 + </div>
  151 + )
  152 +}
  153 +
  154 +// ── ErrorList ────────────────────────────────────────────────────────
  155 +
  156 +export function ErrorList<
  157 + T = unknown,
  158 + S extends StrictRJSFSchema = RJSFSchema,
  159 + F extends FormContextType = Record<string, unknown>,
  160 +>(props: ErrorListProps<T, S, F>) {
  161 + const { errors } = props
  162 + if (!errors || errors.length === 0) return null
  163 +
  164 + return (
  165 + <div className="rounded-md border border-rose-200 bg-rose-50 p-3 mb-4">
  166 + <ul className="list-disc list-inside space-y-1">
  167 + {errors.map((err, i) => (
  168 + <li key={i} className="text-sm text-rose-600">
  169 + {err.stack}
  170 + </li>
  171 + ))}
  172 + </ul>
  173 + </div>
  174 + )
  175 +}
  176 +
  177 +// ── SubmitButton ─────────────────────────────────────────────────────
  178 +
  179 +export function SubmitButton<
  180 + T = unknown,
  181 + S extends StrictRJSFSchema = RJSFSchema,
  182 + F extends FormContextType = Record<string, unknown>,
  183 +>(props: SubmitButtonProps<T, S, F>) {
  184 + const { uiSchema } = props
  185 + const { submitText, norender } = getSubmitButtonOptions(uiSchema)
  186 + if (norender) return null
  187 +
  188 + return (
  189 + <div className="mt-4">
  190 + <button type="submit" className="btn-primary">
  191 + {submitText}
  192 + </button>
  193 + </div>
  194 + )
  195 +}
  196 +
  197 +// ── Assembled templates export ──────────────────────────────────────
  198 +
  199 +export const vibeTemplates = {
  200 + BaseInputTemplate,
  201 + FieldTemplate,
  202 + ObjectFieldTemplate,
  203 + ButtonTemplates: { SubmitButton },
  204 + ErrorListTemplate: ErrorList,
  205 +}