From 1875c0687208112452491edde86d6eac2f34044d Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 10 Apr 2026 13:35:59 +0800 Subject: [PATCH] feat(web): MetadataFormRenderer + VibeErp theme + 6 custom widgets --- web/src/components/MetadataFormRenderer.tsx | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/components/form-widgets/ItemPicker.tsx | 35 +++++++++++++++++++++++++++++++++++ web/src/components/form-widgets/LocationPicker.tsx | 35 +++++++++++++++++++++++++++++++++++ web/src/components/form-widgets/MoneyInput.tsx | 23 +++++++++++++++++++++++ web/src/components/form-widgets/PartnerPicker.tsx | 35 +++++++++++++++++++++++++++++++++++ web/src/components/form-widgets/QuantityInput.tsx | 23 +++++++++++++++++++++++ web/src/components/form-widgets/UomSelector.tsx | 35 +++++++++++++++++++++++++++++++++++ web/src/components/form-widgets/index.ts | 19 +++++++++++++++++++ web/src/components/form-widgets/vibeErpTheme.tsx | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 493 insertions(+), 0 deletions(-) create mode 100644 web/src/components/MetadataFormRenderer.tsx create mode 100644 web/src/components/form-widgets/ItemPicker.tsx create mode 100644 web/src/components/form-widgets/LocationPicker.tsx create mode 100644 web/src/components/form-widgets/MoneyInput.tsx create mode 100644 web/src/components/form-widgets/PartnerPicker.tsx create mode 100644 web/src/components/form-widgets/QuantityInput.tsx create mode 100644 web/src/components/form-widgets/UomSelector.tsx create mode 100644 web/src/components/form-widgets/index.ts create mode 100644 web/src/components/form-widgets/vibeErpTheme.tsx diff --git a/web/src/components/MetadataFormRenderer.tsx b/web/src/components/MetadataFormRenderer.tsx new file mode 100644 index 0000000..56ed2af --- /dev/null +++ b/web/src/components/MetadataFormRenderer.tsx @@ -0,0 +1,83 @@ +// Metadata-driven form renderer. +// +// Given a form definition slug, fetches the JSON Schema + UI Schema +// from the metadata API and renders a fully functional @rjsf form +// using the VibeErp theme and custom ERP widgets. + +import { useEffect, useState } from 'react' +import Form from '@rjsf/core' +import type { IChangeEvent } from '@rjsf/core' +import type { RJSFSchema, UiSchema } from '@rjsf/utils' +import validator from '@rjsf/validator-ajv8' +import { apiFetch } from '@/api/client' +import type { FormDefinition } from '@/types/api' +import { vibeWidgets } from '@/components/form-widgets' +import { vibeTemplates } from '@/components/form-widgets/vibeErpTheme' +import { Loading } from '@/components/Loading' +import { ErrorBox } from '@/components/ErrorBox' + +interface MetadataFormRendererProps { + slug: string + initialValues?: Record + onSubmit?: (values: Record) => void + readOnly?: boolean +} + +export function MetadataFormRenderer({ + slug, + initialValues, + onSubmit, + readOnly = false, +}: MetadataFormRendererProps) { + const [def, setDef] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [formData, setFormData] = useState>( + initialValues ?? {}, + ) + + useEffect(() => { + setLoading(true) + setError(null) + apiFetch(`/api/v1/_meta/metadata/forms/${slug}`) + .then((data) => { + setDef(data) + if (initialValues) setFormData(initialValues) + }) + .catch((err: unknown) => { + if (err instanceof Error) { + setError(err) + } else { + setError(new Error(String(err))) + } + }) + .finally(() => setLoading(false)) + }, [slug, initialValues]) + + if (loading) return + if (error) return + if (!def) return + + const handleChange = (e: IChangeEvent) => { + setFormData((e.formData as Record) ?? {}) + } + + const handleSubmit = (e: IChangeEvent) => { + onSubmit?.((e.formData as Record) ?? {}) + } + + return ( +
+ ) +} diff --git a/web/src/components/form-widgets/ItemPicker.tsx b/web/src/components/form-widgets/ItemPicker.tsx new file mode 100644 index 0000000..d3d2664 --- /dev/null +++ b/web/src/components/form-widgets/ItemPicker.tsx @@ -0,0 +1,35 @@ +// Item picker widget for @rjsf forms. +// Fetches catalog items from the API and renders a onChange(e.target.value || undefined)} + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" + > + + {items.map((item) => ( + + ))} + + ) +} diff --git a/web/src/components/form-widgets/LocationPicker.tsx b/web/src/components/form-widgets/LocationPicker.tsx new file mode 100644 index 0000000..0bad696 --- /dev/null +++ b/web/src/components/form-widgets/LocationPicker.tsx @@ -0,0 +1,35 @@ +// Location picker widget for @rjsf forms. +// Fetches inventory locations from the API and renders a onChange(e.target.value || undefined)} + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" + > + + {locations.map((loc) => ( + + ))} + + ) +} diff --git a/web/src/components/form-widgets/MoneyInput.tsx b/web/src/components/form-widgets/MoneyInput.tsx new file mode 100644 index 0000000..1b8d6cf --- /dev/null +++ b/web/src/components/form-widgets/MoneyInput.tsx @@ -0,0 +1,23 @@ +// Money input widget for @rjsf forms. +// A simple number input with step="0.01" for currency values. + +import type { WidgetProps } from '@rjsf/utils' + +export function MoneyInput(props: WidgetProps) { + const { id, value, required, disabled, readonly, onChange } = props + + return ( + + onChange(e.target.value === '' ? undefined : Number(e.target.value)) + } + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" + /> + ) +} diff --git a/web/src/components/form-widgets/PartnerPicker.tsx b/web/src/components/form-widgets/PartnerPicker.tsx new file mode 100644 index 0000000..4f9bb8c --- /dev/null +++ b/web/src/components/form-widgets/PartnerPicker.tsx @@ -0,0 +1,35 @@ +// Partner picker widget for @rjsf forms. +// Fetches partners from the API and renders a onChange(e.target.value || undefined)} + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" + > + + {items.map((p) => ( + + ))} + + ) +} diff --git a/web/src/components/form-widgets/QuantityInput.tsx b/web/src/components/form-widgets/QuantityInput.tsx new file mode 100644 index 0000000..07d15f7 --- /dev/null +++ b/web/src/components/form-widgets/QuantityInput.tsx @@ -0,0 +1,23 @@ +// Quantity input widget for @rjsf forms. +// A simple number input with step="0.01" for quantity values. + +import type { WidgetProps } from '@rjsf/utils' + +export function QuantityInput(props: WidgetProps) { + const { id, value, required, disabled, readonly, onChange } = props + + return ( + + onChange(e.target.value === '' ? undefined : Number(e.target.value)) + } + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" + /> + ) +} diff --git a/web/src/components/form-widgets/UomSelector.tsx b/web/src/components/form-widgets/UomSelector.tsx new file mode 100644 index 0000000..76da327 --- /dev/null +++ b/web/src/components/form-widgets/UomSelector.tsx @@ -0,0 +1,35 @@ +// UoM selector widget for @rjsf forms. +// Fetches units of measure from the API and renders a onChange(e.target.value || undefined)} + className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm" + > + + {uoms.map((u) => ( + + ))} + + ) +} diff --git a/web/src/components/form-widgets/index.ts b/web/src/components/form-widgets/index.ts new file mode 100644 index 0000000..f62c6ba --- /dev/null +++ b/web/src/components/form-widgets/index.ts @@ -0,0 +1,19 @@ +// Widget registry for the @rjsf form renderer. +// Maps widget identifiers used in uiSchema to their React components. + +import type { RegistryWidgetsType } from '@rjsf/utils' +import { PartnerPicker } from './PartnerPicker' +import { ItemPicker } from './ItemPicker' +import { UomSelector } from './UomSelector' +import { LocationPicker } from './LocationPicker' +import { MoneyInput } from './MoneyInput' +import { QuantityInput } from './QuantityInput' + +export const vibeWidgets: RegistryWidgetsType = { + 'partner-picker': PartnerPicker, + 'item-picker': ItemPicker, + 'uom-selector': UomSelector, + 'location-picker': LocationPicker, + 'money-input': MoneyInput, + 'quantity-input': QuantityInput, +} diff --git a/web/src/components/form-widgets/vibeErpTheme.tsx b/web/src/components/form-widgets/vibeErpTheme.tsx new file mode 100644 index 0000000..5018f21 --- /dev/null +++ b/web/src/components/form-widgets/vibeErpTheme.tsx @@ -0,0 +1,205 @@ +// Custom @rjsf theme for vibe_erp. +// +// Applies the same Tailwind CSS classes used throughout the existing +// SPA so that metadata-driven forms blend in seamlessly with the +// hand-coded pages. + +import type { + BaseInputTemplateProps, + ErrorListProps, + FieldTemplateProps, + ObjectFieldTemplateProps, + SubmitButtonProps, + RJSFSchema, + StrictRJSFSchema, + FormContextType, +} from '@rjsf/utils' +import { + getSubmitButtonOptions, + getInputProps, +} from '@rjsf/utils' + +// ── Visibility condition ──────────────────────────────────────────── +// A field's uiSchema can declare: +// { "ui:visible": { "field": "otherField", "equals": "someValue" } } +// When the condition is not met the field is hidden entirely. + +interface VisibleCondition { + field: string + equals: unknown +} + +function isVisible( + uiSchema: Record | undefined, + formData: Record | undefined, +): boolean { + if (!uiSchema) return true + const cond = uiSchema['ui:visible'] as VisibleCondition | undefined + if (!cond) return true + if (!formData) return false + return formData[cond.field] === cond.equals +} + +// ── BaseInputTemplate ─────────────────────────────────────────────── + +export function BaseInputTemplate< + T = unknown, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = Record, +>(props: BaseInputTemplateProps) { + const { + id, + type, + value, + readonly, + disabled, + autofocus, + onChange, + onBlur, + onFocus, + schema, + options, + } = props + + const { type: inputType, ...restInputProps } = getInputProps(schema, type, options) + + return ( + onChange(e.target.value === '' ? options.emptyValue : e.target.value)} + onBlur={(e) => onBlur(id, e.target.value)} + onFocus={(e) => onFocus(id, e.target.value)} + type={inputType ?? type} + {...restInputProps} + /> + ) +} + +// ── FieldTemplate ─────────────────────────────────────────────────── + +export function FieldTemplate< + T = unknown, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = Record, +>(props: FieldTemplateProps) { + const { + id, + label, + required, + children, + errors, + help, + description, + hidden, + uiSchema, + formContext, + } = props + + // Check ui:visible condition + const ctx = formContext as { formData?: Record } | undefined + if ( + hidden || + !isVisible(uiSchema as Record | undefined, ctx?.formData) + ) { + return null + } + + return ( +
+ {label && id !== 'root' && ( + + )} + {description && ( +

{description}

+ )} + {children} + {errors} + {help} +
+ ) +} + +// ── ObjectFieldTemplate ───────────────────────────────────────────── + +export function ObjectFieldTemplate< + T = unknown, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = Record, +>(props: ObjectFieldTemplateProps) { + const { title, description, properties } = props + + return ( +
+ {title && ( +

+ {title} +

+ )} + {description && ( +

{description}

+ )} + {properties.map((prop) => prop.content)} +
+ ) +} + +// ── ErrorList ──────────────────────────────────────────────────────── + +export function ErrorList< + T = unknown, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = Record, +>(props: ErrorListProps) { + const { errors } = props + if (!errors || errors.length === 0) return null + + return ( +
+
    + {errors.map((err, i) => ( +
  • + {err.stack} +
  • + ))} +
+
+ ) +} + +// ── SubmitButton ───────────────────────────────────────────────────── + +export function SubmitButton< + T = unknown, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = Record, +>(props: SubmitButtonProps) { + const { uiSchema } = props + const { submitText, norender } = getSubmitButtonOptions(uiSchema) + if (norender) return null + + return ( +
+ +
+ ) +} + +// ── Assembled templates export ────────────────────────────────────── + +export const vibeTemplates = { + BaseInputTemplate, + FieldTemplate, + ObjectFieldTemplate, + ButtonTemplates: { SubmitButton }, + ErrorListTemplate: ErrorList, +} -- libgit2 0.22.2