Exploring modular forms

Improving forms has been a frequent topic of conversation. More specifically, reducing the need to re-implement the same functionality across multiple forms in a project config.

Being able to extract these repeating pieces into reusable form building blocks should make them easier to maintain. Or at least, that’s the goal. Fix an issue or expand functionality in one place, and have it take effect everywhere.

I took a stab at exploring this idea (or at least getting the conversation going) with this POC. It’s still fairly rudimentary, I wanted to check my thinking before I sink too much time into it.

The thinking went as follows:

  • Form sections (or modular parts) should be captured in the same format as existing forms.
    Apart from keeping things familiar, it also allows sections to benefit from the same validation as the OG forms. Perhaps also opens the door to testing sections in isolation (though I’m not entirely sure how valuable that is yet).
  • Sections/modules should be shareable between app and contact forms.
    This functionality is implemented on the form conversion lib level and makes use of the xml libraries for content manipulation.
  • Module-related variables should follow a strict naming convention to:
    • clearly communicate intent
    • explicitly define behaviour
    • make fields easy to identify for app report filtering and downstream processing (everything is saved in app form submissions)

Variable syntax

Taking inspiration from medic-xpath-extensions (and a sprinkle of Python-style private variables):

Name Type Description
__cht_include-<module_ref> group Indicates that a module should be inserted. The suffix (after -) is expected to map directly to the module name.
__cht_include_input_param-<var_name> calculate Allows the implementing form to override values within the module. Useful for skip logic, constraints, defaults, etc.
__cht_include_output_param-<var_name> calculate Exposes values from the module for use in the implementing form (e.g. further calculations or persistence).

For example:

We include the location module, pass in some input values, and expose outputs for later use in the form.

implementing form except:

Type Name Calculation
begin group __cht_include-location
calculate __cht_include_input_param-my_input_value 5 + 5
calculate __cht_include_output_param-full_address 10 + 10
calculate __cht_include_output_param-summary 20 + 20
end group __cht_include-location
note test_output1 string(../__cht_include-location/__cht_include_output_param-full_address)
note test_output2 string(../__cht_include-location/__cht_include_output_param-summary)

Module (conceptual XLSXForm)

Note: __cht_include_input_param should be calculate, but is shown as string here purely to visualize value injection.
Furthermore, as explained in a section further below, at the moment we’re processing hardcoded dummy xml but below is what that content would look like converted to xlsx.

Type Name Calculation
string __cht_include_input_param-my_input_value 77
begin group capture
string address
begin group details
string street
string postal_code
end group details
end group capture
calculate __cht_include_output_param-full_address concat(concat(../capture/address, ../capture/details/street, ../capture/details/postal_code))
calculate __cht_include_output_param-summary ../capture/address

Results:

Before:

After:

XML produced by cht-conf

