Skip to content

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:

  1. a hidden loader block that fetches KPI counts when the page mounts
  2. a header row: title on the left, action buttons on the right
  3. a KPI row: a few core.kpi-card blocks
  4. a filter row: a search box and a status dropdown
  5. a grid
  6. 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.dataSourcethe 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's name.

Inside definition

  • id — must be <pluginId>.<page-name>, dotted, lowercase.
  • title — an i18n key (define it in metadata/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. category is one of workspace | detail | list | dashboard | landing. propertyGroups: [] and preview: { "kind": "page" } are fine as-is.
  • rows — an array of { id, columns: [ { id, items: [...] } ] }. Each item is { 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).
  • page state is a bag of values you set with setValue actions and read with bindings. kpiTotal, gridExternalFilter, formDialogOpen are 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 shapes

How 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 skipped

After 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

SymptomCauseFix
grid shows 0 rows, no errormetadata.dataSource missing / misspelled / not a provider namesee Wire a page's data; erp plugin test flags this as PAGE-DS-001
page not in the sidenavmodules array wrong or missingset it to your module's name
KPI cards blankwrong response path in the setValuecomposite → ${out.results.<step>.value}; count → ${out.value}
text shows the raw i18n keykey missing from metadata/i18n/en.jsonadd it; erp plugin test flags I18N-001
"route" is the browser URL — it isn'tconfusing route.pattern with the reachable URLURL is /app/<app>/<module>/<plugin>/<page>