Forms and DataAreas
HR Studio 2 uses static React components for all forms. There is no dynamic form generation from the database. Forms are code — type-checked, testable, and tree-shakeable.
For the team overview, see docs/forms-dataarea-design.md in the repository root.
Core concepts
DataArea
A DataArea is a React component that owns a logical, reusable group of fields. Examples: PersonalDataSection, ContactSection, SalarySection.
Properties:
- Self-contained — own props, Yup validation schema, i18n keys
- Reusable — appears in multiple forms
- Configurable — reads tenant overrides via
useDataAreaConfig - Permission-aware — sensitive sections wrapped in
<PermissionGate> - Extensible — Category 1 DataAreas include
<CustomFieldsZone>for tenant custom fields
Form
A Form assembles DataAreas. It contains no field logic — only layout (<FormShell>) and the list of DataAreas.
const EmployeeRegistryForm = ({ employeeId }: Props) => (
<FormShell title="Employee Registry">
<PersonalDataSection employeeId={employeeId} />
<ContactSection employeeId={employeeId} />
<DocumentsSection employeeId={employeeId} />
<PermissionGate section="employee.salary">
<SalarySection employeeId={employeeId} />
</PermissionGate>
</FormShell>
);
Three categories
| Category | Criteria | Treatment |
|---|---|---|
| 1 — Data DataArea | Coherent data group, reused in multiple forms | Full: useDataAreaConfig + <CustomFieldsZone> + <PermissionGate> + unit tests |
| 2 — Relational/config DataArea | Links entities or configures settings, reused | Lightweight: no customization layer, <PermissionGate> if needed |
| 3 — One-off section | ≤ 5 fields, appears in exactly one form | Inline in the form; extract when copied to a second form |
Adding a field
Standard field in a DataArea
- Add the field to the DataArea component.
- Add the field name to the Yup schema in the same file.
- Add the i18n key.
Because the DataArea is shared, the field automatically appears in every form that includes it. No other changes are needed.
// PersonalDataSection.tsx
const schema = yup.object({
firstName: yup.string().required(),
lastName: yup.string().required(),
taxCode: yup.string().required(), // ← new field added here
});
const PersonalDataSection = ({ employeeId }: Props) => {
const { fieldConfig } = useDataAreaConfig("PersonalDataSection");
// ...
return (
<div>
<Field name="firstName" hidden={fieldConfig.hidden_fields?.includes("firstName")} />
<Field name="lastName" />
<Field name="taxCode" /> {/* ← rendered automatically in all forms */}
</div>
);
};
Tenant custom field (without code change)
Tenant admins can add extra fields to any Category 1 DataArea via the configuration system. No deployment needed.
Set the forms.{DataAreaName}.custom_fields config key for the tenant:
[
{
"key": "badge_number",
"label": "Badge Number",
"type": "string",
"required": false,
"searchable": true,
"indexed": false,
"jsonb_path": "colcustom.badge_number"
}
]
Supported types: string, number, date, boolean, select (+ options array), file_upload, rich_text.
Custom field values are stored in the colcustom JSONB column already present on most HR tables.
Component override per tenant
For deep customization, a tenant can replace any DataArea component with a custom implementation.
// In any form
const PersonalDataSectionComponent = useTenantComponent("PersonalDataSection");
return (
<PersonalDataSectionComponent employeeId={employeeId} />
// If no tenant override exists, this renders the standard component.
);
The custom component for a tenant lives in a separate module. It is loaded only for that tenant — other tenants are not affected.
Tenant field configuration (without custom fields)
The forms.{DataAreaName}.field_config key lets TenantAdmins control standard fields — hide them, make them required, or relabel them — without writing code.
Set via POST /api/config/tenant/set:
{
"code": "forms.PersonalDataSection.field_config",
"value": "{\"hidden_fields\":[\"usr_address\"],\"required_override\":[\"usr_phone_number\"],\"relabels\":{\"usr_phone_number\":\"Company Mobile\"}}"
}
The DataArea reads this at render time via useDataAreaConfig:
const { fieldConfig, customFields } = useDataAreaConfig("PersonalDataSection");
// Hide a field
if (fieldConfig.hidden_fields?.includes("firstName")) return null;
// Override required
const isRequired = fieldConfig.required_override?.includes("firstName") ?? false;
// Custom label
const label = fieldConfig.relabels?.["usr_phone_number"] ?? t("fields.phone");
Permission gating
DataAreas containing sensitive data are wrapped with <PermissionGate>. The gate reads permissions.section_visibility from the resolved tenant config.
<PermissionGate section="employee.salary" subjectUserId={employee.userId}>
<SalarySection employeeId={employeeId} />
</PermissionGate>
permissions.section_visibility is a JSON object where each key is a section code and the value is an array of allowed role identifiers:
{
"employee.salary": ["admin", "hr_manager"],
"employee.documents": ["admin"]
}
Permission checks are enforced server-side too. API endpoints that return sensitive data call FieldMaskingService to strip fields that the requesting user is not allowed to see.
Shared infrastructure
These are built once and shared by all DataAreas and Forms:
| Component / Hook | File | Purpose |
|---|---|---|
useDataAreaConfig(name) | hooks/useDataAreaConfig.ts | Reads field_config + custom_fields from Redux |
<CustomFieldsZone> | components/CustomFieldsZone.tsx | Renders custom fields with dynamic Yup validation |
<PermissionGate> | components/PermissionGate.tsx | Role-based section visibility |
useTenantComponent(name) | hooks/useTenantComponent.ts | Dynamic component override |
<FormShell> | components/FormShell.tsx | Layout shell (navigation per form, Save/Cancel buttons) |
CustomFieldValidationService | Services/Config/CustomFieldValidationService.cs | Backend validation of custom fields |
Backend — custom field validation
Every API endpoint that receives form data should inject CustomFieldValidationService:
public async Task<IActionResult> Update(
[FromBody] UpdateEmployeeRequest request,
[FromServices] CustomFieldValidationService customFieldValidator,
CancellationToken ct)
{
var errors = await customFieldValidator.ValidateAsync(
request.CustomFields, CurrentTenantId, "PersonalDataSection", ct);
if (errors.Count > 0)
return UnprocessableEntity(new { errors });
// proceed with update
}
Request body convention — custom fields are always in a customFields dictionary:
{
"firstName": "Mario",
"lastName": "Rossi",
"customFields": {
"badge_number": "A1234"
}
}
FormShell navigation
Each form independently chooses its navigation model. Pass navigation to <FormShell>:
// Tabs
<FormShell navigation="tabs">...</FormShell>
// Accordion
<FormShell navigation="accordion">...</FormShell>
// Vertical stepper (for wizards)
<FormShell navigation="stepper">...</FormShell>
There is no global default — the decision is made per form based on the number and grouping of DataAreas.