Skip to content

Add a KPI or aggregation

What you're doing

Putting a number on a page — "12 total", "8 available" — computed server-side. The building block is a Data Service with operation: "count". A page's mount loader calls it and drops the result into page state; a core.kpi-card displays it.

For a row of several KPIs you wrap the counts in one composite Data Service so the page makes one call, not five.

The complete example

One count per metric

spk-assembly/metadata/data_service/oeq-equipment-count-total.json:

json
{
  "name": "oeq-equipment-count-total",
  "description": "Total equipment.",
  "definition": {
    "operation": "count",
    "source": { "kind": "entity", "entityName": "oeq_equipment" },
    "filters": [],
    "parameters": []
  },
  "metadata": {}, "modules": []
}

oeq-equipment-count-available.json is the same with a filter:

json
"filters": [ { "field": "status", "operator": "eq", "value": "AVAILABLE" } ]

filters on a count is a list of { field, operator, value }. Operators: eq, neq, gt, gte, lt, lte, contains. For "no groupBy, just a filtered number" this is the whole recipe.

The composite wrapper

oeq-equipment-kpis.json:

json
{
  "name": "oeq-equipment-kpis",
  "description": "Composite KPI fan-out for the Equipment Register page.",
  "definition": {
    "operation": "composite",
    "source": { "kind": "entity", "entityName": "oeq_equipment" },
    "steps": [
      { "as": "total",     "service": "oeq-equipment-count-total",     "parameters": {} },
      { "as": "available", "service": "oeq-equipment-count-available", "parameters": {} },
      { "as": "assigned",  "service": "oeq-equipment-count-assigned",  "parameters": {} }
    ]
  },
  "metadata": {}, "modules": []
}

The page mount loader

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}" } }
] } }

The KPI card

json
{
  "blockType": "core.kpi-card",
  "properties": {
    "value":    { "source": "binding", "binding": { "scope": "page", "key": "kpiTotal" } },
    "labelKey": { "source": "static", "value": "office-equipment.equipment-register.kpi.total" },
    "format":   { "source": "static", "value": "number" },
    "colorToken": { "source": "static", "value": "primary" },
    "icon":     { "source": "static", "value": "devices" }
  }
}

Line by line — the two traps

  1. The outputName: "value" / no-groupBy pattern. A count returns exactly { "value": <number> }. You do not get .total (that's the pagination envelope of the entity list endpoint, unrelated). A composite nests each step's envelope under results.<step.as>, so a composite count step is ${out.results.<as>.value}.

    you calledyou bind
    a bare count service${out.value}
    a composite of counts${out.results.<step>.value}
  2. Call once on mount, store in page state. The KPI card binds a page-state key, not the API directly. The mount loader is a hidden core.container; see Build a page.

Recomputing after a write

Add the same callApi + setValue steps to the end of your Save action chain, so creating a row updates the counts without a page reload.

How to verify it worked

bash
erp api post "/api/v1/data-services/oeq-equipment-kpis/execute" --body "{}"

Actual response from the tutorial module right after install (no rows yet):

json
{
  "value": null, "rows": null, "total": null, "record": null,
  "results": {
    "total":     { "value": 0 },
    "available": { "value": 0 },
    "assigned":  { "value": 0 }
  }
}

The top-level value/rows/total are always-present nulls for a composite — ignore them, read results.

Common mistakes

SymptomCauseFix
card shows undefinedbound ${out.value} on a compositeuse ${out.results.<step>.value}
card shows 0 when there is datacount filter value doesn't match the enum casingcheck typeParams.enumValues
every KPI is one HTTP callseparate callApi per count on mountwrap them in one composite
card never updates after a saveKPI steps not appended to the Save chainappend callApi + setValue there too