@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,141 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Build a plugin with custom React + Java code
|
|
3
|
+
audience: tenant
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Build a plugin with custom React + Java code
|
|
7
|
+
|
|
8
|
+
## What you're doing
|
|
9
|
+
|
|
10
|
+
Every other guide in these docs builds a plugin out of JSON: entities, pages,
|
|
11
|
+
data services, workflows. That covers the large majority of real screens. This
|
|
12
|
+
guide is for the remaining case — a screen that needs **real component state,
|
|
13
|
+
hooks, or business logic the block engine can't express**, backed by **real
|
|
14
|
+
server-side logic the Entity / Query / Rule / Workflow / Job engines can't
|
|
15
|
+
express either**.
|
|
16
|
+
|
|
17
|
+
You'll use two escape hatches together, both real and already shipped:
|
|
18
|
+
|
|
19
|
+
- **The custom-component escape hatch** (frontend) — a genuinely arbitrary
|
|
20
|
+
React component, mounted through `@erp/block-engine`'s `registerExternal` +
|
|
21
|
+
`@erp/block-adapter-mui`'s `registerCustomRenderer`, reading data through
|
|
22
|
+
`@erp/data`'s hooks or plain `fetch`.
|
|
23
|
+
- **A Java `Plugin` extension** (backend) — a real class implementing one of
|
|
24
|
+
`engine-plugin-api`'s extension-point interfaces (`RestContribution`,
|
|
25
|
+
`DashboardWidgetContribution`, and others — see
|
|
26
|
+
[`erp platform catalog`](./use-the-mcp-server.md)), loaded in-process into
|
|
27
|
+
the shared ERP via PF4J.
|
|
28
|
+
|
|
29
|
+
## When to reach for this (and when not to)
|
|
30
|
+
|
|
31
|
+
Try these first, in order — see `create-a-plugin.md`'s own "Java: only if you
|
|
32
|
+
need it" section:
|
|
33
|
+
|
|
34
|
+
1. **JSON composition** — entities, pages, data services/views, workflows,
|
|
35
|
+
rules. No code at all. Covers most screens.
|
|
36
|
+
2. **A scheduled job config row** — no Java, see the
|
|
37
|
+
[job guides](./index.md#scheduled-jobs-no-java).
|
|
38
|
+
3. **The compile-to-JSON Code Plugin path** — `@erp/ui` + `definePlugin()`,
|
|
39
|
+
for declarative composition of existing blocks with no runtime hooks/state.
|
|
40
|
+
4. **The custom-component escape hatch alone** (frontend only) — real React,
|
|
41
|
+
reading data through the generic query engine (`useERPQuery`). No Java —
|
|
42
|
+
see [Add a custom block](./add-a-custom-block.md) if this is all you need.
|
|
43
|
+
5. **This guide** — real React AND real Java, when the data or logic your
|
|
44
|
+
component needs isn't something the generic engines can compute at all
|
|
45
|
+
(a bespoke server-side calculation, a call to a library or resource only
|
|
46
|
+
your Java code should touch).
|
|
47
|
+
|
|
48
|
+
## Level 4 (this guide) vs Level 5 (a tenant extension service)
|
|
49
|
+
|
|
50
|
+
Both let you write real, unrestricted code. They differ in **where it runs**
|
|
51
|
+
and **what it's allowed to touch**:
|
|
52
|
+
|
|
53
|
+
| | **Level 4 — a Java plugin (this guide)** | **Level 5 — a [tenant extension service](./build-a-tenant-extension-service.md)** |
|
|
54
|
+
| --- | --- | --- |
|
|
55
|
+
| Where your code runs | In-process, loaded via PF4J into the SAME JVM as the ERP core and every other plugin | Your own separate process/container, called over plain HTTP |
|
|
56
|
+
| `scope` | `global` — an ERP-vendor/marketplace-style plugin, installed once, available to any tenant that installs it | `tenant` — isolated to the one tenant that owns it |
|
|
57
|
+
| What it can do | Contribute a real extension point (`RestContribution`, `DashboardWidgetContribution`, `ScheduledJobContribution`, ...) — genuinely part of the platform's own request handling | Respond to ONE wire contract (`POST /extension-api/{extensionPointCode}`) the core calls with a timeout + circuit breaker |
|
|
58
|
+
| Trust bar | Vetted/loaded like core code — no sandbox, full JVM access, affects every tenant with it installed | The core never loads it; a slow/broken service just degrades to "not extended" |
|
|
59
|
+
| Use it when | You're building a plugin (JSON or code) that ANY tenant could install, same trust level as the platform's own modules | You need to run code the platform can't vet in-process — a proprietary calculation, a call to your own internal systems, dependencies that would never be approved into the shared runtime |
|
|
60
|
+
|
|
61
|
+
If you're not sure: a normal Java plugin (this guide) is the default for
|
|
62
|
+
`scope: global` work. Reach for an L5 extension service only when the code
|
|
63
|
+
genuinely must NOT run inside the shared ERP process — see that guide's own
|
|
64
|
+
"What you're doing" for the full reasoning.
|
|
65
|
+
|
|
66
|
+
## The complete example
|
|
67
|
+
|
|
68
|
+
Copy the real, runnable example at
|
|
69
|
+
[`developer-docs/tutorial/code-plugin-example/`](../tutorial/code-plugin-example) —
|
|
70
|
+
don't hand-write one from scratch. It pairs:
|
|
71
|
+
|
|
72
|
+
- **Java**: `RiskScoreSummaryContribution` — a real `RestContribution` (a live
|
|
73
|
+
route, `GET /api/v1/plugins/risk-score-widget/summary`) that also
|
|
74
|
+
implements `DashboardWidgetContribution`, computing a "risk score" over the
|
|
75
|
+
tenant's real `organization_tag` rows with a plain `JdbcTemplate` query —
|
|
76
|
+
the exact same arithmetic the JSON tutorial's data lives inside, just run
|
|
77
|
+
in Java instead of read through the generic query engine.
|
|
78
|
+
- **React**: `RiskScoreSummaryPanel` — real `useState`/`useEffect`, calls that
|
|
79
|
+
SAME Java route with a plain `fetch` (there's no generic `@erp/data` hook
|
|
80
|
+
for an arbitrary plugin-owned route — only for entity reads/writes),
|
|
81
|
+
renders the real returned score.
|
|
82
|
+
|
|
83
|
+
### Build and verify it yourself
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# 1. Java compiles standalone (own pom.xml, never added to the platform's
|
|
87
|
+
# own Maven reactor — see the example's README for what a true
|
|
88
|
+
# third-party build (no platform source) looks like)
|
|
89
|
+
mvn -f developer-docs/tutorial/code-plugin-example/java/pom.xml clean package
|
|
90
|
+
|
|
91
|
+
# 2. React type-checks and tests clean (extends the existing
|
|
92
|
+
# erp-code-plugin-demo workspace package)
|
|
93
|
+
cd frontend/packages/erp-code-plugin-demo
|
|
94
|
+
pnpm typecheck && pnpm test
|
|
95
|
+
|
|
96
|
+
# 3. Package the .spk — auto-refreshes spk-assembly/lib/*.jar from the
|
|
97
|
+
# Java build above, validates the page JSON
|
|
98
|
+
node tools/spark-cli/spark.js package developer-docs/tutorial/code-plugin-example/spk-assembly
|
|
99
|
+
|
|
100
|
+
# 4. Validate the manifest against the real schema
|
|
101
|
+
node tools/erp-cli/erp.mjs schema validate \
|
|
102
|
+
developer-docs/tutorial/code-plugin-example/spk-assembly/plugin.json \
|
|
103
|
+
--schema plugin-manifest
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Installing it on a live ERP and re-running the React test against the real
|
|
107
|
+
route is the example's own README's last step — it needs a running ERP, which
|
|
108
|
+
this guide doesn't assume you have yet.
|
|
109
|
+
|
|
110
|
+
## The parts that differ from a pure-JSON plugin
|
|
111
|
+
|
|
112
|
+
- **`plugin.json`'s `mainClass`** is a real fully-qualified class name, not
|
|
113
|
+
`null` — see [`reference/plugin-manifest.md`](../reference/plugin-manifest.md).
|
|
114
|
+
- **`spk-assembly/lib/`** carries your compiled jar (and only your own
|
|
115
|
+
classes — `engine-plugin-api`/Spring dependencies are `provided`, resolved
|
|
116
|
+
from the host's own classloader at runtime, never bundled).
|
|
117
|
+
- **Your Java class** implements one of `engine-plugin-api`'s extension-point
|
|
118
|
+
interfaces (`org.pf4j.ExtensionPoint` subtypes), annotated `@Extension`.
|
|
119
|
+
`erp platform catalog` lists every one that exists today.
|
|
120
|
+
- **Your React component** is registered through `registerExternal` +
|
|
121
|
+
`registerCustomRenderer`, not composed from `@erp/ui` primitives — see
|
|
122
|
+
[Add a custom block](./add-a-custom-block.md) for that seam on its own
|
|
123
|
+
(no Java), and [`concepts/sdk-modes.md`](../concepts/sdk-modes.md) for the
|
|
124
|
+
full custom-component boundary (what IS and ISN'T sandboxed).
|
|
125
|
+
- **Getting the bundle onto a live tenant** is `erp plugin publish-frontend`
|
|
126
|
+
(uploads `dist/browser.js`, stamps `plugin.json`'s `frontendBundle`) — a
|
|
127
|
+
separate step from `.spk` packaging, run after install.
|
|
128
|
+
|
|
129
|
+
## What this guide does NOT cover
|
|
130
|
+
|
|
131
|
+
- Writing your OWN new extension-point interface — `engine-plugin-api`'s
|
|
132
|
+
existing interfaces are what you implement; adding a new one is platform
|
|
133
|
+
work, not plugin work.
|
|
134
|
+
- Wiring a `DashboardWidgetContribution` into Studio's actual Dashboard
|
|
135
|
+
designer/renderer — real, shipped as an extension-point SHAPE, but the
|
|
136
|
+
host-side rendering wire-up is a disclosed, not-yet-built platform gap (see
|
|
137
|
+
that interface's own Javadoc). The example above still uses it to show the
|
|
138
|
+
shape; the `RestContribution` route is what's reachable end to end today.
|
|
139
|
+
- `scope: tenant` + Java — that's Level 5, the
|
|
140
|
+
[tenant extension service guide](./build-a-tenant-extension-service.md), a
|
|
141
|
+
different deployable entirely.
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Build a page
|
|
3
|
+
audience: tenant
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Build a page
|
|
7
|
+
|
|
8
|
+
## What you're doing
|
|
9
|
+
|
|
10
|
+
A **page** is one screen. It is a JSON file under `spk-assembly/metadata/page/`
|
|
11
|
+
describing a tree of **blocks** (widgets) arranged in rows and columns. The
|
|
12
|
+
platform renders it; you never write React.
|
|
13
|
+
|
|
14
|
+
The house style for a "list" screen — the one you'll build most often — is a
|
|
15
|
+
single column of stacked sections:
|
|
16
|
+
|
|
17
|
+
1. a hidden **loader** block that fetches KPI counts when the page mounts
|
|
18
|
+
2. a **header** row: title on the left, action buttons on the right
|
|
19
|
+
3. a **KPI row**: a few `core.kpi-card` blocks
|
|
20
|
+
4. a **filter row**: a search box and a status dropdown
|
|
21
|
+
5. a **grid**
|
|
22
|
+
6. one or more **dialogs** for create / edit / detail
|
|
23
|
+
|
|
24
|
+
## The complete example
|
|
25
|
+
|
|
26
|
+
The tutorial's `equipment-register.json` is a full, working instance of this
|
|
27
|
+
pattern:
|
|
28
|
+
[the real file](../tutorial/example-plugin/spk-assembly/metadata/page/equipment-register.json).
|
|
29
|
+
It is large (every widget is spelled out), so read it alongside this guide
|
|
30
|
+
rather than pasting it here. Its shape on disk:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"name": "equipment-register",
|
|
35
|
+
"description": "Office Equipment tutorial - Equipment Register.",
|
|
36
|
+
"definition": {
|
|
37
|
+
"contractVersion": 1,
|
|
38
|
+
"id": "office-equipment.equipment-register",
|
|
39
|
+
"version": "1.0.0",
|
|
40
|
+
"publisher": "office-equipment",
|
|
41
|
+
"title": "office-equipment.equipment-register.title",
|
|
42
|
+
"usesAppShell": true,
|
|
43
|
+
"route": { "pattern": "/office-equipment/equipment-register", "params": [] },
|
|
44
|
+
"designer": {
|
|
45
|
+
"displayNameKey": "office-equipment.equipment-register.title",
|
|
46
|
+
"descriptionKey": "office-equipment.equipment-register.title",
|
|
47
|
+
"icon": "devices",
|
|
48
|
+
"category": "list",
|
|
49
|
+
"propertyGroups": [],
|
|
50
|
+
"preview": { "kind": "page" }
|
|
51
|
+
},
|
|
52
|
+
"rows": [
|
|
53
|
+
{ "id": "row-1", "columns": [ { "id": "col-1", "items": [ /* blocks */ ] } ] }
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
"metadata": { "dataSource": "oeq-equipment-provider" },
|
|
57
|
+
"modules": ["office-equipment"]
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Line by line
|
|
62
|
+
|
|
63
|
+
### The wrapper
|
|
64
|
+
|
|
65
|
+
- **`name`** — the file's short name; unique within the plugin.
|
|
66
|
+
- **`definition`** — the actual page contract (`contractVersion: 1`).
|
|
67
|
+
- **`metadata.dataSource`** — **the single field that wires the grid to its
|
|
68
|
+
data.** It names a [Data Provider](./add-a-data-provider.md). It is
|
|
69
|
+
*page-level*, never on the grid block. Get this wrong and the grid renders zero
|
|
70
|
+
rows with no error. See [Wire a page's data](./wire-a-pages-data.md).
|
|
71
|
+
- **`modules`** — the module id(s) this page belongs to. The app shell only
|
|
72
|
+
lists a page in the sidenav if it's here. Use your module's `name`.
|
|
73
|
+
|
|
74
|
+
### Inside `definition`
|
|
75
|
+
|
|
76
|
+
- **`id`** — must be `<pluginId>.<page-name>`, dotted, lowercase.
|
|
77
|
+
- **`title`** — an i18n key (define it in `metadata/i18n/en.json`).
|
|
78
|
+
- **`route.pattern`** — starts with `/`. This is an internal routing key, **not**
|
|
79
|
+
the browser URL. The reachable URL is
|
|
80
|
+
`/app/<app-slug>/<module-slug>/<pluginId>/<page-name>`.
|
|
81
|
+
- **`designer`** — required metadata for Studio. `category` is one of
|
|
82
|
+
`workspace | detail | list | dashboard | landing`. `propertyGroups: []` and
|
|
83
|
+
`preview: { "kind": "page" }` are fine as-is.
|
|
84
|
+
- **`rows`** — an array of `{ id, columns: [ { id, items: [...] } ] }`. Each
|
|
85
|
+
`item` is `{ id, kind: "block", block: { ... } }`.
|
|
86
|
+
|
|
87
|
+
### A block
|
|
88
|
+
|
|
89
|
+
Every block instance looks like:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"contractVersion": 1,
|
|
94
|
+
"instanceId": "kpi-8",
|
|
95
|
+
"blockType": "core.kpi-card",
|
|
96
|
+
"blockVersion": "1.0.0",
|
|
97
|
+
"properties": {
|
|
98
|
+
"value": { "source": "binding", "binding": { "scope": "page", "key": "kpiTotal" } },
|
|
99
|
+
"labelKey": { "source": "static", "value": "office-equipment.equipment-register.kpi.total" },
|
|
100
|
+
"format": { "source": "static", "value": "number" }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- Every property is `{ "source": "static", "value": ... }` (a literal) or
|
|
106
|
+
`{ "source": "binding", "binding": { "scope": "page", "key": "..." } }` (read
|
|
107
|
+
from page state).
|
|
108
|
+
- **`page` state** is a bag of values you set with `setValue` actions and read
|
|
109
|
+
with bindings. `kpiTotal`, `gridExternalFilter`, `formDialogOpen` are all just
|
|
110
|
+
keys you invented.
|
|
111
|
+
|
|
112
|
+
### The mount loader
|
|
113
|
+
|
|
114
|
+
The first block is a `core.container` with `hidden: true` and a `mounted` event
|
|
115
|
+
that runs an **action chain** — call the KPI data service, then `setValue` each
|
|
116
|
+
result into page state:
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"blockType": "core.container",
|
|
121
|
+
"properties": { "variant": { "source": "static", "value": "plain" },
|
|
122
|
+
"hidden": { "source": "static", "value": true } },
|
|
123
|
+
"events": {
|
|
124
|
+
"mounted": {
|
|
125
|
+
"source": "action-chain",
|
|
126
|
+
"actions": [
|
|
127
|
+
{ "id": "a0", "order": 0, "type": "callApi",
|
|
128
|
+
"config": { "connectionRef": "self", "path": "/api/v1/data-services/oeq-equipment-kpis/execute", "httpMethod": "POST", "params": {} },
|
|
129
|
+
"output": "kpi" },
|
|
130
|
+
{ "id": "a1", "order": 1, "type": "setValue",
|
|
131
|
+
"config": { "field": "page.kpiTotal", "value": "${kpi.results.total.value}" } }
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`${kpi.results.total.value}` is the response-shape for a **composite** data
|
|
139
|
+
service — see [Add a KPI](./add-a-kpi.md).
|
|
140
|
+
|
|
141
|
+
### The grid
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{
|
|
145
|
+
"blockType": "core.grid",
|
|
146
|
+
"properties": {
|
|
147
|
+
"columns": { "source": "static", "value": [
|
|
148
|
+
{ "name": "asset_tag", "type": "string", "headerKey": "office-equipment.equipment-register.column.asset_tag" },
|
|
149
|
+
{ "name": "status", "type": "string", "headerKey": "...column.status", "renderAs": "chip",
|
|
150
|
+
"colorMap": { "AVAILABLE": "success", "ASSIGNED": "info", "MAINTENANCE": "warning", "RETIRED": "default" } }
|
|
151
|
+
] },
|
|
152
|
+
"pageSize": { "source": "static", "value": 25 },
|
|
153
|
+
"externalFilter": { "source": "binding", "binding": { "scope": "page", "key": "gridExternalFilter" } },
|
|
154
|
+
"refreshTrigger": { "source": "binding", "binding": { "scope": "page", "key": "gridRefreshTick" } }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`core.grid` has **no** `dataSource`, `entity`, or `recordType` property — its
|
|
160
|
+
data comes from the page's `metadata.dataSource`. `externalFilter` binds a page
|
|
161
|
+
object whose keys become query filters; `refreshTrigger` re-runs the query
|
|
162
|
+
whenever the bound value changes (toggle it with `setValue page.gridRefreshTick
|
|
163
|
+
= "${!page.gridRefreshTick}"` after a save).
|
|
164
|
+
|
|
165
|
+
## Ground yourself first
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
erp blocks list # every block type
|
|
169
|
+
erp blocks list --type core.grid # one block's full property/event list
|
|
170
|
+
erp examples patterns --kind page # curated real page shapes
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## How to verify it worked
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
erp plugin test office-equipment/spk-assembly
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
```
|
|
180
|
+
✓ page:equipment-register.json
|
|
181
|
+
4 passed, 0 failed, 0 semantic warning(s), 3 skipped
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
After publishing, confirm it registered:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
erp api get "/api/v1/authoring/pages?module=office-equipment"
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
[ { "id": 11324, "name": "equipment-register", "status": "published",
|
|
192
|
+
"route": { "pageId": "office-equipment.equipment-register",
|
|
193
|
+
"pattern": "/office-equipment/equipment-register", "params": [] } } ]
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Then open `/app/<app>/<module>/office-equipment/equipment-register` in the ERP.
|
|
197
|
+
|
|
198
|
+
## Common mistakes
|
|
199
|
+
|
|
200
|
+
| Symptom | Cause | Fix |
|
|
201
|
+
| --- | --- | --- |
|
|
202
|
+
| grid shows 0 rows, no error | `metadata.dataSource` missing / misspelled / not a provider name | see [Wire a page's data](./wire-a-pages-data.md); `erp plugin test` flags this as `PAGE-DS-001` |
|
|
203
|
+
| page not in the sidenav | `modules` array wrong or missing | set it to your module's `name` |
|
|
204
|
+
| KPI cards blank | wrong response path in the `setValue` | composite → `${out.results.<step>.value}`; count → `${out.value}` |
|
|
205
|
+
| text shows the raw i18n key | key missing from `metadata/i18n/en.json` | add it; `erp plugin test` flags `I18N-001` |
|
|
206
|
+
| "route" is the browser URL — it isn't | confusing `route.pattern` with the reachable URL | URL is `/app/<app>/<module>/<plugin>/<page>` |
|
|
207
|
+
|
|
208
|
+
## What to read next
|
|
209
|
+
|
|
210
|
+
- [Wire a page's data](./wire-a-pages-data.md)
|
|
211
|
+
- [Add a create / edit form](./add-a-create-edit-form.md)
|
|
212
|
+
- [Add a KPI or aggregation](./add-a-kpi.md)
|
|
213
|
+
- Recipe: [KPI dashboard page](../recipes/kpi-dashboard-page.md), [master-detail page](../recipes/master-detail-page.md)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Build a Service-mode plugin in Python or Node.js
|
|
3
|
+
audience: tenant
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Build a Service-mode plugin in Python or Node.js
|
|
7
|
+
|
|
8
|
+
## What you're doing
|
|
9
|
+
|
|
10
|
+
Every plugin starts life **Embedded** — Java code loaded in-process via PF4J,
|
|
11
|
+
sharing the ERP core's own JVM. Since 2026-08-14 a plugin can also declare
|
|
12
|
+
**Service mode**: its own standalone process, reached over plain HTTP, that
|
|
13
|
+
self-registers itself with the platform and sends a heartbeat. Until now the
|
|
14
|
+
only real Service-mode example was Spring Boot. As of 2026-09-14 the wire
|
|
15
|
+
contract is proven language-agnostic with two more real, working
|
|
16
|
+
implementations — the platform genuinely does not care what language your
|
|
17
|
+
process is written in, as long as it speaks the same three things: self-
|
|
18
|
+
registration, a heartbeat, and the tenant-context headers.
|
|
19
|
+
|
|
20
|
+
| Runtime | Framework | Real example in this repo | Port |
|
|
21
|
+
| --- | --- | --- | --- |
|
|
22
|
+
| `java` (default) | `spring-boot` | `backend/modules/hello-plugin-service/` | 8090 |
|
|
23
|
+
| `python` | `fastapi` | `backend/modules/hello-plugin-service-python/` | 8091 |
|
|
24
|
+
| `nodejs` | `express` | `backend/modules/hello-plugin-service-node/` | 8092 |
|
|
25
|
+
|
|
26
|
+
This is a **different mechanism** from
|
|
27
|
+
[Build a tenant extension service (L5)](./build-a-tenant-extension-service.md)
|
|
28
|
+
— don't confuse the two:
|
|
29
|
+
|
|
30
|
+
| | This guide (Service-mode plugin) | L5 extension service |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| What it replaces | An entire plugin's REST surface | One narrow extension point (`EntityRecordBeforeCreate`) |
|
|
33
|
+
| Who calls whom | Platform proxies EVERY request for that plugin to your process | Platform calls your process only at one specific hook |
|
|
34
|
+
| Auth | Shared-secret self-registration token | Short-lived HMAC-signed bearer token per call |
|
|
35
|
+
| Scope | A full business application (HCM, CRM, or your own) | A single tenant-specific customization |
|
|
36
|
+
|
|
37
|
+
## The fastest way: scaffold it
|
|
38
|
+
|
|
39
|
+
> The plugin ids used below (`ai-document-parser`, `whatsapp-connector`) are
|
|
40
|
+
> illustrative example names for this guide, chosen to show the kind of
|
|
41
|
+
> plugin each language suits — a Python service calling an ML model, a
|
|
42
|
+
> Node.js service calling a chat API. Neither ships with this platform;
|
|
43
|
+
> swap in your own plugin id.
|
|
44
|
+
|
|
45
|
+
Don't hand-write the self-registration/heartbeat/tenant-context wiring —
|
|
46
|
+
generate a real, runnable starting point:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
platform-cli service-plugin:scaffold --id ai-document-parser --runtime python --vendor "Acme Corp" --out .
|
|
50
|
+
# or:
|
|
51
|
+
platform-cli service-plugin:scaffold --id whatsapp-connector --runtime nodejs --vendor "Acme Corp" --out .
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This produces the same structure `hello-plugin-service-python`/
|
|
55
|
+
`hello-plugin-service-node` have by hand: `plugin.json`, the app source with
|
|
56
|
+
self-registration/heartbeat/tenant-context already wired in, a
|
|
57
|
+
`requirements.txt`/`package.json`, a multi-stage `Dockerfile`, and a local
|
|
58
|
+
dev launcher script. Both arms of this command are proven by actually running
|
|
59
|
+
their own scaffolded output end-to-end, not just hand-verified once.
|
|
60
|
+
|
|
61
|
+
## The manifest
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"id": "ai-document-parser",
|
|
66
|
+
"name": "AI Document Parser",
|
|
67
|
+
"version": "1.0.0",
|
|
68
|
+
"runtimeModes": ["service"],
|
|
69
|
+
"serviceRuntime": "python",
|
|
70
|
+
"serviceFramework": "fastapi"
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Python and Node.js plugins can **only** declare `runtimeModes: ["service"]`
|
|
75
|
+
— never `"embedded"` — nothing outside the JVM can load in-process. Installing
|
|
76
|
+
a manifest that sets `serviceRuntime` to `"python"`/`"nodejs"` without
|
|
77
|
+
`"service"` in `runtimeModes` fails install with a clear error
|
|
78
|
+
(`PluginCodeLifecycle#validateServiceRuntime`). `serviceRuntime`/
|
|
79
|
+
`serviceFramework` default to `"java"`/`"spring-boot"` when absent — every
|
|
80
|
+
manifest written before this feature existed keeps its exact current
|
|
81
|
+
behavior.
|
|
82
|
+
|
|
83
|
+
## The wire contract, exactly
|
|
84
|
+
|
|
85
|
+
Match this regardless of language — it's the same contract
|
|
86
|
+
`hello-plugin-service`/`-python`/`-node` all implement:
|
|
87
|
+
|
|
88
|
+
- **Self-register on startup**: `POST {engine-api base URL}/api/v1/platform/service-registrations`
|
|
89
|
+
with your plugin id and this process's own reachable URL, header
|
|
90
|
+
`X-Plugin-Registration-Token: <token>` — same env var name in every
|
|
91
|
+
language, `ERP_PLUGIN_SERVICE_TOKEN`.
|
|
92
|
+
- **Heartbeat**: repeat the same POST on an interval (`deploymentConfig.heartbeatIntervalSeconds`,
|
|
93
|
+
default matches whatever your `plugin.json`'s `serviceDeployment` declares)
|
|
94
|
+
— a missed heartbeat past a freshness window makes the platform treat your
|
|
95
|
+
service as unavailable and return a clean `503`, never a hang.
|
|
96
|
+
- **Deregister on shutdown**: `DELETE {engine-api base URL}/api/v1/platform/service-registrations/{pluginId}`
|
|
97
|
+
— a graceful `SIGTERM` (container/pod termination, not a hard `kill -9`)
|
|
98
|
+
should trigger this.
|
|
99
|
+
- **Every proxied request** carries `X-Tenant-Id`/`X-Actor` (and
|
|
100
|
+
`Authorization` when present) — read them, never trust anything else as
|
|
101
|
+
the tenant identity.
|
|
102
|
+
- **Health endpoint**: whatever path your manifest's `serviceDeployment.healthPath`
|
|
103
|
+
declares (`/health` for both the Python and Node.js examples — no
|
|
104
|
+
FastAPI/Express equivalent of Spring's `/actuator/health` exists, so this
|
|
105
|
+
is just a plain declared value, not a framework convention).
|
|
106
|
+
|
|
107
|
+
## Ground yourself first
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
erp_get_schema {"name":"plugin-manifest"}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Build, run, and verify it standalone (no ERP needed yet)
|
|
114
|
+
|
|
115
|
+
Python:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
cd backend/modules/hello-plugin-service-python
|
|
119
|
+
python -m venv .venv && .venv/Scripts/activate
|
|
120
|
+
pip install -r requirements.txt
|
|
121
|
+
uvicorn app.main:app --port 8091
|
|
122
|
+
curl localhost:8091/health
|
|
123
|
+
curl localhost:8091/greeting -H "X-Tenant-Id: 1" -H "X-Actor: demo"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Node.js:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
cd backend/modules/hello-plugin-service-node
|
|
130
|
+
npm install && npm run build && npm start
|
|
131
|
+
curl localhost:8092/health
|
|
132
|
+
curl localhost:8092/greeting -H "X-Tenant-Id: 1" -H "X-Actor: demo"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Then build the real container each ships:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
docker build -t ai-document-parser .
|
|
139
|
+
docker run -p 8091:8091 -e ERP_PLUGIN_SERVICE_TOKEN=<your token> ai-document-parser
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Test it
|
|
143
|
+
|
|
144
|
+
Both languages have a real, committed, passing test suite you can run and
|
|
145
|
+
extend as your own starting point — not just illustrative snippets:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
# Python — pytest + FastAPI's TestClient, no live engine-api needed
|
|
149
|
+
cd backend/modules/hello-plugin-service-python
|
|
150
|
+
pip install -r requirements.txt -r requirements-dev.txt
|
|
151
|
+
python -m pytest tests/ -v
|
|
152
|
+
|
|
153
|
+
# Node.js — vitest + supertest, no live engine-api needed
|
|
154
|
+
cd backend/modules/hello-plugin-service-node
|
|
155
|
+
npm install
|
|
156
|
+
npm test
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Both suites drive the real app in-process (no port bound, no engine-api
|
|
160
|
+
required) and cover the same two things worth testing in any Service-mode
|
|
161
|
+
plugin: your health endpoint responds, and your handlers correctly read
|
|
162
|
+
`X-Tenant-Id`/`X-Actor` from the tenant-context layer. Note Node's Express
|
|
163
|
+
app is deliberately split into `app.ts` (routes, importable/testable) and
|
|
164
|
+
`main.ts` (the actual `listen()` + registration + signal handling) — import
|
|
165
|
+
`createApp()` in your own tests the same way, rather than importing a file
|
|
166
|
+
that starts a real server as a side effect of being imported.
|
|
167
|
+
|
|
168
|
+
## Register it against a running ERP
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
start-hello-plugin-service-python.bat # or start-hello-plugin-service-node.bat
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Then flip the plugin to Service mode in Studio's Plugin Runtime panel — the
|
|
175
|
+
platform starts routing `GET /api/v1/plugins/<your-plugin-id>/**` to your
|
|
176
|
+
process instead of (or in addition to, if you're testing) the Java version.
|
|
177
|
+
|
|
178
|
+
## Deploying it for real
|
|
179
|
+
|
|
180
|
+
Each example ships a parallel Kustomize tree — `deploy/kubernetes/hello-plugin-service-python/`
|
|
181
|
+
and `.../hello-plugin-service-node/`, mirroring the Java one's `base`/`overlays/{local,aws}`
|
|
182
|
+
shape with a different image/port/health-path. Copy the tree for your own
|
|
183
|
+
plugin id rather than trying to parameterize one shared tree across
|
|
184
|
+
languages — the images, ports, and health checks genuinely differ per
|
|
185
|
+
language and a single shared tree obscures more than it clarifies.
|
|
186
|
+
|
|
187
|
+
## Common mistakes
|
|
188
|
+
|
|
189
|
+
| Symptom | Cause | Fix |
|
|
190
|
+
| --- | --- | --- |
|
|
191
|
+
| Install fails with a serviceRuntime error | Declared `serviceRuntime: "python"`/`"nodejs"` without `"service"` in `runtimeModes` | Embedded is always Java — add `"service"` to `runtimeModes` |
|
|
192
|
+
| `403`/`401` on self-registration | Token mismatch | Same `ERP_PLUGIN_SERVICE_TOKEN` value on both the ERP core and your process |
|
|
193
|
+
| Requests never reach your process | Plugin still on Embedded mode in Studio | Flip the Plugin Runtime toggle to Service |
|
|
194
|
+
| Clean `503`s from every call | No heartbeat received recently | Check your process is actually running and its heartbeat interval hasn't lapsed |
|
|
195
|
+
| Graceful shutdown never deregisters | Process was hard-killed (`kill -9`, or on native Windows a bare terminal `kill`) rather than sent a real `SIGTERM` | Use `docker stop`/pod termination in any real deployment — that delivers a real, trappable signal |
|
|
196
|
+
|
|
197
|
+
## What to read next
|
|
198
|
+
|
|
199
|
+
- [Build a tenant extension service (L5)](./build-a-tenant-extension-service.md) — the narrower, single-extension-point alternative to this guide
|
|
200
|
+
- [Publish and upgrade a plugin](./publish-and-upgrade.md)
|
|
201
|
+
- `platform-cli service-plugin:scaffold --help` for every scaffold option
|