4.2.6.2 Configure an event declaration form

Configuring an event declaration form requires you to:

  • Define the structure of the form into pages using defineDeclarationForm

  • Build each page from the available form fields, validations, conditionals etc

Define form structure using defineDeclarationForm()

In the v2 events engine, every event must declare its Declaration Form — the form a registrar completes when declaring the event (birth, death, marriage, etc.).

To make this easier and strongly typed, OpenCRVS provides the helper:

defineDeclarationForm(...)

This constructs a validated, typed declaration form configuration that can be assigned to:

declarationForm: defineDeclarationForm({...})

inside your EventConfigInput.


1. What defineDeclarationForm() does

defineDeclarationForm():

  • Ensures your declaration form follows the required v2 structure

  • Guarantees consistent typing for pages, groups and fields

  • Applies Zod validation so invalid form structures fail at service startup rather than in production

  • Produces the exact shape the events engine expects in EventConfigInput

It’s essentially a form builder with strong compile-time and runtime validation.


2. Structure of defineDeclarationForm()

It accepts a single config object:

Let’s break down the example:


3. label — The translated heading of the form

This is a TranslationConfig object.

It defines the name that appears at the top of the declaration flow.

Important notes:

  • defaultMessage is what appears in English (or fallback language)

  • id should be a proper translation key (e.g. "event.birth.declarationForm.label")

  • description is for translators

This label is shown in various parts of the UI, including on page headers.


4. pages — The ordered list of pages in the declaration form

The array contains Page Config Objects, each representing a “page” of the declaration form.

A Page represents:

  • One screen in the UI

  • Containing one or more groups

  • Each group containing one or more fields

Pages are defined separately (e.g. in deathIntroduction.ts, deceased.ts, etc.) so you can re-use them or swap them between events.

Why the order matters

The order you put the pages in this array determines how the registrar navigates the form:

  1. deathIntroduction

  2. deceased

  3. eventDetails

  4. informant

  5. documents

The form wizard/navigation follows this order exactly.


5. What a page looks like

A page config typically looks like:

defineDeclarationForm() collects all pages and produces the full declaration form schema.


Build each page from the available form fields

To configure fields for a page, you’re basically building an array of FieldConfig objects, one per UI control, using the FieldType enum to pick the widget and then layering on:

  • label & translation

  • required/optional

  • when it shows (conditionals)

  • how it’s validated

  • extra config (options, address config, etc.)

I’ll use your mother’s page as a worked example and generalise as we go.


1. What a field actually is (FieldConfig + FieldType)

Each entry in the fields array is a FieldConfig — the schema that the v2 engine uses to render and validate the control. The core type lives in FieldConfig.ts, and the allowed type values are defined by FieldType.ts (e.g. TEXT, CHECKBOX, NAME, AGE, ADDRESS, COUNTRY, SELECT, ID, DIVIDER, PARAGRAPH, etc.)

At minimum you’ll always set:

  • id – unique within the form; also becomes the path used in conditionals and validators (field('mother.dob'))

  • type – one of FieldType (or 'DATE' string in your example – effectively the same)

  • label – a TranslationConfig for the UI text

Then you add optional behaviour:

  • required, hideLabel, analytics, secured

  • conditionals – show/hide and review behaviour

  • validation – per-field and cross-field rules

  • configuration – extra settings depending on field type

  • options – for selects

  • defaultValue


2. Step-by-step: designing the fields for a page

Step 1 – Decide the data + IDs

For each piece of data you want on the page, pick a stable ID.

Using the mother’s page for birth as an example:

  • mother.detailsNotAvailable

  • mother.reason

  • mother.name

  • mother.dob

  • mother.dobUnknown

  • mother.age

  • mother.nationality

  • mother.idType

  • mother.nid, mother.passport, mother.brn

  • mother.address, mother.addressHelper, mother.addressDivider1, mother.addressDivider2

  • mother.maritalStatus, mother.educationalAttainment, mother.occupation, mother.previousBirths

This mother.* convention is what makes the conditions and validators readable (field('mother.age'), field('mother.idType'), etc.).

When you define a page for another role (father, informant, deceased, spouse), follow the same pattern: father.*, informant.*, etc.


Step 2 – Choose the FieldType for each input

Given the ID + domain meaning, pick a FieldType:

  • Booleans / togglesFieldType.CHECKBOX

    • e.g. mother.detailsNotAvailable, mother.dobUnknown

  • Free textFieldType.TEXT

    • e.g. mother.reason, mother.occupation

  • NamesFieldType.NAME (composite widget with first/middle/last etc.)

    • mother.name

  • DatesFieldType.DATE (in your example it’s 'DATE', but in code you’d normally use the enum)

    • mother.dob

  • Numeric valuesFieldType.AGE or FieldType.NUMBER

    • mother.age (AGE), mother.previousBirths (NUMBER)

  • Select listsFieldType.SELECT

    • mother.idType, mother.maritalStatus, mother.educationalAttainment

  • CountryFieldType.COUNTRY

    • mother.nationality

  • ID-like textFieldType.ID or FieldType.TEXT depending on validation needs

    • mother.nid (ID), mother.passport (TEXT), mother.brn (TEXT)

  • AddressFieldType.ADDRESS (composite with nested validators)

    • mother.address

  • Layout / helpers (no data)FieldType.DIVIDER, FieldType.PARAGRAPH

    • mother.details.divider, mother.addressDivider1, mother.addressDivider2, mother.addressHelper

