Appearance
An add/remove list editor (page-scope array, no backend round trip)
When you need this
A form or dialog where the user builds up a list before saving — approval levels, line items, notification rules, anything shaped like "fill in a few fields, click Add, see it appear below, click it again to remove it." The list lives in page scope only (e.g. page.levels) until the whole form is submitted in one callApi — you are not creating a record per row.
This is harder than it looks because the expression engine has no map/splice/filter function (checked before writing this — see @erp/expression-engine's function registry). You cannot reshape an existing array or remove one item from it with a plain expression. Use the two action-chain primitives built for exactly this instead of reaching for a JSON textarea as a workaround.
The two primitives
arrayJoin— merges arrays. Appending one new item isarrayJoin({ arrays: ["${page.levels}", "${page.newItemWrapped}"] })wherepage.newItemWrappedis a one-element array built from your form's own fields.arrayRemoveWhere— removes the first (or, withall:true, every) item from an array whosefieldequalsvalue. Give every appended item a real unique_id(uuid()) at add-time so removal is reliable even when the row's visible fields could repeat.
Both are real, shipped, tested action types (@erp/action-engine, src/handlers/arrayJoin.ts / arrayRemoveWhere.ts) — not something you register per page.
Add: wrap the new item, then join
json
[
{
"id": "wrap-new-level",
"type": "setValue",
"order": 0,
"config": {
"field": "page.newLevelWrapped",
"value": { "source": "expression", "expression": "[{\"_id\": uuid(), \"name\": page.newLevelName, \"approverType\": page.newLevelApproverType}]" }
}
},
{
"id": "add-level-join",
"type": "arrayJoin",
"order": 1,
"config": { "arrays": ["${page.levels}", "${page.newLevelWrapped}"] },
"output": "mergedLevels",
"onSuccess": [
{ "id": "add-level-set", "type": "setValue", "order": 0, "config": { "field": "page.levels", "value": "${api.mergedLevels}" } },
{ "id": "add-level-clear-name", "type": "setValue", "order": 1, "config": { "field": "page.newLevelName", "value": "" } }
]
}
]Wire this to a button's clicked event. Clear the "new item" fields in the same chain so the form is ready for the next entry.
Remove: wire the list's own selected event
core.list's selected event payload is ${event.item} — no index, so match on the _id you stamped at add-time:
json
{
"events": {
"selected": {
"source": "action-chain",
"actions": [
{
"id": "remove-level",
"type": "arrayRemoveWhere",
"order": 0,
"config": { "array": "${page.levels}", "field": "_id", "value": "${event.item._id}" },
"output": "filteredLevels",
"onSuccess": [{ "id": "set-filtered-levels", "type": "setValue", "order": 0, "config": { "field": "page.levels", "value": "${api.filteredLevels}" } }]
}
]
}
}
}Clicking a row in the list removes it. If you also need rows to be clickable for something other than delete (e.g. opening a detail view), this pattern doesn't compose with that on the same list — core. list has one selected event, not a per-action-icon affordance (there's no renderAs:"actions" equivalent on core.list the way core.grid has). Use two separate lists, or accept "click = remove" as the list's only interaction, the way this recipe does.
What this can't do
- Reordering items in place — no splice/index-insert primitive exists either. Only append and full-item removal.
- Editing an already-added item — the pattern here is add-only, remove-only. To support edit, you'd populate the "new item" fields from the clicked item, remove it, and let the user re-add it (a workable but disclosed UX compromise, not implemented automatically).
Real example
backend/modules/hcm-admin-console/spk-assembly/metadata/page/approval-hierarchies.json — the Levels/Conditions/Notifications tabs of the Approval Hierarchy editor all use this exact pattern.
