Appearance
Build a page
What you're doing
A page is one screen. It is a JSON file under spk-assembly/metadata/page/ describing a tree of blocks (widgets) arranged in rows and columns. The platform renders it; you never write React.
The house style for a "list" screen — the one you'll build most often — is a single column of stacked sections:
- a hidden loader block that fetches KPI counts when the page mounts
- a header row: title on the left, action buttons on the right
- a KPI row: a few
core.kpi-cardblocks - a filter row: a search box and a status dropdown
- a grid
- one or more dialogs for create / edit / detail
The complete example
The tutorial's equipment-register.json is a full, working instance of this pattern: the real file. It is large (every widget is spelled out), so read it alongside this guide rather than pasting it here. Its shape on disk:
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": [ /* blocks */ ] } ] }
]
},
"metadata": { "dataSource": "oeq-equipment-provider" },
"modules": ["office-equipment"]
}Line by line
The wrapper
name— the file's short name; unique within the plugin.definition— the actual page contract (contractVersion: 1).metadata.dataSource— the single field that wires the grid to its data. It names a Data Provider. It is page-level, never on the grid block. Get this wrong and the grid renders zero rows with no error. See Wire a page's data.modules— the module id(s) this page belongs to. The app shell only lists a page in the sidenav if it's here. Use your module'sname.
Inside definition
id— must be<pluginId>.<page-name>, dotted, lowercase.title— an i18n key (define it inmetadata/i18n/en.json).route.pattern— starts with/. This is an internal routing key, not the browser URL. The reachable URL is/app/<app-slug>/<module-slug>/<pluginId>/<page-name>.designer— required metadata for Studio.categoryis one ofworkspace | detail | list | dashboard | landing.propertyGroups: []andpreview: { "kind": "page" }are fine as-is.rows— an array of{ id, columns: [ { id, items: [...] } ] }. Eachitemis{ id, kind: "block", block: { ... } }.
A block
Every block instance looks like:
json
{
"contractVersion": 1,
"instanceId": "kpi-8",
"blockType": "core.kpi-card",
"blockVersion": "1.0.0",
"properties": {
"value": { "source": "binding", "binding": { "scope": "page", "key": "kpiTotal" } },
"labelKey": { "source": "static", "value": "office-equipment.equipment-register.kpi.total" },
"format": { "source": "static", "value": "number" }
}
}- Every property is
{ "source": "static", "value": ... }(a literal) or{ "source": "binding", "binding": { "scope": "page", "key": "..." } }(read from page state). pagestate is a bag of values you set withsetValueactions and read with bindings.kpiTotal,gridExternalFilter,formDialogOpenare all just keys you invented.
The mount loader
The first block is a core.container with hidden: true and a mounted event that runs an action chain — call the KPI data service, then setValue each result into page state:
json
{
"blockType": "core.container",
"properties": { "variant": { "source": "static", "value": "plain" },
"hidden": { "source": "static", "value": true } },
"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}" } }
]
}
}
}${kpi.results.total.value} is the response-shape for a composite data service — see Add a KPI.
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": "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" } }
}
}core.grid has no dataSource, entity, or recordType property — its data comes from the page's metadata.dataSource. externalFilter binds a page object whose keys become query filters; refreshTrigger re-runs the query whenever the bound value changes (toggle it with setValue page.gridRefreshTick = "${!page.gridRefreshTick}" after a save).
Ground yourself first
bash
erp blocks list # every block type
erp blocks list --type core.grid # one block's full property/event list
erp examples patterns --kind page # curated real page shapesHow to verify it worked
bash
erp plugin test office-equipment/spk-assembly✓ page:equipment-register.json
4 passed, 0 failed, 0 semantic warning(s), 3 skippedAfter publishing, confirm it registered:
bash
erp api get "/api/v1/authoring/pages?module=office-equipment"json
[ { "id": 11324, "name": "equipment-register", "status": "published",
"route": { "pageId": "office-equipment.equipment-register",
"pattern": "/office-equipment/equipment-register", "params": [] } } ]Then open /app/<app>/<module>/office-equipment/equipment-register in the ERP.
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
| grid shows 0 rows, no error | metadata.dataSource missing / misspelled / not a provider name | see Wire a page's data; erp plugin test flags this as PAGE-DS-001 |
| page not in the sidenav | modules array wrong or missing | set it to your module's name |
| KPI cards blank | wrong response path in the setValue | composite → ${out.results.<step>.value}; count → ${out.value} |
| text shows the raw i18n key | key missing from metadata/i18n/en.json | add it; erp plugin test flags I18N-001 |
| "route" is the browser URL — it isn't | confusing route.pattern with the reachable URL | URL is /app/<app>/<module>/<plugin>/<page> |