Skip to content

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 is arrayJoin({ arrays: ["${page.levels}", "${page.newItemWrapped}"] }) where page.newItemWrapped is a one-element array built from your form's own fields.
  • arrayRemoveWhere — removes the first (or, with all:true, every) item from an array whose field equals value. 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.