@sparkerp/plugin-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/bundle/blocks.json +12229 -0
  4. package/bundle/catalog.json +32475 -0
  5. package/bundle/docs/applications/hcm/employee-profile.md +144 -0
  6. package/bundle/docs/concepts/artifact-types.md +80 -0
  7. package/bundle/docs/concepts/glossary.md +124 -0
  8. package/bundle/docs/concepts/overview.md +88 -0
  9. package/bundle/docs/concepts/page-data-flow.md +84 -0
  10. package/bundle/docs/concepts/plugin-lifecycle.md +81 -0
  11. package/bundle/docs/concepts/sdk-modes.md +71 -0
  12. package/bundle/docs/docs.json +109 -0
  13. package/bundle/docs/guides/add-a-cadence-job.md +134 -0
  14. package/bundle/docs/guides/add-a-compliance-job.md +139 -0
  15. package/bundle/docs/guides/add-a-create-edit-form.md +167 -0
  16. package/bundle/docs/guides/add-a-custom-block.md +225 -0
  17. package/bundle/docs/guides/add-a-data-provider.md +216 -0
  18. package/bundle/docs/guides/add-a-kpi.md +154 -0
  19. package/bundle/docs/guides/add-a-reminder-job.md +181 -0
  20. package/bundle/docs/guides/add-an-aggregation-job.md +153 -0
  21. package/bundle/docs/guides/add-an-approval-workflow.md +222 -0
  22. package/bundle/docs/guides/add-an-entity.md +170 -0
  23. package/bundle/docs/guides/add-business-rules.md +147 -0
  24. package/bundle/docs/guides/add-i18n.md +121 -0
  25. package/bundle/docs/guides/add-menus.md +149 -0
  26. package/bundle/docs/guides/build-a-code-plugin.md +141 -0
  27. package/bundle/docs/guides/build-a-page.md +213 -0
  28. package/bundle/docs/guides/build-a-polyglot-service-plugin.md +201 -0
  29. package/bundle/docs/guides/build-a-tenant-extension-service.md +149 -0
  30. package/bundle/docs/guides/create-a-plugin.md +172 -0
  31. package/bundle/docs/guides/expose-a-plugin-operation-as-an-ai-tool.md +114 -0
  32. package/bundle/docs/guides/index.md +53 -0
  33. package/bundle/docs/guides/publish-and-upgrade.md +152 -0
  34. package/bundle/docs/guides/responsive-plugin.md +117 -0
  35. package/bundle/docs/guides/seed-data-on-install.md +127 -0
  36. package/bundle/docs/guides/set-up-the-sdk.md +120 -0
  37. package/bundle/docs/guides/use-the-mcp-server.md +104 -0
  38. package/bundle/docs/guides/validate-and-test.md +119 -0
  39. package/bundle/docs/guides/wire-a-pages-data.md +153 -0
  40. package/bundle/docs/quickstart/index.md +118 -0
  41. package/bundle/docs/recipes/condition-based-compliance-flag.md +70 -0
  42. package/bundle/docs/recipes/cross-plugin-offboarding-fanout.md +68 -0
  43. package/bundle/docs/recipes/cross-row-aggregation.md +67 -0
  44. package/bundle/docs/recipes/entity-with-approval-workflow.md +72 -0
  45. package/bundle/docs/recipes/index.md +40 -0
  46. package/bundle/docs/recipes/install-lifecycle-data-seeding.md +76 -0
  47. package/bundle/docs/recipes/kpi-dashboard-page.md +92 -0
  48. package/bundle/docs/recipes/master-detail-page.md +84 -0
  49. package/bundle/docs/recipes/n-days-before-reminder.md +72 -0
  50. package/bundle/docs/recipes/per-row-document-generator.md +65 -0
  51. package/bundle/docs/recipes/related-record-lookup-field.md +74 -0
  52. package/bundle/docs/recipes/resolved-reference-grid-column.md +63 -0
  53. package/bundle/docs/recipes/scheduled-record-generation.md +68 -0
  54. package/bundle/docs/reference/action.md +18 -0
  55. package/bundle/docs/reference/block-definition.md +35 -0
  56. package/bundle/docs/reference/block-instance.md +33 -0
  57. package/bundle/docs/reference/composite-pack-manifest.md +33 -0
  58. package/bundle/docs/reference/connector-definition.md +36 -0
  59. package/bundle/docs/reference/data-service-definition.md +22 -0
  60. package/bundle/docs/reference/data-view-definition.md +26 -0
  61. package/bundle/docs/reference/entity-aggregation-config.md +38 -0
  62. package/bundle/docs/reference/entity-cadence-config.md +33 -0
  63. package/bundle/docs/reference/entity-compliance-config.md +37 -0
  64. package/bundle/docs/reference/entity-cross-plugin-action-config.md +37 -0
  65. package/bundle/docs/reference/entity-definition.md +18 -0
  66. package/bundle/docs/reference/entity-document-generator-config.md +35 -0
  67. package/bundle/docs/reference/entity-rule-definition.md +29 -0
  68. package/bundle/docs/reference/entity-status-date-sweep-config.md +27 -0
  69. package/bundle/docs/reference/form.md +35 -0
  70. package/bundle/docs/reference/index.md +37 -0
  71. package/bundle/docs/reference/menu.md +26 -0
  72. package/bundle/docs/reference/mobile-nav.md +22 -0
  73. package/bundle/docs/reference/page-template.md +29 -0
  74. package/bundle/docs/reference/page.md +44 -0
  75. package/bundle/docs/reference/plugin-manifest.md +54 -0
  76. package/bundle/docs/reference/plugin-seed-data.md +21 -0
  77. package/bundle/docs/reference/print-template-definition.md +22 -0
  78. package/bundle/docs/reference/provider-definition.md +22 -0
  79. package/bundle/docs/reference/report-definition.md +22 -0
  80. package/bundle/docs/reference/rule.md +30 -0
  81. package/bundle/docs/reference/workflow-definition.md +28 -0
  82. package/bundle/docs/troubleshooting/grid-shows-zero-rows.md +82 -0
  83. package/bundle/docs/troubleshooting/job-never-runs.md +89 -0
  84. package/bundle/docs/troubleshooting/known-gotchas.md +193 -0
  85. package/bundle/docs/troubleshooting/publishing-errors.md +119 -0
  86. package/bundle/docs/tutorial/01-create-the-plugin.md +157 -0
  87. package/bundle/docs/tutorial/02-category-entity-and-seed.md +94 -0
  88. package/bundle/docs/tutorial/03-equipment-entity.md +121 -0
  89. package/bundle/docs/tutorial/04-equipment-register-page.md +153 -0
  90. package/bundle/docs/tutorial/05-checkout-entity-and-page.md +116 -0
  91. package/bundle/docs/tutorial/06-checkout-approval-workflow.md +139 -0
  92. package/bundle/docs/tutorial/07-return-due-reminder-job.md +129 -0
  93. package/bundle/docs/tutorial/08-menus-i18n-publish.md +123 -0
  94. package/bundle/docs/tutorial/index.md +72 -0
  95. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-aggregation-configs.json +18 -0
  96. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cadence-configs.json +9 -0
  97. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-compliance-configs.json +74 -0
  98. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cross-plugin-configs.json +9 -0
  99. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-document-configs.json +9 -0
  100. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-sweep-configs.json +260 -0
  101. package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/entities/hcm_salary_planning_entry.json +41 -0
  102. package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/page/salary-planning.json +1 -0
  103. package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/page/promotions.json +10944 -0
  104. package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.movement.json +44 -0
  105. package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.promotion-acknowledge.json +19 -0
  106. package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.resignation.json +29 -0
  107. package/bundle/examples/example-patterns.mjs +94 -0
  108. package/bundle/manifest.json +14 -0
  109. package/bundle/schemas/action.schema.json +102 -0
  110. package/bundle/schemas/block-definition.schema.json +222 -0
  111. package/bundle/schemas/block-instance.schema.json +139 -0
  112. package/bundle/schemas/composite-pack-manifest.schema.json +160 -0
  113. package/bundle/schemas/connector-definition.schema.json +29 -0
  114. package/bundle/schemas/data-service-definition.schema.json +64 -0
  115. package/bundle/schemas/data-view-definition.schema.json +148 -0
  116. package/bundle/schemas/entity-aggregation-config.schema.json +32 -0
  117. package/bundle/schemas/entity-cadence-config.schema.json +27 -0
  118. package/bundle/schemas/entity-compliance-config.schema.json +31 -0
  119. package/bundle/schemas/entity-cross-plugin-action-config.schema.json +31 -0
  120. package/bundle/schemas/entity-definition.schema.json +126 -0
  121. package/bundle/schemas/entity-document-generator-config.schema.json +29 -0
  122. package/bundle/schemas/entity-rule-definition.schema.json +75 -0
  123. package/bundle/schemas/entity-status-date-sweep-config.schema.json +21 -0
  124. package/bundle/schemas/form.schema.json +281 -0
  125. package/bundle/schemas/menu.schema.json +106 -0
  126. package/bundle/schemas/mobile-nav.schema.json +53 -0
  127. package/bundle/schemas/page-template.schema.json +46 -0
  128. package/bundle/schemas/page.schema.json +292 -0
  129. package/bundle/schemas/plugin-manifest.schema.json +139 -0
  130. package/bundle/schemas/plugin-seed-data.schema.json +24 -0
  131. package/bundle/schemas/print-template-definition.schema.json +90 -0
  132. package/bundle/schemas/provider-definition.schema.json +27 -0
  133. package/bundle/schemas/report-definition.schema.json +96 -0
  134. package/bundle/schemas/rule.schema.json +82 -0
  135. package/bundle/schemas/workflow-definition.schema.json +42 -0
  136. package/bundle/validators/block-engine.mjs +23222 -0
  137. package/bundle/validators/page-engine.mjs +21645 -0
  138. package/erp-cli/authoring-root.mjs +344 -0
  139. package/erp-cli/erp.mjs +4065 -0
  140. package/erp-cli/example-patterns.mjs +94 -0
  141. package/erp-mcp-server/index.mjs +818 -0
  142. package/package.json +27 -0
