The idea is to explore alternative ways to manage form config and hopefully make PRs easier to review. This has been a bit of a long-standing pain point for us and perhaps we’re not the only ones running into it.
In our setup, it usually means:
- downloading the XLSX file, or
- pulling the branch locally and spinning this up
And that’s just to understand what changed.
We do try to soften this by adding:
- screenshots
- descriptions
- linked tickets / specs
but that all takes time, and during high-churn periods things can slip. When that happens, it becomes genuinely hard to answer simple questions like:
- what actually changed here?
- what else could have been impacted?
We also try to distribute review load to whoever is willing and has capacity (not just the core project folks), which is great, but then it should be straightforward for them to jump in and contribute. More often than not, they don’t have the project checked out which means download each file manually.
The goal is pretty simple, make form config changes a little easier to understand at a glance in a PR. Without requiring:
- local setup
- file downloads
To do this, one possible avenue is to allow for an alternative way to define form config, using JSON (with comments), and then convert that back into the existing XLSX → XML pipeline.
I ended up using jsonc-parser and xlsx. An alternative to the former is json5 which, in addition to supporting comments, also removes the need to quote the entry keys & supports multi line strings (can be very nice for large calculation strings).
Going in this direction would help with:
- seeing diffs directly in github/az devops
- potentially structure things more clearly (you’re forced to consider nesting)
- comments would allow for capturing some of the “why” and not just the “what/how”
- lower barrier for occasional reviewers to contribute meaningfully
One thing that’s very important to note is that this does NOT replace the current pipeline. It feeds into it. The JSONC (or potentially JSON5) config gets converted to XLSX. From there everything continues as per normal. Existing transformations, validations, and XML generation. And only the XML gets uploaded to the DB.
This means:
- no major rewrites to the tooling
- easy to fall back to pure xlsx if needed (can just grab the output at any time if JSON no longer facilitates needs)
- keeps compatibility with everything that already works
Of course, getting the above does not come without “cost”:
- it does introduce some overhead in converting the JSON tree structure to flat XLSX
- slight increase in tool size due to added dependencies
The .jsonc content I used to test and successfully push a working form to the client:
{
"survey": {
"inputs": {
"type": "group",
"appearance": "hidden",
"children": {
"user": {
"type": "group",
"children": {
"contact_id": { "type": "string", "label": "NO_LABEL" },
"facility_id": { "type": "string", "label": "NO_LABEL" },
"name": { "type": "string", "label": "NO_LABEL" }
}
}
}
},
// Comments are supported in this file type
"capture": {
"type": "group",
"children": {
"form_start": {
"type": "start",
"label::en": "This field captures when the form is opened. It will be used in conjunction with the `reported_date` to calculate form completion duration."
},
"lookup": {
"type": "group",
"appearance": "hidden",
"children": {
"_id": {
"type": "string",
"appearance": "select-contact type-dwelling",
"calculation": "../../../PLACE_TYPE/parent",
"label": "NO_LABEL"
},
"name": { "type": "string", "label": "NO_LABEL" },
"parent_place_name": {
"type": "string",
"calculation": "../name"
}
}
},
"note_place": {
"type": "note",
"label::en": "Belongs to **${parent_place_name}**"
},
"should_create_login_user": {
"type": "calculate",
"calculation": "string(../../PLACE_TYPE/_id) = ''"
},
"prev_first_name": {
"type": "calculate",
"calculation": "once(../../PLACE_TYPE/first_name)"
},
"prev_last_name": {
"type": "calculate",
"calculation": "once(../../PLACE_TYPE/last_name)"
},
"prev_email": {
"type": "calculate",
"calculation": "once(../../PLACE_TYPE/email)"
},
"prev_phone": {
"type": "calculate",
"calculation": "once(../../PLACE_TYPE/phone)"
},
"first_name": {
"type": "string",
"label::en": "First Name",
"required": true,
"constraint": "string-length(.) <= 20",
"constraint_message::en": "A first name can be maximum 20 characters long.",
"default": "once(${prev_first_name})"
},
"last_name": {
"type": "string",
"label::en": "Surname/Last Name",
"required": true,
"constraint": "string-length(.) <= 30",
"constraint_message::en": "A surname/last name can be maximum 30 characters long.",
"default": "once(${prev_last_name})"
},
"email": {
"type": "string",
"label::en": "Email Address",
"constraint": "regex(., \".+[@].+[\\\\.].+\")",
"constraint_message::en": "Please enter a valid email address, which contains an @ sign followed by an email provider.",
"default": "once(${prev_email})"
},
"phone_raw": {
"type": "string",
"label::en": "Cell Number",
"required": true,
"constraint": "regex(., \"^(?:\\\\+27|0)\\\\d{9}$\")",
"constraint_message::en": "Please enter a number starting with zero (0) or a plus sign (+) followed by 27.",
"default": "once(${prev_phone})"
},
"phone": {
"type": "calculate",
"relevant": "string-length(${phone_raw}) > 9",
"calculation": "if(starts-with(${phone_raw}, \"+27\"), ${phone_raw}, concat(\"+27\", substr(${phone_raw}, 1, string-length(${phone_raw}))))"
},
"__cht_include-location": {
"type": "group",
"appearance": "location",
"children": {
"__cht_include_input_param-my_input_value": {
"type": "calculate",
"calculation": "5 + 5"
},
"__cht_include_output_param-full_address": {
"type": "calculate",
"calculation": "10 + 10"
},
"__cht_include_output_param-summary": {
"type": "calculate",
"calculation": "20 + 20"
}
}
},
"test_output1": {
"type": "note",
"label::en": "Section output 1",
"calculation": "string(../__cht_include-location/__cht_include_output_param-full_address)"
},
"test_output2": {
"type": "note",
"label::en": "Section output 2",
"calculation": "string(../__cht_include-location/__cht_include_output_param-summary)"
}
}
},
"PLACE_TYPE": {
"type": "group",
"children": {
"_id": { "type": "hidden" },
"parent": { "type": "hidden", "default": "PARENT" },
"type": { "type": "hidden", "default": "person" },
"first_name": {
"type": "calculate",
"calculation": "../../capture/first_name"
},
"last_name": {
"type": "calculate",
"calculation": "../../capture/last_name"
},
"name": {
"type": "calculate",
"calculation": "join(' ', ../first_name, ../last_name)"
},
"email": {
"type": "calculate",
"calculation": "../../capture/email"
},
"phone": {
"type": "calculate",
"calculation": "../../capture/phone"
},
"role": {
"type": "hidden",
"default": "chw"
},
"user_for_contact": {
"type": "group",
"relevant": "string(../_id) = ''",
"children": {
"create": {
"type": "calculate",
"calculation": "${should_create_login_user}"
}
}
},
"meta": {
"type": "group",
"appearance": "hidden",
"children": {
"created_by": {
"type": "calculate",
"calculation": "../../../inputs/user/name"
},
"created_by_person_uuid": {
"type": "calculate",
"calculation": "../../../inputs/user/contact_id"
},
"created_by_place_uuid": {
"type": "calculate",
"calculation": "../../../inputs/user/facility_id"
},
"last_edited_by": {
"type": "calculate",
"calculation": "if(string(../../_id) != '', ../../../inputs/user/name, .)"
},
"last_edited_by_person_uuid": {
"type": "calculate",
"calculation": "if(string(../../_id) != '', ../../../inputs/user/contact_id, .)"
},
"last_edited_by_place_uuid": {
"type": "calculate",
"calculation": "if(string(../../_id) != '', ../../../inputs/user/facility_id, .)"
},
"last_edited_on": {
"type": "calculate",
"calculation": "if(string(../../_id) != '', now(), .)"
}
}
},
"form_start": {
"type": "calculate",
"calculation": "../../capture/form_start"
}
}
}
},
"choices": {
// Can have some user defined columns that act as cascade filters
"_filters":["something"],
"yes_no": {
"yes": {
"label::en": "Yes",
},
"no": {
"label::en": "No",
},
"na": {
"label::en": "Not asked",
"something": "1"
}
}
},
"settings": {
"form_title": "New PLACE_TYPE",
"form_id": "contact:PLACE_TYPE:create",
"version": "2026-04-01 01-35",
"default_language": "en"
}
}
app_settings.json tweak to surface form:
{
"id": "test",
"name_key": "contact.type.test",
"group_key": "contact.type.test.plural",
"create_key": "contact.type.test.new",
"edit_key": "contact.type.test.edit",
"parents": [
"dwelling"
],
"icon": "wcg-dwelling",
"create_form": "form:contact:test:create",
"person": true
},
Form on client:
Would really appreciate some thoughts on this. Code can be found here.