This list is not exhaustive, but a good example of common fields used in the field palette you get from FieldType.ts — you pick whichever widget makes sense for that piece of data.

A full recipe of how to use each field isnt available at present, but we will build each recipe out in documentation over time, starting with the Address component.


Step 3 – Add labels (TranslationConfig)

Every field gets a label:

Patterns worth keeping:

  • defaultMessage is human-readable English

  • description helps translators

  • id follows your event + section naming convention

For layout-only fields (DIVIDER) you reuse emptyMessage; for helper headings (PARAGRAPH) you give a more presentational label and optional configuration.styles.


Step 4 – Required / secured / analytics / hideLabel

You then decide how the field behaves e.g.:

  • required: true – must be filled when it is mandatory or optional

    • e.g. mother.name, mother.dob or mother.age, mother.idType, mother.address

  • secured: true – treated as sensitive PII (e.g. DOB, address)

    • e.g. mother.dob, mother.address

  • analytics: true – flagged for metrics / reporting meaning the field will be accessible in Metabase

    • e.g. mother.dob, mother.age, mother.maritalStatus, mother.educationalAttainment, mother.occupation, mother.previousBirths

  • hideLabel: true – when the control has an implicit label (e.g. NAME with inline placeholders) and you don’t want an extra label line

    • e.g. mother.name

This is where you encode business importance and privacy for each field.


Step 5 – Conditional visibility (conditionals)

This is the big one: when should each field show?

You use conditionals: [] with:

  • type: ConditionalType.SHOW – controls visibility in the main form

  • type: ConditionalType.DISPLAY_ON_REVIEW – controls visibility in the review pane

  • conditional: ... – expression built using helpers like field(), user(), and(), or(), not(), never()

Some patterns from the mother example:

5.1. Entire “mother details not available” branch

Key ideas:

  • If the informant is the mother, you don’t even show the “details not available” checkbox (we assume they know their own details).

  • On the review screen, only show this checkbox when it is true.

Same logic is reused for:

  • mother.details.divider – only visible when informant is not the mother

  • mother.reason – only visible if detailsNotAvailable is true

5.2. “Mother details must be captured” branch

Most fields use a requireMotherDetails helper:

Where requireMotherDetails is (roughly) “mother details are required for this record”, factoring in:

  • whether informant is mother, and

  • whether the “details not available” checkbox is ticked

This keeps field configs readable and DRY.

5.3. Mutually exclusive DOB / Age logic

  • mother.dob shows when:

    • mother.dobUnknown is not true, and

    • requireMotherDetails is true

  • mother.age shows when:

    • mother.dobUnknown is true, and

    • requireMotherDetails is true

So users either give an exact date of birth or an age as of child.dob.

5.4. ID type switching

  • mother.nid shows when mother.idType === NATIONAL_ID

  • mother.passport shows when mother.idType === PASSPORT

  • mother.brn shows when mother.idType === BIRTH_REGISTRATION_NUMBER

This is a classic pattern you’ll reuse elsewhere: pick an ID type via a SELECT, then show the relevant input field.


Step 6 – Validation rules (validation)

Each field can have multiple validation rules:

Patterns in the mother page:

  • Simple date rulemother.dob must be before today.

  • Cross-field date rulemother.dob must be before child.dob.

Age field:

National ID:

Here you see:

  • A reusable nationalIdValidator

  • A cross-field uniqueness check vs father and informant

Address:

The address field uses:

  • a top-level “leaf level” check (ensuring the lowest administrative level is selected)

  • nested validators for street address sub-fields

When you build pages for other entities, you follow the same pattern: use core’s field helpers plus any custom validators to enforce your country’s rules.


Step 7 – Field-specific configuration and options

Some field types accept extra configuration:

  • NAME:

  • AGE:

  • ADDRESS:

  • PARAGRAPH:

For selects:

  • idTypeOptions, maritalStatusOptions, educationalAttainmentOptions are arrays of { value, label } or similar, defined elsewhere with translation support.

This is where you plug in country-specific enumerations and UI presentation tweaks.


Step 8 – Defaults (defaultValue)

Finally, you can pre-fill fields:

  • mother.nationality defaults to 'FAR':

  • mother.address defaults to:

    • country: 'FAR'

    • addressType: DOMESTIC

    • admin area: user’s district:

This improves UX and ensures fields have sensible values for analytics.


3. Recipe for configuring fields on any page

For each page (mother, father, informant, deceased, etc.):

  1. List the information you need for that actor.

  2. Choose a stable ID for each piece (<actor>.<property>).

  3. Pick a FieldType from FieldType.ts that best matches the UI shape.

  4. Add a label with proper defaultMessage and translation id.

  5. Set behaviour flags:

    • required, secured, analytics, hideLabel where appropriate.

  6. Configure conditionals:

    • SHOW + logical helpers (field(), and, or, not, never)

    • optionally DISPLAY_ON_REVIEW if review view should differ

  7. Add validation rules:

    • simple type rules (date, number, length)

    • cross-field rules (mother vs child, uniqueness constraints)

    • nested rules for ADDRESS, etc.

  8. Add configuration and options for complex widgets:

    • NAME, AGE, ADDRESS, SELECT, PARAGRAPH styling, etc.

  9. Set defaultValue where it makes sense (country, address, etc.).

That’s essentially how you configure form fields for each page.

Last updated