Forms & DataArea — Shared Infrastructure (Phase F1)
Components and hooks shared by all forms in the main application (frontend/).
File structure
frontend/src/
├── types/
│ └── forms.ts TypeScript types (FieldOverride, CustomField, DataAreaConfig, …)
├── hooks/
│ ├── useDataAreaConfig.ts Reads field_config + custom_fields from the config slice
│ └── useTenantComponent.ts Returns tenant component override or fallback
└── components/forms/
├── FormShell.tsx Common layout shell (Card + Save/Cancel)
├── PermissionGate.tsx Role-based visibility wrapper
└── CustomFieldsZone.tsx Renders tenant custom fields + validation
Types — types/forms.ts
| Type | Usage |
|---|---|
FieldOverride | { hidden?, required?, label? } — override for a single standard field |
CustomField | Definition of a tenant custom field (name, label, type, required, options, …) |
CustomFieldType | "string" | "number" | "date" | "boolean" | "select" | "file_upload" | "rich_text" |
DataAreaConfig | Output of useDataAreaConfig — { fieldConfig, customFields } |
CustomFieldValues | Record<string, string | number | boolean | null> — values to send in colcustom |
FormLayout | "flat" | "tabs" | "accordion" | "stepper" — chosen per form |
Hooks
useDataAreaConfig(name: string): DataAreaConfig
Reads two config keys from the Redux slice:
| Config key | Type | Description |
|---|---|---|
forms.{name}.field_config | json | Map { fieldName: FieldOverride } |
forms.{name}.custom_fields | json | Array of CustomField |
Both keys are optional — safe defaults ({} / []).
const { fieldConfig, customFields } = useDataAreaConfig("employee_registry");
const isHidden = fieldConfig["middle_name"]?.hidden ?? false;
useTenantComponent<P>(name: string, defaultComponent: ComponentType<P>): ComponentType<P>
Returns a component override from the window.__tenantComponents registry if configured in forms.components.{name}. In F1 the registry is always empty — always returns defaultComponent.
const AddressBlock = useTenantComponent("address_block", DefaultAddressBlock);
return <AddressBlock {...props} />;
Components
<FormShell>
Common layout shell for all forms. Wraps children in a Bootstrap Card with a title header and Save/Cancel footer.
<FormShell
title="Employee Registry"
onSubmit={handleSubmit(onSave)}
onCancel={goBack}
isSubmitting={isSubmitting}
isDirty={isDirty}
>
<EmployeeRegistryDataArea control={control} errors={errors} />
</FormShell>
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | — | Title in the header |
children | ReactNode | — | Form content |
onSubmit | FormEventHandler | — | Submit handler |
onCancel | () => void | — | If present, shows the Cancel button |
isSubmitting | boolean | false | Disables Save + shows Spinner |
isDirty | boolean | true | false → Save disabled (pristine form) |
className | string | "" | Additional CSS classes on the Card |
<PermissionGate>
Role-based visibility wrapper. Makes no API calls — reads Redux.
import PermissionGate, { UserType } from "../components/forms/PermissionGate";
// Mode 1 — static roles
<PermissionGate allowedRoles={[UserType.SuperAdmin, UserType.TenantAdmin]}>
<AdminSection />
</PermissionGate>
// Mode 2 — config-driven (reads permissions.section_visibility[section])
<PermissionGate section="hr.employee.salary" fallback={<p>Access denied</p>}>
<SalarySection />
</PermissionGate>
// Mode 3 — license guard
<PermissionGate module="payroll">
<PayrollWidget />
</PermissionGate>
| Prop | Type | Description |
|---|---|---|
allowedRoles | number[] | Allowed UserType IDs (1=SuperAdmin, 2=TenantAdmin, 3=User) |
section | string | Key in permissions.section_visibility — takes priority over allowedRoles |
module | string | Module name to check in licenseConfig.modules |
fallback | ReactNode | Rendered if access denied (default: null) |
UserType constants exported: UserType.SuperAdmin = 1, UserType.TenantAdmin = 2, UserType.User = 3.
<CustomFieldsZone>
Renders tenant custom fields defined in forms.{dataArea}.custom_fields. If the DataArea has no custom fields, renders nothing.
Uses react-hook-form Controller — requires control and errors from the parent form.
const { control, formState: { errors } } = useForm<EmployeeFormValues>();
<CustomFieldsZone
dataArea="employee_registry"
control={control}
errors={errors}
namePrefix="custom" // default — fields saved as custom.{name}
/>
The resulting values are sent in the entity's colcustom payload:
{ "colcustom": { "cost_center": "IT-001", "employment_level": "Senior" } }
Supported field types
| Type | Rendered as |
|---|---|
string | <input type="text"> |
number | <input type="number"> |
date | <input type="date"> |
boolean | <input type="checkbox"> |
select | <select> with the options from the definition |
file_upload | Disabled placeholder (full implementation deferred) |
rich_text | <textarea> (Tiptap editor deferred) |
Yup validation (external schema)
For forms that compose a Yup schema, CustomFieldsZone exports buildCustomFieldsSchema:
import { buildCustomFieldsSchema } from "../components/forms/CustomFieldsZone";
import { useDataAreaConfig } from "../hooks/useDataAreaConfig";
const { customFields } = useDataAreaConfig("employee_registry");
const schema = baseSchema.shape({ custom: buildCustomFieldsSchema(customFields) });
Data flow
Login response
└── config map (Redux configSlice)
├── forms.employee_registry.field_config ──► useDataAreaConfig
├── forms.employee_registry.custom_fields ──► useDataAreaConfig ──► CustomFieldsZone
└── permissions.section_visibility ──► PermissionGate (section mode)
How to add a new DataArea (Category 1)
-
Register the keys in the catalog (migration
AddConfigSystemor a new migration):INSERT INTO cloud.config_catalog (cck_code, cck_category, cck_label, cck_value_type, cck_default_value, cck_allowed_levels)
VALUES
('forms.{name}.field_config', 'forms', '...', 'json', '{}', 2),
('forms.{name}.custom_fields', 'forms', '...', 'json', '[]', 2); -
Create the DataArea component:
export default function MyDataArea({ control, errors }) {
const { fieldConfig, customFields } = useDataAreaConfig("my_area");
if (fieldConfig["my_field"]?.hidden) return null;
return (
<>
{/* standard fields */}
<CustomFieldsZone dataArea="my_area" control={control} errors={errors} />
</>
);
} -
For sensitive sections, wrap with
<PermissionGate>. -
Assemble the form with
<FormShell>.