<?xml version="1.0"?>
<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml" xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:jr="http://openrosa.org/javarosa" xmlns:orx="http://openrosa.org/xforms" xmlns:odk="http://www.opendatakit.org/xforms">
  <h:head>
    <h:title>New CHW</h:title>
    <model odk:xforms-version="1.0.0">
      <itext>
        <translation lang="en">
          <text id="/data/capture/form_start:label">
            <value>This field captures when the form is opened. It will be used in conjunction with the `reported_date` to calculate form completion duration. This is because the `end` question type has a known bug: https://forum.communityhealthtoolkit.org/t/start-end-xlsform-meta-fields-returns-same-value/3416</value>
          </text>
          <text id="/data/capture/note_place:label">
            <value> Belongs to **<output value=" /data/capture/lookup/parent_place_name "/>**</value>
          </text>
          <text id="/data/capture/first_name:jr:constraintMsg">
            <value>A first name can be maximum 20 characters long.</value>
          </text>
          <text id="/data/capture/first_name:label">
            <value>First Name</value>
          </text>
          <text id="/data/capture/last_name:jr:constraintMsg">
            <value>A surname/last name can be maximum 30 characters long.</value>
          </text>
          <text id="/data/capture/last_name:label">
            <value>Surname/Last Name</value>
          </text>
          <text id="/data/capture/email:jr:constraintMsg">
            <value>Please enter a valid email address, which contains an @ sign followed by an email provider. For example: name@gmail.com.</value>
          </text>
          <text id="/data/capture/email:label">
            <value>Email Address</value>
          </text>
          <text id="/data/capture/phone_raw:jr:constraintMsg">
            <value>Please enter a number starting with zero (0) or a plus sign (+) followed by 27. For example: 0xx xxx xxxx OR +27xx xxx xxxx</value>
          </text>
          <text id="/data/capture/phone_raw:label">
            <value>Cell Number</value>
          </text>
          <text id="/data/capture/test_output1:label">
            <value>Section output 1</value>
          </text>
          <text id="/data/capture/test_output2:label">
            <value>Section output 2</value>
          </text>
        </translation>
      </itext>
      <instance>
        <data id="contact:chw:create" version="2026-03-24 10-30" prefix="J1!contact:chw:create!" delimiter="#">
          <inputs>
            <meta>
              <location>
                <lat/>
                <long/>
                <error/>
                <message/>
              </location>
            </meta>
            <user>
              <contact_id/>
              <facility_id/>
              <name/>
            </user>
          </inputs>
          <chw>
            <_id/>
            <parent>PARENT</parent>
            <type>person</type>
            <first_name/>
            <last_name/>
            <name/>
            <email/>
            <phone/>
            <role>chw</role>
            <user_for_contact>
              <create/>
            </user_for_contact>
            <meta tag="hidden">
              <created_by/>
              <created_by_person_uuid/>
              <created_by_place_uuid/>
              <last_edited_by/>
              <last_edited_by_person_uuid/>
              <last_edited_by_place_uuid/>
              <last_edited_on/>
            </meta>
            <form_start/>
          </chw>
          <capture>
            <form_start/>
            <lookup>
              <_id/>
              <name/>
              <parent_place_name/>
            </lookup>
            <note_place/>
            <should_create_login_user/>
            <prev_first_name/>
            <prev_last_name/>
            <prev_email/>
            <prev_phone/>
            <first_name/>
            <last_name/>
            <email/>
            <phone_raw/>
            <phone/>
            <__cht_include-location>
              <__cht_include_input_param-my_input_value/>
              <__cht_include_output_param-full_address/>
              <__cht_include_output_param-summary/>
              <capture>
                <address/>
                <details>
                  <street/>
                  <postal_code/>
                </details>
              </capture>
            </__cht_include-location>
            <test_output1/>
            <test_output2/>
          </capture>
          <meta tag="hidden">
            <instanceID/>
          </meta>
        </data>
      </instance>
      <bind nodeset="/data/inputs" relevant="false()"/>
      <bind nodeset="/data/inputs/user/contact_id" type="string"/>
      <bind nodeset="/data/inputs/user/facility_id" type="string"/>
      <bind nodeset="/data/inputs/user/name" type="string"/>
      <bind nodeset="/data/capture/form_start" jr:preload="timestamp" type="dateTime" jr:preloadParams="start"/>
      <bind nodeset="/data/capture/lookup/_id" type="string" calculate="../../../chw/parent"/>
      <bind nodeset="/data/capture/lookup/name" type="string"/>
      <bind nodeset="/data/capture/lookup/parent_place_name" type="string" calculate="../name"/>
      <bind nodeset="/data/capture/note_place" readonly="true()" type="string"/>
      <bind nodeset="/data/capture/should_create_login_user" type="string" calculate="string(../../chw/_id) = ''"/>
      <bind nodeset="/data/capture/prev_first_name" type="string" calculate="once(../../chw/first_name)"/>
      <bind nodeset="/data/capture/prev_last_name" type="string" calculate="once(../../chw/last_name)"/>
      <bind nodeset="/data/capture/prev_email" type="string" calculate="once(../../chw/email)"/>
      <bind nodeset="/data/capture/prev_phone" type="string" calculate="once(../../chw/phone)"/>
      <bind nodeset="/data/capture/first_name" type="string" required="true()" constraint="string-length(.) &lt;= 20" jr:constraintMsg="jr:itext('/data/capture/first_name:jr:constraintMsg')" calculate="once( /data/capture/prev_first_name )"/>
      <bind nodeset="/data/capture/last_name" type="string" required="true()" constraint="string-length(.) &lt;= 30" jr:constraintMsg="jr:itext('/data/capture/last_name:jr:constraintMsg')" calculate="once( /data/capture/prev_last_name )"/>
      <bind nodeset="/data/capture/email" type="string" constraint="regex(., &quot;.+[@].+[\\.].+&quot;)" jr:constraintMsg="jr:itext('/data/capture/email:jr:constraintMsg')" calculate="once( /data/capture/prev_email )"/>
      <bind nodeset="/data/capture/phone_raw" type="string" required="true()" constraint="regex(., &quot;^(?:\+27|0)\d{9}$&quot;)" jr:constraintMsg="jr:itext('/data/capture/phone_raw:jr:constraintMsg')" calculate="once( /data/capture/prev_phone )"/>
      <bind nodeset="/data/capture/phone" type="string" relevant="string-length( /data/capture/phone_raw ) &gt; 9" calculate="if(starts-with( /data/capture/phone_raw , &quot;+27&quot;),  /data/capture/phone_raw ,concat(&quot;+27&quot;, substr( /data/capture/phone_raw , 1, string-length( /data/capture/phone_raw ))))"/>
      <bind nodeset="/data/capture/test_output1" readonly="true()" type="string" calculate="string(../__cht_include-location/__cht_include_output_param-full_address)"/>
      <bind nodeset="/data/capture/test_output2" readonly="true()" type="string" calculate="string(../__cht_include-location/__cht_include_output_param-summary)"/>
      <bind nodeset="/data/chw/_id" type="string"/>
      <bind nodeset="/data/chw/parent" type="string"/>
      <bind nodeset="/data/chw/type" type="string"/>
      <bind nodeset="/data/chw/first_name" type="string" calculate="../../capture/first_name"/>
      <bind nodeset="/data/chw/last_name" type="string" calculate="../../capture/last_name"/>
      <bind nodeset="/data/chw/name" type="string" calculate="join(' ', ../first_name, ../last_name)"/>
      <bind nodeset="/data/chw/email" type="string" calculate="../../capture/email"/>
      <bind nodeset="/data/chw/phone" type="string" calculate="../../capture/phone"/>
      <bind nodeset="/data/chw/role" type="string"/>
      <bind nodeset="/data/chw/user_for_contact" required="string(../_id) = ''"/>
      <bind nodeset="/data/chw/user_for_contact/create" type="string" calculate=" /data/capture/should_create_login_user "/>
      <bind nodeset="/data/chw/meta/created_by" type="string" calculate="../../../inputs/user/name"/>
      <bind nodeset="/data/chw/meta/created_by_person_uuid" type="string" calculate="../../../inputs/user/contact_id"/>
      <bind nodeset="/data/chw/meta/created_by_place_uuid" type="string" calculate="../../../inputs/user/facility_id"/>
      <bind nodeset="/data/chw/meta/last_edited_by" type="string" calculate="if(string(../../_id) != '', ../../../inputs/user/name, .)"/>
      <bind nodeset="/data/chw/meta/last_edited_by_person_uuid" type="string" calculate="if(string(../../_id) != '', ../../../inputs/user/contact_id, .)"/>
      <bind nodeset="/data/chw/meta/last_edited_by_place_uuid" type="string" calculate="if(string(../../_id) != '', ../../../inputs/user/facility_id, .)"/>
      <bind nodeset="/data/chw/meta/last_edited_on" type="string" calculate="if(string(../../_id) != '', now(), .)"/>
      <bind nodeset="/data/chw/form_start" type="string" calculate="../../capture/form_start"/>
      <bind nodeset="/data/meta/instanceID" type="string" readonly="true()" jr:preload="uid"/>
      <bind nodeset="/data/capture/__cht_include-location/__cht_include_input_param-my_input_value" type="string" calculate="5 + 5"/>
      <bind nodeset="/data/capture/__cht_include-location/__cht_include_output_param-full_address" type="string" calculate="concat(../capture/address, ../capture/details/street, ../capture/details/postal_code)"/>
      <bind nodeset="/data/capture/__cht_include-location/__cht_include_output_param-summary" type="string" calculate="../capture/address"/>
    </model>
  </h:head>
  <h:body>
    <group ref="/data/inputs">
      <group ref="/data/inputs/user">
        <input ref="/data/inputs/user/contact_id"/>
        <input ref="/data/inputs/user/facility_id"/>
        <input ref="/data/inputs/user/name"/>
      </group>
    </group>
    <group ref="/data/capture">
      <group appearance="hidden" ref="/data/capture/lookup">
        <input ref="/data/capture/lookup/_id" appearance="select-contact type-ff_household"/>
        <input ref="/data/capture/lookup/name"/>
        <input ref="/data/capture/lookup/parent_place_name"/>
      </group>
      <input ref="/data/capture/note_place">
        <label ref="jr:itext('/data/capture/note_place:label')"/>
      </input>
      <input ref="/data/capture/first_name">
        <label ref="jr:itext('/data/capture/first_name:label')"/>
      </input>
      <input ref="/data/capture/last_name">
        <label ref="jr:itext('/data/capture/last_name:label')"/>
      </input>
      <input ref="/data/capture/email">
        <label ref="jr:itext('/data/capture/email:label')"/>
      </input>
      <input ref="/data/capture/phone_raw">
        <label ref="jr:itext('/data/capture/phone_raw:label')"/>
      </input>
      <group ref="/data/capture/__cht_include-location">
        <input ref="/data/capture/__cht_include-location/__cht_include_input_param-my_input_value" readonly="true()">
          <label>Injected value:</label>
        </input>
        <group ref="/data/capture/__cht_include-location/capture">
          <!-- Actual user input -->
          <input ref="/data/capture/__cht_include-location/capture/address">
            <label>Enter address/location</label>
          </input>
          <group ref="/data/capture/__cht_include-location/capture/details">
            <input ref="/data/capture/__cht_include-location/capture/details/street">
              <label>Enter street</label>
            </input>
            <input ref="/data/capture/__cht_include-location/capture/details/postal_code">
              <label>Enter postal code</label>
            </input>
          </group>
        </group>
      </group>
      <input ref="/data/capture/test_output1">
        <label ref="jr:itext('/data/capture/test_output1:label')"/>
      </input>
      <input ref="/data/capture/test_output2">
        <label ref="jr:itext('/data/capture/test_output2:label')"/>
      </input>
    </group>
    <group ref="/data/chw">
      <group ref="/data/chw/user_for_contact"/>
      <group appearance="hidden" ref="/data/chw/meta"/>
    </group>
  </h:body>
