Skip to main content

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

TypeUsage
FieldOverride{ hidden?, required?, label? } — override for a single standard field
CustomFieldDefinition of a tenant custom field (name, label, type, required, options, …)
CustomFieldType"string" | "number" | "date" | "boolean" | "select" | "file_upload" | "rich_text"
DataAreaConfigOutput of useDataAreaConfig{ fieldConfig, customFields }
CustomFieldValuesRecord<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 keyTypeDescription
forms.{name}.field_configjsonMap { fieldName: FieldOverride }
forms.{name}.custom_fieldsjsonArray 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>
PropTypeDefaultDescription
titlestringTitle in the header
childrenReactNodeForm content
onSubmitFormEventHandlerSubmit handler
onCancel() => voidIf present, shows the Cancel button
isSubmittingbooleanfalseDisables Save + shows Spinner
isDirtybooleantruefalse → Save disabled (pristine form)
classNamestring""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>
PropTypeDescription
allowedRolesnumber[]Allowed UserType IDs (1=SuperAdmin, 2=TenantAdmin, 3=User)
sectionstringKey in permissions.section_visibility — takes priority over allowedRoles
modulestringModule name to check in licenseConfig.modules
fallbackReactNodeRendered 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

TypeRendered 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_uploadDisabled 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)

  1. Register the keys in the catalog (migration AddConfigSystem or 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);
  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} />
    </>
    );
    }
  3. For sensitive sections, wrap with <PermissionGate>.

  4. Assemble the form with <FormShell>.