vibeErpTheme.tsx 5.88 KB
// 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<string, unknown> | undefined,
  formData: Record<string, unknown> | 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<string, unknown>,
>(props: BaseInputTemplateProps<T, S, F>) {
  const {
    id,
    type,
    value,
    readonly,
    disabled,
    autofocus,
    onChange,
    onBlur,
    onFocus,
    schema,
    options,
  } = props

  const { type: inputType, ...restInputProps } = getInputProps<T, S, F>(schema, type, options)

  return (
    <input
      id={id}
      className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
      readOnly={readonly}
      disabled={disabled}
      autoFocus={autofocus}
      value={value == null ? '' : String(value)}
      onChange={(e) => 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<string, unknown>,
>(props: FieldTemplateProps<T, S, F>) {
  const {
    id,
    label,
    required,
    children,
    errors,
    help,
    description,
    hidden,
    uiSchema,
    formContext,
  } = props

  // Check ui:visible condition
  const ctx = formContext as { formData?: Record<string, unknown> } | undefined
  if (
    hidden ||
    !isVisible(uiSchema as Record<string, unknown> | undefined, ctx?.formData)
  ) {
    return null
  }

  return (
    <div className="mb-3">
      {label && id !== 'root' && (
        <label htmlFor={id} className="block text-sm font-medium text-slate-700">
          {label}
          {required && <span className="text-rose-500 ml-0.5">*</span>}
        </label>
      )}
      {description && (
        <p className="text-xs text-slate-500 mt-0.5">{description}</p>
      )}
      {children}
      {errors}
      {help}
    </div>
  )
}

// ── ObjectFieldTemplate ─────────────────────────────────────────────

export function ObjectFieldTemplate<
  T = unknown,
  S extends StrictRJSFSchema = RJSFSchema,
  F extends FormContextType = Record<string, unknown>,
>(props: ObjectFieldTemplateProps<T, S, F>) {
  const { title, description, properties } = props

  return (
    <div className="space-y-4">
      {title && (
        <h3 className="text-xs font-semibold uppercase tracking-wide text-slate-400 mb-3">
          {title}
        </h3>
      )}
      {description && (
        <p className="text-sm text-slate-500 mb-2">{description}</p>
      )}
      {properties.map((prop) => prop.content)}
    </div>
  )
}

// ── ErrorList ────────────────────────────────────────────────────────

export function ErrorList<
  T = unknown,
  S extends StrictRJSFSchema = RJSFSchema,
  F extends FormContextType = Record<string, unknown>,
>(props: ErrorListProps<T, S, F>) {
  const { errors } = props
  if (!errors || errors.length === 0) return null

  return (
    <div className="rounded-md border border-rose-200 bg-rose-50 p-3 mb-4">
      <ul className="list-disc list-inside space-y-1">
        {errors.map((err, i) => (
          <li key={i} className="text-sm text-rose-600">
            {err.stack}
          </li>
        ))}
      </ul>
    </div>
  )
}

// ── SubmitButton ─────────────────────────────────────────────────────

export function SubmitButton<
  T = unknown,
  S extends StrictRJSFSchema = RJSFSchema,
  F extends FormContextType = Record<string, unknown>,
>(props: SubmitButtonProps<T, S, F>) {
  const { uiSchema } = props
  const { submitText, norender } = getSubmitButtonOptions(uiSchema)
  if (norender) return null

  return (
    <div className="mt-4">
      <button type="submit" className="btn-primary">
        {submitText}
      </button>
    </div>
  )
}

// ── Assembled templates export ──────────────────────────────────────

export const vibeTemplates = {
  BaseInputTemplate,
  FieldTemplate,
  ObjectFieldTemplate,
  ButtonTemplates: { SubmitButton },
  ErrorListTemplate: ErrorList,
}