@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.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/bundle/blocks.json +12229 -0
- package/bundle/catalog.json +32475 -0
- package/bundle/docs/applications/hcm/employee-profile.md +144 -0
- package/bundle/docs/concepts/artifact-types.md +80 -0
- package/bundle/docs/concepts/glossary.md +124 -0
- package/bundle/docs/concepts/overview.md +88 -0
- package/bundle/docs/concepts/page-data-flow.md +84 -0
- package/bundle/docs/concepts/plugin-lifecycle.md +81 -0
- package/bundle/docs/concepts/sdk-modes.md +71 -0
- package/bundle/docs/docs.json +109 -0
- package/bundle/docs/guides/add-a-cadence-job.md +134 -0
- package/bundle/docs/guides/add-a-compliance-job.md +139 -0
- package/bundle/docs/guides/add-a-create-edit-form.md +167 -0
- package/bundle/docs/guides/add-a-custom-block.md +225 -0
- package/bundle/docs/guides/add-a-data-provider.md +216 -0
- package/bundle/docs/guides/add-a-kpi.md +154 -0
- package/bundle/docs/guides/add-a-reminder-job.md +181 -0
- package/bundle/docs/guides/add-an-aggregation-job.md +153 -0
- package/bundle/docs/guides/add-an-approval-workflow.md +222 -0
- package/bundle/docs/guides/add-an-entity.md +170 -0
- package/bundle/docs/guides/add-business-rules.md +147 -0
- package/bundle/docs/guides/add-i18n.md +121 -0
- package/bundle/docs/guides/add-menus.md +149 -0
- package/bundle/docs/guides/build-a-code-plugin.md +141 -0
- package/bundle/docs/guides/build-a-page.md +213 -0
- package/bundle/docs/guides/build-a-polyglot-service-plugin.md +201 -0
- package/bundle/docs/guides/build-a-tenant-extension-service.md +149 -0
- package/bundle/docs/guides/create-a-plugin.md +172 -0
- package/bundle/docs/guides/expose-a-plugin-operation-as-an-ai-tool.md +114 -0
- package/bundle/docs/guides/index.md +53 -0
- package/bundle/docs/guides/publish-and-upgrade.md +152 -0
- package/bundle/docs/guides/responsive-plugin.md +117 -0
- package/bundle/docs/guides/seed-data-on-install.md +127 -0
- package/bundle/docs/guides/set-up-the-sdk.md +120 -0
- package/bundle/docs/guides/use-the-mcp-server.md +104 -0
- package/bundle/docs/guides/validate-and-test.md +119 -0
- package/bundle/docs/guides/wire-a-pages-data.md +153 -0
- package/bundle/docs/quickstart/index.md +118 -0
- package/bundle/docs/recipes/condition-based-compliance-flag.md +70 -0
- package/bundle/docs/recipes/cross-plugin-offboarding-fanout.md +68 -0
- package/bundle/docs/recipes/cross-row-aggregation.md +67 -0
- package/bundle/docs/recipes/entity-with-approval-workflow.md +72 -0
- package/bundle/docs/recipes/index.md +40 -0
- package/bundle/docs/recipes/install-lifecycle-data-seeding.md +76 -0
- package/bundle/docs/recipes/kpi-dashboard-page.md +92 -0
- package/bundle/docs/recipes/master-detail-page.md +84 -0
- package/bundle/docs/recipes/n-days-before-reminder.md +72 -0
- package/bundle/docs/recipes/per-row-document-generator.md +65 -0
- package/bundle/docs/recipes/related-record-lookup-field.md +74 -0
- package/bundle/docs/recipes/resolved-reference-grid-column.md +63 -0
- package/bundle/docs/recipes/scheduled-record-generation.md +68 -0
- package/bundle/docs/reference/action.md +18 -0
- package/bundle/docs/reference/block-definition.md +35 -0
- package/bundle/docs/reference/block-instance.md +33 -0
- package/bundle/docs/reference/composite-pack-manifest.md +33 -0
- package/bundle/docs/reference/connector-definition.md +36 -0
- package/bundle/docs/reference/data-service-definition.md +22 -0
- package/bundle/docs/reference/data-view-definition.md +26 -0
- package/bundle/docs/reference/entity-aggregation-config.md +38 -0
- package/bundle/docs/reference/entity-cadence-config.md +33 -0
- package/bundle/docs/reference/entity-compliance-config.md +37 -0
- package/bundle/docs/reference/entity-cross-plugin-action-config.md +37 -0
- package/bundle/docs/reference/entity-definition.md +18 -0
- package/bundle/docs/reference/entity-document-generator-config.md +35 -0
- package/bundle/docs/reference/entity-rule-definition.md +29 -0
- package/bundle/docs/reference/entity-status-date-sweep-config.md +27 -0
- package/bundle/docs/reference/form.md +35 -0
- package/bundle/docs/reference/index.md +37 -0
- package/bundle/docs/reference/menu.md +26 -0
- package/bundle/docs/reference/mobile-nav.md +22 -0
- package/bundle/docs/reference/page-template.md +29 -0
- package/bundle/docs/reference/page.md +44 -0
- package/bundle/docs/reference/plugin-manifest.md +54 -0
- package/bundle/docs/reference/plugin-seed-data.md +21 -0
- package/bundle/docs/reference/print-template-definition.md +22 -0
- package/bundle/docs/reference/provider-definition.md +22 -0
- package/bundle/docs/reference/report-definition.md +22 -0
- package/bundle/docs/reference/rule.md +30 -0
- package/bundle/docs/reference/workflow-definition.md +28 -0
- package/bundle/docs/troubleshooting/grid-shows-zero-rows.md +82 -0
- package/bundle/docs/troubleshooting/job-never-runs.md +89 -0
- package/bundle/docs/troubleshooting/known-gotchas.md +193 -0
- package/bundle/docs/troubleshooting/publishing-errors.md +119 -0
- package/bundle/docs/tutorial/01-create-the-plugin.md +157 -0
- package/bundle/docs/tutorial/02-category-entity-and-seed.md +94 -0
- package/bundle/docs/tutorial/03-equipment-entity.md +121 -0
- package/bundle/docs/tutorial/04-equipment-register-page.md +153 -0
- package/bundle/docs/tutorial/05-checkout-entity-and-page.md +116 -0
- package/bundle/docs/tutorial/06-checkout-approval-workflow.md +139 -0
- package/bundle/docs/tutorial/07-return-due-reminder-job.md +129 -0
- package/bundle/docs/tutorial/08-menus-i18n-publish.md +123 -0
- package/bundle/docs/tutorial/index.md +72 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-aggregation-configs.json +18 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cadence-configs.json +9 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-compliance-configs.json +74 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cross-plugin-configs.json +9 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-document-configs.json +9 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-sweep-configs.json +260 -0
- package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/entities/hcm_salary_planning_entry.json +41 -0
- package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/page/salary-planning.json +1 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/page/promotions.json +10944 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.movement.json +44 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.promotion-acknowledge.json +19 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.resignation.json +29 -0
- package/bundle/examples/example-patterns.mjs +94 -0
- package/bundle/manifest.json +14 -0
- package/bundle/schemas/action.schema.json +102 -0
- package/bundle/schemas/block-definition.schema.json +222 -0
- package/bundle/schemas/block-instance.schema.json +139 -0
- package/bundle/schemas/composite-pack-manifest.schema.json +160 -0
- package/bundle/schemas/connector-definition.schema.json +29 -0
- package/bundle/schemas/data-service-definition.schema.json +64 -0
- package/bundle/schemas/data-view-definition.schema.json +148 -0
- package/bundle/schemas/entity-aggregation-config.schema.json +32 -0
- package/bundle/schemas/entity-cadence-config.schema.json +27 -0
- package/bundle/schemas/entity-compliance-config.schema.json +31 -0
- package/bundle/schemas/entity-cross-plugin-action-config.schema.json +31 -0
- package/bundle/schemas/entity-definition.schema.json +126 -0
- package/bundle/schemas/entity-document-generator-config.schema.json +29 -0
- package/bundle/schemas/entity-rule-definition.schema.json +75 -0
- package/bundle/schemas/entity-status-date-sweep-config.schema.json +21 -0
- package/bundle/schemas/form.schema.json +281 -0
- package/bundle/schemas/menu.schema.json +106 -0
- package/bundle/schemas/mobile-nav.schema.json +53 -0
- package/bundle/schemas/page-template.schema.json +46 -0
- package/bundle/schemas/page.schema.json +292 -0
- package/bundle/schemas/plugin-manifest.schema.json +139 -0
- package/bundle/schemas/plugin-seed-data.schema.json +24 -0
- package/bundle/schemas/print-template-definition.schema.json +90 -0
- package/bundle/schemas/provider-definition.schema.json +27 -0
- package/bundle/schemas/report-definition.schema.json +96 -0
- package/bundle/schemas/rule.schema.json +82 -0
- package/bundle/schemas/workflow-definition.schema.json +42 -0
- package/bundle/validators/block-engine.mjs +23222 -0
- package/bundle/validators/page-engine.mjs +21645 -0
- package/erp-cli/authoring-root.mjs +344 -0
- package/erp-cli/erp.mjs +4065 -0
- package/erp-cli/example-patterns.mjs +94 -0
- package/erp-mcp-server/index.mjs +818 -0
- 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)
|