Appearance
4. The equipment register page
The full page file is large — every widget is spelled out — so read equipment-register.json open in another tab. This chapter walks its structure section by section.
The wrapper
json
{
"name": "equipment-register",
"description": "Office Equipment tutorial - Equipment Register.",
"definition": {
"contractVersion": 1,
"id": "office-equipment.equipment-register",
"version": "1.0.0",
"publisher": "office-equipment",
"title": "office-equipment.equipment-register.title",
"usesAppShell": true,
"route": { "pattern": "/office-equipment/equipment-register", "params": [] },
"designer": {
"displayNameKey": "office-equipment.equipment-register.title",
"descriptionKey": "office-equipment.equipment-register.title",
"icon": "devices", "category": "list", "propertyGroups": [], "preview": { "kind": "page" }
},
"rows": [ { "id": "row-1", "columns": [ { "id": "col-1", "items": [ /* the 6 sections */ ] } ] } ]
},
"metadata": { "dataSource": "oeq-equipment-provider" },
"modules": ["office-equipment"]
}metadata.dataSource: "oeq-equipment-provider"— the whole grid wiring. It matches the provider file'sname. Page-level.modules: ["office-equipment"]— the modulename, so the page lists in the sidenav.idmust be<pluginId>.<name>.
Section 1 — the mount loader
A hidden core.container whose mounted event calls the KPI composite and seeds page state:
json
"events": { "mounted": { "source": "action-chain", "actions": [
{ "id": "a0", "order": 0, "type": "callApi",
"config": { "connectionRef": "self", "path": "/api/v1/data-services/oeq-equipment-kpis/execute", "httpMethod": "POST", "params": {} },
"output": "kpi" },
{ "id": "a1", "order": 1, "type": "setValue", "config": { "field": "page.kpiTotal", "value": "${kpi.results.total.value}" } },
{ "id": "a2", "order": 2, "type": "setValue", "config": { "field": "page.kpiAvailable", "value": "${kpi.results.available.value}" } },
{ "id": "a3", "order": 3, "type": "setValue", "config": { "field": "page.kpiAssigned", "value": "${kpi.results.assigned.value}" } },
{ "id": "a4", "order": 4, "type": "setValue", "config": { "field": "page.gridRefreshTick", "value": 0 } },
{ "id": "a5", "order": 5, "type": "setValue", "config": { "field": "page.formDialogOpen", "value": false } }
] } }${kpi.results.total.value} — composite response shape. This was verified live.
Section 2 — the header
A flex core.container with the title/subtitle labels on the left and two buttons on the right: Export CSV (a downloadFile action to /api/v1/entities/oeq_equipment/records/export?format=csv) and New (clears the form keys, opens the dialog).
Section 3 — the KPI row
Three core.kpi-card blocks bound to page.kpiTotal / kpiAvailable / kpiAssigned, each with a labelKey, format: "number", colorToken, icon.
Section 4 — the filter row
A core.text-input bound to page.gridExternalFilter.search and a core.select bound to page.gridExternalFilter.status. Each has a committed handler writing ${event.new} back to its key.
Section 5 — the grid
json
{
"blockType": "core.grid",
"properties": {
"columns": { "source": "static", "value": [
{ "name": "asset_tag", "type": "string", "headerKey": "office-equipment.equipment-register.column.asset_tag" },
{ "name": "name", "type": "string", "headerKey": "...column.name" },
{ "name": "category_name", "type": "string", "headerKey": "...column.category_name" },
{ "name": "serial_number", "type": "string", "headerKey": "...column.serial_number" },
{ "name": "status", "type": "string", "headerKey": "...column.status", "renderAs": "chip",
"colorMap": { "AVAILABLE": "success", "ASSIGNED": "info", "MAINTENANCE": "warning", "RETIRED": "default" } }
] },
"pageSize": { "source": "static", "value": 25 },
"externalFilter": { "source": "binding", "binding": { "scope": "page", "key": "gridExternalFilter" } },
"refreshTrigger": { "source": "binding", "binding": { "scope": "page", "key": "gridRefreshTick" } }
}
}The grid has no data property — it reads the page's metadata.dataSource. category_name shows the denormalized value.
Section 6 — the create dialog
A core.dialog bound to page.formDialogOpen. Inside: text inputs for asset tag, name, serial number, notes; a core.select for status; core.date-picker for the two dates; and a core.lookup for the category:
json
{
"blockType": "core.lookup",
"properties": {
"value": { "source": "binding", "binding": { "scope": "page", "key": "formCategoryId" } },
"recordType": { "source": "static", "value": "OeqCategory" },
"optionsSourceKey": { "source": "static", "value": "oeq-category-search" }
},
"events": { "committed": { "source": "action-chain", "actions": [
{ "id": "a0", "order": 0, "type": "setValue", "config": { "field": "page.formCategoryId", "value": "${event.new}" } }
] } }
}The Save button POSTs to /api/v1/entities/oeq_equipment/records with a params map built from ${page.form*}, closes the dialog, toggles page.gridRefreshTick, and shows a toast.
Verify
bash
erp plugin test example-plugin/spk-assembly✓ page:equipment-register.json
4 passed, 0 failed, 0 semantic warning(s), 3 skipped0 semantic warning(s) means PAGE-DS-001 (data source wiring) and I18N-001 (missing keys) both passed. After publish, drive the Save chain from the API:
bash
$ erp api post "/api/v1/entities/oeq_equipment/records" \
--body '{"asset_tag":"LAP-001","name":"Dell Latitude 7440","category_id":1,"category_name":"Laptops","status":"AVAILABLE"}'
{ "id": 1, "asset_tag": "LAP-001", "name": "Dell Latitude 7440", "status": "AVAILABLE",
"created_by": "you@example.com", "created_at": "2026-09-10T08:08:33.282+00:00" }That row now appears in the grid.