Skip to main content

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

CategoryCriteriaTreatment
1 — Data DataAreaCoherent data group, reused in multiple formsFull: useDataAreaConfig + <CustomFieldsZone> + <PermissionGate> + unit tests
2 — Relational/config DataAreaLinks entities or configures settings, reusedLightweight: no customization layer, <PermissionGate> if needed
3 — One-off section≤ 5 fields, appears in exactly one formInline in the form; extract when copied to a second form

Adding a field

Standard field in a DataArea

  1. Add the field to the DataArea component.
  2. Add the field name to the Yup schema in the same file.
  3. 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 / HookFilePurpose
useDataAreaConfig(name)hooks/useDataAreaConfig.tsReads field_config + custom_fields from Redux
<CustomFieldsZone>components/CustomFieldsZone.tsxRenders custom fields with dynamic Yup validation
<PermissionGate>components/PermissionGate.tsxRole-based section visibility
useTenantComponent(name)hooks/useTenantComponent.tsDynamic component override
<FormShell>components/FormShell.tsxLayout shell (navigation per form, Save/Cancel buttons)
CustomFieldValidationServiceServices/Config/CustomFieldValidationService.csBackend 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.