@@ -0,0 +1,225 @@
1
+ ---
2
+ title: Add a custom block (a "widget" you build yourself)
3
+ audience: tenant
4
+ ---
5
+
6
+ # Add a custom block (a "widget" you build yourself)
7
+
8
+ ## What you're doing
9
+
10
+ The one UI unit on this platform is a **Block** — every page is a tree of
11
+ blocks (`core.grid`, `core.text-input`, `core.button`, ...), rendered by a
12
+ generic engine that knows nothing about any specific block's internals. This
13
+ guide adds a NEW block type of your own — real React, real `useState`/hooks,
14
+ real data — that plugs into that same tree.
15
+
16
+ This is narrower than a full [Code Plugin with custom Java](./build-a-code-plugin.md):
17
+ you don't need a `Plugin` subclass, a Java extension, or any backend code at
18
+ all. A custom block can be pure frontend, reading data through the platform's
19
+ existing generic query engine — reach for this guide alone whenever the need
20
+ is "a bespoke piece of UI with real component state," and only add the Java
21
+ half if the data or logic it needs genuinely can't come from an existing
22
+ endpoint (see that guide's "When to reach for this" table).
23
+
24
+ ## How it's different from a page or a full Code Plugin
25
+
26
+ | | An ordinary page | **A custom block (this guide)** | A full [Code Plugin](./build-a-code-plugin.md) |
27
+ | --- | --- | --- | --- |
28
+ | Built from | Existing block types, composed in JSON | Your OWN new block type | Your own block type + your own Java |
29
+ | Where it can be used | That one page | **Any page, in any app**, once registered | Any page, in any app |
30
+ | Needs React/hooks? | No | Yes | Yes |
31
+ | Needs Java? | No | No | Only if the data/logic needs it |
32
+
33
+ ## The two-part registration seam
34
+
35
+ A custom block is two things, registered together:
36
+
37
+ 1. **The block's data/logic contract** — a `BlockDefinition` (its property
38
+ surface: names, types, designer config) plus `BlockLogic` (a `render()`
39
+ function), admitted via `@erp/block-engine`'s
40
+ `registry.registerExternal({ definition, logic })` — the IDENTICAL
41
+ validation path every core block (`core.grid`, `core.button`, ...) goes
42
+ through. Nothing about this seam is second-class.
43
+ 2. **The actual React component** — registered via
44
+ `@erp/block-adapter-mui`'s `registerCustomRenderer(pluginKind, Component)`.
45
+ Your `render()` function emits the reserved `"custom"` render-node kind
46
+ carrying a `pluginKind` string; the adapter looks that string up in this
47
+ registry and mounts your real component. No match → a neutral placeholder,
48
+ never a crash.
49
+
50
+ Both calls are usually wrapped in one `install*(registry)` function, run once
51
+ when your plugin's frontend bundle loads.
52
+
53
+ ## The complete example
54
+
55
+ This platform's own real, already-shipped example —
56
+ `frontend/packages/erp-code-plugin-demo/src/riskScoreBlock.ts` +
57
+ `RiskScoreCard.tsx` + `register.ts` — is genuinely runnable, not a snippet.
58
+ Read alongside this guide:
59
+
60
+ **`riskScoreBlock.ts`** — the `BlockDefinition`:
61
+
62
+ ```ts
63
+ import type { BlockDefinition, BlockIO, BlockLogic, RenderNode } from "@erp/block-engine";
64
+ import { node } from "@erp/block-engine";
65
+
66
+ export const PLUGIN_KIND = "erp-code-plugin-demo.risk-score-card";
67
+
68
+ export const riskScoreCardDefinition: BlockDefinition = {
69
+ contractVersion: 1,
70
+ publisher: "erp-code-plugin-demo",
71
+ permissions: { visible: true, enabled: true, masked: true },
72
+ type: "erp-code-plugin-demo.risk-score-card",
73
+ version: "1.0.0",
74
+ properties: [
75
+ {
76
+ name: "entityName",
77
+ type: "string",
78
+ required: true,
79
+ sources: ["static"],
80
+ designer: { group: "data", editor: "text", labelKey: "erpCodePluginDemo.riskScoreCard.property.entityName" },
81
+ },
82
+ {
83
+ name: "titleKey",
84
+ type: "string",
85
+ sources: ["static"],
86
+ default: "erpCodePluginDemo.riskScoreCard.title",
87
+ designer: { group: "content", editor: "text", labelKey: "erpCodePluginDemo.riskScoreCard.property.titleKey" },
88
+ },
89
+ ],
90
+ a11y: { role: "region", labelFrom: "i18n:erpCodePluginDemo.riskScoreCard.title" },
91
+ designer: {
92
+ displayNameKey: "erpCodePluginDemo.riskScoreCard.displayName",
93
+ descriptionKey: "erpCodePluginDemo.riskScoreCard.description",
94
+ icon: "insights",
95
+ category: "display",
96
+ allowedTargets: ["page", "dashboard"],
97
+ propertyGroups: [
98
+ { id: "content", titleKey: "core.designer.group.content" },
99
+ { id: "data", titleKey: "core.designer.group.data" },
100
+ ],
101
+ preview: { kind: "text" },
102
+ },
103
+ events: [],
104
+ };
105
+
106
+ export const riskScoreCardLogic: BlockLogic = {
107
+ render(io: BlockIO): RenderNode {
108
+ return node("custom", io.instanceId, {
109
+ props: { pluginKind: PLUGIN_KIND, entityName: io.props["entityName"], titleKey: io.props["titleKey"] },
110
+ });
111
+ },
112
+ };
113
+ ```
114
+
115
+ **`RiskScoreCard.tsx`** — the real component (trimmed; see the file for the
116
+ full version):
117
+
118
+ ```tsx
119
+ import { useMemo, useState } from "react";
120
+ import { Card, CardContent, Chip, Stack, Typography } from "@mui/material";
121
+ import type { RenderNode } from "@erp/block-engine";
122
+ import { useERPQuery } from "@erp/data";
123
+
124
+ function scoreFor(rows: Array<Record<string, unknown>>) {
125
+ const activeCount = rows.filter((r) => r["active"] === true).length;
126
+ const score = rows.length === 0 ? 0 : Math.round((activeCount / rows.length) * 100);
127
+ return { score, band: score >= 70 ? "low" : score >= 40 ? "medium" : "high" } as const;
128
+ }
129
+
130
+ export function RiskScoreCard({ n }: { n: RenderNode }) {
131
+ const entityName = n.props["entityName"] as string;
132
+ const [expanded, setExpanded] = useState(false); // real component state
133
+ const { rows, loading, error } = useERPQuery({ entity: entityName, pageSize: 25 }); // real data
134
+ const { score, band } = useMemo(() => scoreFor(rows), [rows]);
135
+
136
+ return (
137
+ <Card variant="outlined">
138
+ <CardContent>
139
+ <Typography variant="subtitle1">{String(n.props["titleKey"])}</Typography>
140
+ {!loading && !error && (
141
+ <Stack direction="row" spacing={1}>
142
+ <Typography variant="h4">{score}</Typography>
143
+ <Chip label={band} onClick={() => setExpanded((v) => !v)} />
144
+ </Stack>
145
+ )}
146
+ </CardContent>
147
+ </Card>
148
+ );
149
+ }
150
+ ```
151
+
152
+ **`register.ts`** — both halves, together:
153
+
154
+ ```ts
155
+ import type { BlockRegistry } from "@erp/block-engine";
156
+ import { registerCustomRenderer } from "@erp/block-adapter-mui";
157
+ import { riskScoreCardDefinition, riskScoreCardLogic, PLUGIN_KIND } from "./riskScoreBlock";
158
+ import { RiskScoreCard } from "./RiskScoreCard";
159
+
160
+ export function installRiskScoreCardPlugin(registry: BlockRegistry) {
161
+ const result = registry.registerExternal({ definition: riskScoreCardDefinition, logic: riskScoreCardLogic });
162
+ registerCustomRenderer(PLUGIN_KIND, RiskScoreCard);
163
+ return result;
164
+ }
165
+ ```
166
+
167
+ ### Build and verify it yourself
168
+
169
+ This is a real, already-shipped workspace package — run its own checks
170
+ directly, no scaffolding needed:
171
+
172
+ ```bash
173
+ cd frontend/packages/erp-code-plugin-demo
174
+ pnpm typecheck
175
+ pnpm test # real jsdom mount of this exact block, see riskScoreCard.e2e.test.tsx
176
+ pnpm build # tsup — produces dist/index.js
177
+ ```
178
+
179
+ ## Property surface → the designer's config panel
180
+
181
+ Each entry in `BlockDefinition.properties` becomes one field in the page
182
+ designer's property panel for an instance of your block, once it's dropped
183
+ onto a canvas:
184
+
185
+ - `name`/`type`/`required` — the property's identity and validation.
186
+ - `designer.group` — which tab/section of the panel it appears under
187
+ (`riskScoreCardDefinition` declares two: `content`, `data`).
188
+ - `designer.editor` — which input control renders it (`text` here; other
189
+ block types use `select`, `checkbox`, `binding-picker`, etc.).
190
+ - `designer.labelKey` — the i18n key for the field's label.
191
+ - `sources: ["static"]` — v0 property values are static only, same static-vs-
192
+ binding distinction the [Code Plugin compile-to-JSON path](./build-a-code-plugin.md)
193
+ documents.
194
+
195
+ ## Why this matters: cross-app reusability
196
+
197
+ Once `installRiskScoreCardPlugin` runs against a host's `BlockRegistry`, the
198
+ block type `erp-code-plugin-demo.risk-score-card` is registered platform-wide
199
+ for that session — it shows up in the page designer's block palette and can
200
+ be dragged onto **any page, in any app**, not only pages belonging to the
201
+ plugin that registered it. That's the actual point of building a custom
202
+ block instead of a full custom page: you write the component once, and every
203
+ other page author on the tenant gets to use it declaratively, the same way
204
+ they use `core.grid` or `core.button`.
205
+
206
+ ## Getting it onto a live tenant
207
+
208
+ A registered block only exists in whatever process called `registerExternal`
209
+ — shipping it to a real tenant is the same `frontendBundle` +
210
+ `erp plugin publish-frontend` mechanism [the Code Plugin guide](./build-a-code-plugin.md#the-parts-that-differ-from-a-pure-json-plugin)
211
+ describes. If your block needs no Java at all (as `RiskScoreCard` doesn't —
212
+ it reads through the generic query engine), you skip that guide's Java
213
+ sections entirely and package a pure-JSON `.spk` (`mainClass: null`) whose
214
+ `frontendBundle` points at your component's compiled browser bundle.
215
+
216
+ ## What this guide does NOT cover
217
+
218
+ - A block whose data comes from your OWN Java code (a plugin-owned REST
219
+ route, not the generic query engine) — see
220
+ [Build a plugin with custom React + Java code](./build-a-code-plugin.md).
221
+ - Data binding (`{source: "binding"}`) — v0 property values are static only;
222
+ see that guide's own "not supported" boundary in `ai/patterns/code-plugin-sdk.md`.
223
+ - Wiring your block into Studio's design-time preview surfaces yourself —
224
+ already real and automatic once `frontendBundle` is set (the dynamic
225
+ loader wires designer previews and the live page host the same way).
@@ -0,0 +1,216 @@
1
+ ---
2
+ title: Add a data provider, data view, or data service
3
+ audience: tenant
4
+ ---
5
+
6
+ # Add a data provider, data view, or data service
7
+
8
+ ## What you're doing
9
+
10
+ Three related artifacts feed data to your pages. They sound alike; here's the
11
+ difference in one line each:
12
+
13
+ | Artifact | Folder | What it is | Use it for |
14
+ | --- | --- | --- | --- |
15
+ | **Data Provider** | `metadata/provider/` | a name → REST base path mapping | the thing a grid's `metadata.dataSource` points at |
16
+ | **Data View** | `metadata/data_view/` | a declared SQL join over physical tables, read-only | id/label option lists, multi-table reads |
17
+ | **Data Service** | `metadata/data_service/` | a parameterized query: `count`, `search`, `get`, or `composite` | KPI counts, typeahead lookups, dashboard fan-outs |
18
+
19
+ Rule of thumb: **prefer a Data View or Data Service over hand-writing a REST
20
+ endpoint.** You never write a controller.
21
+
22
+ ## Data Provider — the complete example
23
+
24
+ Covered in [Wire a page's data](./wire-a-pages-data.md). For an entity you own it
25
+ is always this, with the name and entity swapped:
26
+
27
+ ```json
28
+ {
29
+ "name": "oeq-checkout-provider",
30
+ "description": "Data Provider for oeq_checkout (grid + CRUD).",
31
+ "definition": {
32
+ "kind": "rest",
33
+ "connectionRef": "self",
34
+ "basePath": "/api/v1/entities/oeq_checkout/records",
35
+ "supports": ["search", "get", "create", "update"]
36
+ },
37
+ "metadata": {}, "modules": []
38
+ }
39
+ ```
40
+
41
+ ## Data View — the complete example
42
+
43
+ `spk-assembly/metadata/data_view/oeq-category-select-options-view.json` —
44
+ [real file](../tutorial/example-plugin/spk-assembly/metadata/data_view/oeq-category-select-options-view.json).
45
+ It produces `{ value, label }` pairs for a category picker:
46
+
47
+ ```json
48
+ {
49
+ "name": "oeq-category-select-options-view",
50
+ "description": "oeq_category id/label pairs for pickers.",
51
+ "definition": {
52
+ "source": { "table": "oeq_category", "alias": "t", "excludeDeleted": false, "schema": "erp_core" },
53
+ "joins": [],
54
+ "fields": [ { "ref": "t.id", "outputName": "value" } ],
55
+ "calculatedFields": [
56
+ { "outputName": "label", "expression": { "op": "concat", "args": [
57
+ { "op": "field", "fieldRef": "t.category_code" },
58
+ { "op": "literal", "literalValue": " - " },
59
+ { "op": "coalesce", "args": [ { "op": "field", "fieldRef": "t.category_name" }, { "op": "literal", "literalValue": "" } ] }
60
+ ] } }
61
+ ],
62
+ "filter": null, "groupBy": [], "aggregations": [],
63
+ "sort": [ { "ref": "t.id", "descending": true } ],
64
+ "pagination": { "defaultPageSize": 200, "maxPageSize": 500 },
65
+ "permissionKey": null
66
+ },
67
+ "metadata": {}, "modules": []
68
+ }
69
+ ```
70
+
71
+ ### Line by line
72
+
73
+ - **`source`** — the driving table, its alias, and its **`schema`** (must match
74
+ your plugin's `schemaName`).
75
+ - **`fields`** — plain column selections, `{ ref, outputName }`.
76
+ - **`calculatedFields`** — expression columns. The expression tree ops you'll use:
77
+ `field` (a column), `literal` (a constant), `concat`, `coalesce`.
78
+ - **`sort` / `pagination`** — read defaults.
79
+ - A picker view **must** output columns literally named `value` and `label`.
80
+
81
+ ## Data Service — the complete examples
82
+
83
+ ### A `count` (one number)
84
+
85
+ `oeq-equipment-count-available.json` —
86
+ [real file](../tutorial/example-plugin/spk-assembly/metadata/data_service/oeq-equipment-count-available.json):
87
+
88
+ ```json
89
+ {
90
+ "name": "oeq-equipment-count-available",
91
+ "description": "Available equipment.",
92
+ "definition": {
93
+ "operation": "count",
94
+ "source": { "kind": "entity", "entityName": "oeq_equipment" },
95
+ "filters": [ { "field": "status", "operator": "eq", "value": "AVAILABLE" } ],
96
+ "parameters": []
97
+ },
98
+ "metadata": {}, "modules": []
99
+ }
100
+ ```
101
+
102
+ `POST /api/v1/data-services/oeq-equipment-count-available/execute` returns
103
+ `{ "value": 3 }`. Bind it as `${out.value}`.
104
+
105
+ ### A `search` (typeahead over a Data View)
106
+
107
+ `oeq-category-search.json` —
108
+ [real file](../tutorial/example-plugin/spk-assembly/metadata/data_service/oeq-category-search.json):
109
+
110
+ ```json
111
+ {
112
+ "name": "oeq-category-search",
113
+ "description": "Typeahead category search for the category lookup field.",
114
+ "definition": {
115
+ "operation": "search",
116
+ "source": { "kind": "dataView", "dataViewName": "oeq-category-select-options-view" },
117
+ "filters": [ { "field": "t.category_name", "operator": "contains", "value": "${param.search}" } ],
118
+ "parameters": [ { "name": "search", "type": "string" } ]
119
+ },
120
+ "metadata": {}, "modules": []
121
+ }
122
+ ```
123
+
124
+ A `core.lookup` block points its `optionsSourceKey` at
125
+ `"oeq-category-search"`. See
126
+ [the lookup recipe](../recipes/related-record-lookup-field.md).
127
+
128
+ ### A `composite` (KPI fan-out)
129
+
130
+ `oeq-equipment-kpis.json` —
131
+ [real file](../tutorial/example-plugin/spk-assembly/metadata/data_service/oeq-equipment-kpis.json):
132
+
133
+ ```json
134
+ {
135
+ "name": "oeq-equipment-kpis",
136
+ "description": "Composite KPI fan-out for the Equipment Register page.",
137
+ "definition": {
138
+ "operation": "composite",
139
+ "source": { "kind": "entity", "entityName": "oeq_equipment" },
140
+ "steps": [
141
+ { "as": "total", "service": "oeq-equipment-count-total", "parameters": {} },
142
+ { "as": "available", "service": "oeq-equipment-count-available", "parameters": {} },
143
+ { "as": "assigned", "service": "oeq-equipment-count-assigned", "parameters": {} }
144
+ ]
145
+ },
146
+ "metadata": {}, "modules": []
147
+ }
148
+ ```
149
+
150
+ `POST .../oeq-equipment-kpis/execute` returns
151
+ `{ "results": { "total": { "value": 12 }, "available": { "value": 8 }, "assigned": { "value": 4 } } }`.
152
+ Bind each as `${out.results.total.value}`. **Verified live** against the tutorial
153
+ module.
154
+
155
+ ## The `/execute` wire-shape trap
156
+
157
+ A Data Service that declares `parameters` must be called with the arguments
158
+ **nested one level deeper** than a normal entity call:
159
+
160
+ ```json
161
+ { "params": { "parameters": { "search": "lap" } } }
162
+ ```
163
+
164
+ not `{ "params": { "search": "lap" } }`. A `core.lookup` block does this for you;
165
+ a hand-written `callApi` action must nest it.
166
+
167
+ ## Response envelope by operation
168
+
169
+ | `operation` | `/execute` returns | bind as |
170
+ | --- | --- | --- |
171
+ | `count` | `{ "value": <n> }` | `${out.value}` |
172
+ | `search` / `get` | `{ "items": [...] }` or `{ "records": [...] }` | check a shipped example |
173
+ | `composite` | `{ "results": { "<step.as>": <that step's envelope> } }` | `${out.results.<as>.value}` |
174
+
175
+ ## Ground yourself first
176
+
177
+ ```bash
178
+ erp schema pull data-service-definition
179
+ erp schema pull data-view-definition
180
+ erp examples search employee-search --kind data-service
181
+ ```
182
+
183
+ ## How to verify it worked
184
+
185
+ ```bash
186
+ erp schema validate spk-assembly/metadata/data_service/oeq-equipment-kpis.json --schema data-service-definition
187
+ ```
188
+
189
+ ```
190
+ OK — ... matches schema "data-service-definition"
191
+ ```
192
+
193
+ After publishing:
194
+
195
+ ```bash
196
+ erp api post "/api/v1/data-services/oeq-equipment-kpis/execute" --body "{}"
197
+ ```
198
+
199
+ ```json
200
+ { "results": { "total": { "value": 0 }, "available": { "value": 0 }, "assigned": { "value": 0 } } }
201
+ ```
202
+
203
+ ## Common mistakes
204
+
205
+ | Symptom | Cause | Fix |
206
+ | --- | --- | --- |
207
+ | lookup returns nothing | Data View doesn't output columns named `value`/`label` | rename the outputs |
208
+ | `/execute` 400s on a parameterized service | argument not nested under `parameters` | `{ "params": { "parameters": { ... } } }` |
209
+ | KPI card shows `undefined` | bound `${out.value}` on a composite | composite is `${out.results.<step>.value}` |
210
+ | Data View returns 0 rows | `source.schema` doesn't match where the table lives | set it to your plugin's `schemaName` |
211
+
212
+ ## What to read next
213
+
214
+ - [Add a KPI or aggregation](./add-a-kpi.md)
215
+ - [Add a create / edit form](./add-a-create-edit-form.md) (lookups)
216
+ - Recipe: [searchable related-record lookup field](../recipes/related-record-lookup-field.md)
@@ -0,0 +1,154 @@
1
+ ---
2
+ title: Add a KPI or aggregation
3
+ audience: tenant
4
+ ---
5
+
6
+ # Add a KPI or aggregation
7
+
8
+ ## What you're doing
9
+
10
+ Putting a number on a page — "12 total", "8 available" — computed server-side.
11
+ The building block is a **Data Service** with `operation: "count"`. A page's
12
+ mount loader calls it and drops the result into page state; a `core.kpi-card`
13
+ displays it.
14
+
15
+ For a row of several KPIs you wrap the counts in one `composite` Data Service so
16
+ the page makes **one** call, not five.
17
+
18
+ ## The complete example
19
+
20
+ ### One count per metric
21
+
22
+ `spk-assembly/metadata/data_service/oeq-equipment-count-total.json`:
23
+
24
+ ```json
25
+ {
26
+ "name": "oeq-equipment-count-total",
27
+ "description": "Total equipment.",
28
+ "definition": {
29
+ "operation": "count",
30
+ "source": { "kind": "entity", "entityName": "oeq_equipment" },
31
+ "filters": [],
32
+ "parameters": []
33
+ },
34
+ "metadata": {}, "modules": []
35
+ }
36
+ ```
37
+
38
+ `oeq-equipment-count-available.json` is the same with a filter:
39
+
40
+ ```json
41
+ "filters": [ { "field": "status", "operator": "eq", "value": "AVAILABLE" } ]
42
+ ```
43
+
44
+ > `filters` on a `count` is a list of `{ field, operator, value }`. Operators:
45
+ > `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`. For "no groupBy, just a
46
+ > filtered number" this is the whole recipe.
47
+
48
+ ### The composite wrapper
49
+
50
+ `oeq-equipment-kpis.json`:
51
+
52
+ ```json
53
+ {
54
+ "name": "oeq-equipment-kpis",
55
+ "description": "Composite KPI fan-out for the Equipment Register page.",
56
+ "definition": {
57
+ "operation": "composite",
58
+ "source": { "kind": "entity", "entityName": "oeq_equipment" },
59
+ "steps": [
60
+ { "as": "total", "service": "oeq-equipment-count-total", "parameters": {} },
61
+ { "as": "available", "service": "oeq-equipment-count-available", "parameters": {} },
62
+ { "as": "assigned", "service": "oeq-equipment-count-assigned", "parameters": {} }
63
+ ]
64
+ },
65
+ "metadata": {}, "modules": []
66
+ }
67
+ ```
68
+
69
+ ### The page mount loader
70
+
71
+ ```json
72
+ "events": { "mounted": { "source": "action-chain", "actions": [
73
+ { "id": "a0", "order": 0, "type": "callApi",
74
+ "config": { "connectionRef": "self", "path": "/api/v1/data-services/oeq-equipment-kpis/execute", "httpMethod": "POST", "params": {} },
75
+ "output": "kpi" },
76
+ { "id": "a1", "order": 1, "type": "setValue", "config": { "field": "page.kpiTotal", "value": "${kpi.results.total.value}" } },
77
+ { "id": "a2", "order": 2, "type": "setValue", "config": { "field": "page.kpiAvailable", "value": "${kpi.results.available.value}" } },
78
+ { "id": "a3", "order": 3, "type": "setValue", "config": { "field": "page.kpiAssigned", "value": "${kpi.results.assigned.value}" } }
79
+ ] } }
80
+ ```
81
+
82
+ ### The KPI card
83
+
84
+ ```json
85
+ {
86
+ "blockType": "core.kpi-card",
87
+ "properties": {
88
+ "value": { "source": "binding", "binding": { "scope": "page", "key": "kpiTotal" } },
89
+ "labelKey": { "source": "static", "value": "office-equipment.equipment-register.kpi.total" },
90
+ "format": { "source": "static", "value": "number" },
91
+ "colorToken": { "source": "static", "value": "primary" },
92
+ "icon": { "source": "static", "value": "devices" }
93
+ }
94
+ }
95
+ ```
96
+
97
+ ## Line by line — the two traps
98
+
99
+ 1. **The `outputName: "value"` / no-groupBy pattern.** A `count` returns exactly
100
+ `{ "value": <number> }`. You do **not** get `.total` (that's the pagination
101
+ envelope of the entity list endpoint, unrelated). A `composite` nests each
102
+ step's envelope under `results.<step.as>`, so a composite count step is
103
+ `${out.results.<as>.value}`.
104
+
105
+ | you called | you bind |
106
+ | --- | --- |
107
+ | a bare `count` service | `${out.value}` |
108
+ | a `composite` of counts | `${out.results.<step>.value}` |
109
+
110
+ 2. **Call once on mount, store in page state.** The KPI card binds a page-state
111
+ key, not the API directly. The mount loader is a hidden `core.container`; see
112
+ [Build a page](./build-a-page.md).
113
+
114
+ ## Recomputing after a write
115
+
116
+ Add the same `callApi` + `setValue` steps to the end of your Save action chain,
117
+ so creating a row updates the counts without a page reload.
118
+
119
+ ## How to verify it worked
120
+
121
+ ```bash
122
+ erp api post "/api/v1/data-services/oeq-equipment-kpis/execute" --body "{}"
123
+ ```
124
+
125
+ Actual response from the tutorial module right after install (no rows yet):
126
+
127
+ ```json
128
+ {
129
+ "value": null, "rows": null, "total": null, "record": null,
130
+ "results": {
131
+ "total": { "value": 0 },
132
+ "available": { "value": 0 },
133
+ "assigned": { "value": 0 }
134
+ }
135
+ }
136
+ ```
137
+
138
+ The top-level `value`/`rows`/`total` are always-present nulls for a composite —
139
+ ignore them, read `results`.
140
+
141
+ ## Common mistakes
142
+
143
+ | Symptom | Cause | Fix |
144
+ | --- | --- | --- |
145
+ | card shows `undefined` | bound `${out.value}` on a composite | use `${out.results.<step>.value}` |
146
+ | card shows `0` when there is data | count filter value doesn't match the enum casing | check `typeParams.enumValues` |
147
+ | every KPI is one HTTP call | separate `callApi` per count on mount | wrap them in one `composite` |
148
+ | card never updates after a save | KPI steps not appended to the Save chain | append `callApi` + `setValue` there too |
149
+
150
+ ## What to read next
151
+
152
+ - [Add a data provider, data view, or data service](./add-a-data-provider.md)
153
+ - [Add a cross-row aggregation job](./add-an-aggregation-job.md) — for rollups stored back into a table
154
+ - Recipe: [KPI dashboard page](../recipes/kpi-dashboard-page.md)