</h:html>

Snippet of the functionality in action

Caveats/requirements:

In the module:

  • Variables must be declared at the root level.
    This voids name collisions (same var name in nested levels), simplifies replacement logic, and path resolution
  • Use relative paths or variable references (${})
    Preferably relative paths as ${} can introduce conflicts if names overlap in implementing form. Probably not a big concern, as it should be in the minority, but implementers should NOT depend on full path references.
  • External value dependencies must be passed via input params only.
    This ensures deterministic and predictable behaviour.

Functionality overview:

Processing a module and inserting it into a form happens in four steps. Before that, a quick recap of XForm XML structure:

Layer Path Description
Instance html/head/model/instance Data structure (fields, meta, persisted data)
Bind html/head/model Field logic (types, required, calculate, constraints)
UI html/body Layout, grouping, and appearance

1. Build include reference map

Scan the implementing form for all module references and build a collection of include definitions for downstream processing.

2. Update instance

  • Remove existing instance entries for the include group
  • Grab section/module instance entries
  • Minor filtering
  • Attach them under the main form instance group

(Choice sheet handling is not implemented yet)

3. Update bindings

  • Remove existing bind entries for the include group
  • Grab section/module model bind entries
  • Rebase node paths
  • Override calculate values using input params
  • Attach them under the main form model group

4. Update UI

  • Remove existing UI nodes for the include group
  • Recursively rebase paths (due to nesting)
  • Attachment them to the main form body group

Current Gaps in the POC

  • Loading, converting, and validating module forms
    (currently using hardcoded XML)
  • Choice sheet handling
  • Nested module inclusion (maybe a later feature)

ChinHairSaintClair/cht-conf at form-sections

2 Likes