@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,170 @@
1
+ ---
2
+ title: Add an entity
3
+ audience: tenant
4
+ ---
5
+
6
+ # Add an entity
7
+
8
+ ## What you're doing
9
+
10
+ An **entity** is a database table you declare instead of writing SQL. You list
11
+ its **fields** and their **data types**; the platform's Entity Engine generates
12
+ the `CREATE TABLE`, the CRUD REST endpoints (`/api/v1/entities/<name>/records`),
13
+ the grid query endpoint, and single-record reads. Zero persistence code.
14
+
15
+ One entity = one file under `spk-assembly/metadata/entities/`.
16
+
17
+ ## The complete example
18
+
19
+ `spk-assembly/metadata/entities/oeq_equipment.json` from the tutorial module —
20
+ [the real file](../tutorial/example-plugin/spk-assembly/metadata/entities/oeq_equipment.json):
21
+
22
+ ```json
23
+ {
24
+ "entity": {
25
+ "name": "oeq_equipment",
26
+ "tableName": "oeq_equipment",
27
+ "label": "{\"en\":\"Equipment\"}",
28
+ "category": "office-equipment",
29
+ "icon": "devices",
30
+ "color": "#2563EB",
31
+ "pkStrategy": "identity"
32
+ },
33
+ "fields": [
34
+ { "fieldName": "asset_tag", "label": "\"Asset Tag\"", "dataType": "text", "nullable": false, "required": true, "isUnique": true, "isIndexed": true, "isDisplayField": true, "displayOrder": 1 },
35
+ { "fieldName": "name", "label": "\"Name\"", "dataType": "text", "nullable": false, "required": true, "displayOrder": 2 },
36
+ { "fieldName": "category_id", "label": "\"Category Id\"", "dataType": "long", "nullable": true, "required": false, "isIndexed": true, "displayOrder": 3 },
37
+ { "fieldName": "category_name", "label": "\"Category\"", "dataType": "text", "nullable": true, "required": false, "displayOrder": 4 },
38
+ { "fieldName": "serial_number", "label": "\"Serial Number\"", "dataType": "text", "nullable": true, "required": false, "displayOrder": 5 },
39
+ { "fieldName": "status", "label": "\"Status\"", "dataType": "enum", "nullable": false, "required": true, "isIndexed": true, "defaultValue": "AVAILABLE", "typeParams": "{\"enumValues\":[\"AVAILABLE\",\"ASSIGNED\",\"MAINTENANCE\",\"RETIRED\"]}", "displayOrder": 6 },
40
+ { "fieldName": "purchase_date", "label": "\"Purchase Date\"", "dataType": "date", "nullable": true, "required": false, "displayOrder": 7 },
41
+ { "fieldName": "warranty_expiry_date", "label": "\"Warranty Expiry Date\"", "dataType": "date", "nullable": true, "required": false, "displayOrder": 8 },
42
+ { "fieldName": "notes", "label": "\"Notes\"", "dataType": "text", "nullable": true, "required": false, "displayOrder": 9 }
43
+ ]
44
+ }
45
+ ```
46
+
47
+ ## Line by line
48
+
49
+ ### The `entity` block
50
+
51
+ - **`name`** — lowercase, `snake_case`, starts with a letter. This is the table
52
+ name and the id in every REST path. **Prefix it** (`oeq_`) so it never
53
+ collides with another plugin's table.
54
+ - **`label`** — this column is stored as JSON in the database, so the *string*
55
+ must itself be valid JSON: `"{\"en\":\"Equipment\"}"` (an i18n object) — **not**
56
+ `"Equipment"`. A bare string fails the install with `invalid input syntax for
57
+ type json`. This is the single most common entity mistake.
58
+ - **`pkStrategy`** — `identity` gives an auto-incrementing integer `id`.
59
+ `snowflake` gives a distributed 64-bit id. Use `identity` unless you have a
60
+ reason not to.
61
+ - **`icon` / `color` / `category`** — cosmetic, used by Studio's explorer tree.
62
+
63
+ ### Each `field`
64
+
65
+ - **`fieldName`** — `snake_case`, starts with a letter.
66
+ - **`label`** — **also pre-stringified JSON.** `"\"Asset Tag\""` is the JSON
67
+ string literal `"Asset Tag"`. A bare `"Asset Tag"` fails the install.
68
+ - **`dataType`** — accepted case-insensitively; every shipped file uses
69
+ lowercase. The ones you'll actually use:
70
+
71
+ | `dataType` | Postgres | notes |
72
+ | --- | --- | --- |
73
+ | `text` | `text` | any string, short or long. **Not `long`** — that's the 64-bit integer type. |
74
+ | `integer` | `int` | 32-bit |
75
+ | `long` | `bigint` | 64-bit; use for foreign-key id columns |
76
+ | `decimal` / `currency` | `numeric` | money |
77
+ | `boolean` | `boolean` | |
78
+ | `date` | `date` | |
79
+ | `datetime` | `timestamptz` | |
80
+ | `enum` | `text` + check | needs `typeParams` (below) |
81
+
82
+ - **`typeParams`** — **also a pre-stringified JSON string.** For an enum:
83
+ `"{\"enumValues\":[\"AVAILABLE\",\"ASSIGNED\"]}"`.
84
+ - **`nullable` / `required`** — a `required: true` field **must** also be
85
+ `nullable: false`. A `required: false` field **must** be `nullable: true`. A
86
+ mismatched pair produces a broken `ALTER TABLE` on a table that already has
87
+ rows.
88
+ - **`isUnique`**, **`isIndexed`** — add a unique constraint / index (scoped to
89
+ the tenant).
90
+ - **`isDisplayField: true`** — marks the human-readable "name" column. Pick
91
+ exactly one.
92
+ - **`defaultValue`** — always a string, even for numbers/booleans:
93
+ `"10"`, `"true"`, `"AVAILABLE"`.
94
+
95
+ ### Foreign keys and automatic labels
96
+
97
+ `category_id` is a `long` holding another table's `id`. Two ways to show a name
98
+ instead of a raw number:
99
+
100
+ 1. **Denormalize** — also store `category_name` and write both when you create a
101
+ row (what the tutorial does — simplest).
102
+ 2. **`reference` flag** — add
103
+ `"flags": "{\"reference\":{\"entityType\":\"Employee\"}}"` to the id field.
104
+ Every read then gains a sibling `<field>_label` key
105
+ (`employee_id` → `employee_id_label`) resolved automatically, batched once per
106
+ page load. `entityType` must be a **registered** provider — `"Employee"` is
107
+ the only one shipped today. See
108
+ [resolved reference column](../recipes/resolved-reference-grid-column.md).
109
+
110
+ The tutorial's `oeq_checkout` entity uses the `reference` flag on `employee_id`
111
+ and it was verified live to return `"employee_id_label": "Aria Chen"`.
112
+
113
+ ## Ground yourself first
114
+
115
+ ```bash
116
+ erp schema pull entity-definition # the full contract
117
+ ```
118
+
119
+ Or over MCP: `erp_get_schema {"name":"entity-definition"}`.
120
+
121
+ ## Two file shapes both validate
122
+
123
+ - **`{ "entity": { ... }, "fields": [ ... ] }`** — the packaged artifact shape,
124
+ used in `spk-assembly/metadata/entities/`. Use this.
125
+ - **flat** (`{ "name": ..., "label": ..., "fields": [ ... ] }`) — the live
126
+ `POST /api/v1/entities` request body. Only matters if you're calling the API
127
+ directly.
128
+
129
+ ## How to verify it worked
130
+
131
+ ```bash
132
+ erp schema validate spk-assembly/metadata/entities/oeq_equipment.json --schema entity-definition
133
+ ```
134
+
135
+ ```
136
+ OK — spk-assembly/metadata/entities/oeq_equipment.json matches schema "entity-definition"
137
+ ```
138
+
139
+ After publishing, the table is live:
140
+
141
+ ```bash
142
+ erp api get "/api/v1/entities/oeq_equipment/records/query?size=5"
143
+ ```
144
+
145
+ ```json
146
+ { "rows": [], "total": 0 }
147
+ ```
148
+
149
+ And a record you create comes back with audit columns the engine added for free:
150
+
151
+ ```json
152
+ { "id": 1, "created_by": "you@example.com", "created_at": "2026-09-10T08:08:33.282+00:00",
153
+ "asset_tag": "LAP-001", "name": "Dell Latitude 7440", "status": "AVAILABLE" }
154
+ ```
155
+
156
+ ## Common mistakes
157
+
158
+ | Symptom | Cause | Fix |
159
+ | --- | --- | --- |
160
+ | install fails `invalid input syntax for type json` | `label` is a bare string | make it `"{\"en\":\"...\"}"` (entity) or `"\"...\""` (field) |
161
+ | a field silently missing after install | `required:false` without `nullable:true` on a table with rows | pair them correctly, bump version, republish |
162
+ | big text truncated / rejected | `dataType: "long"` | that's the integer type; use `"text"` |
163
+ | enum column rejects every value | `typeParams` is a nested object, not a string | pre-stringify it |
164
+ | grid shows a raw number for a person | bound to `employee_id` not `employee_id_label` | add the `reference` flag and bind the `_label` |
165
+
166
+ ## What to read next
167
+
168
+ - [Add a data provider](./add-a-data-provider.md) — so a grid can read the table
169
+ - [Build a page](./build-a-page.md)
170
+ - [Seed reference data on install](./seed-data-on-install.md)
@@ -0,0 +1,147 @@
1
+ ---
2
+ title: Add business rules and expressions
3
+ audience: tenant
4
+ ---
5
+
6
+ # Add business rules and expressions
7
+
8
+ ## What you're doing
9
+
10
+ Attaching declarative logic to an entity's lifecycle: reject an invalid save,
11
+ compute a field, kick off a job or workflow. One rule = one file under
12
+ `spk-assembly/metadata/rules/`, evaluated by the Rule Engine at a lifecycle
13
+ point.
14
+
15
+ ## When each action type fires
16
+
17
+ | `triggerEvent` | fires | typical use |
18
+ | --- | --- | --- |
19
+ | `BEFORE_CREATE` / `BEFORE_UPDATE` | before the write | `REJECT` (validation), `SET_VALUE` (computed/defaulted field) |
20
+ | `AFTER_CREATE` | after insert | `EXECUTE_SERVICE` (register a job on first row), `START_WORKFLOW` |
21
+ | `AFTER_UPDATE` | after update | `START_WORKFLOW` on a status transition |
22
+ | `AFTER_DELETE` | after delete | cleanup fan-out |
23
+
24
+ ## The complete example — a validation + a computed field
25
+
26
+ `spk-assembly/metadata/rules/oeq_checkout_dates.json` (a standalone example,
27
+ [real file](../examples/standalone-plugin/spk-assembly/metadata/rules/oeq_checkout_dates.json)):
28
+
29
+ ```json
30
+ {
31
+ "entityType": "oeq_checkout",
32
+ "name": "oeq_checkout_dates",
33
+ "description": "Due date must be on or after the checkout date.",
34
+ "triggerEvent": "BEFORE_CREATE",
35
+ "conditions": { "field": "due_date", "op": "lt", "value": "${record.checkout_date}" },
36
+ "actions": [
37
+ { "type": "REJECT", "message": "Due date cannot be before the checkout date." }
38
+ ],
39
+ "priority": 10,
40
+ "active": true
41
+ }
42
+ ```
43
+
44
+ > In a `metadata/rules/*.json` file `conditions` and `actions` may be authored as
45
+ > real JSON (as above) **or** as pre-stringified JSON (as the workflow-trigger
46
+ > rule does). Both install. The stringified form matches what the live API
47
+ > stores and returns.
48
+
49
+ ## Line by line
50
+
51
+ - **`entityType`** — the entity `name` this rule fires against.
52
+ - **`triggerEvent`** — one of the six lifecycle points above.
53
+ - **`conditions`** — a condition tree. A leaf is `{ field, op, value }`. Compose
54
+ with `{ "all": [...] }`, `{ "any": [...] }`, `{ "not": {...} }`.
55
+ - **operators:** `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `in`,
56
+ `is_null`, `is_not_null`. `is_null` / `is_not_null` take no `value`.
57
+ - **`${record.<field>}`** in a `value` reads another field of the same row.
58
+ - **`${record.<field>__previous`}`** (in an `AFTER_UPDATE` rule) is the value
59
+ before the update.
60
+ - **`actions`** — at least one:
61
+
62
+ | `type` | fields | effect |
63
+ | --- | --- | --- |
64
+ | `REJECT` | `message` | abort the write, surface `message` to the caller |
65
+ | `SET_VALUE` | `field`, `value` | assign a field before the write |
66
+ | `EXECUTE_SERVICE` | `service` (a registered handler bean) | run a named platform service |
67
+ | `START_WORKFLOW` | `workflowName`, `config`, `callbackUrl` | see [Add an approval workflow](./add-an-approval-workflow.md) |
68
+
69
+ - **`priority`** — lower first. Put `REJECT`/`SET_VALUE` validation rules at `10`,
70
+ workflow triggers at `20`.
71
+
72
+ ## Computed fields — two ways
73
+
74
+ 1. **A `SET_VALUE` rule** (above) — imperative, runs at a lifecycle point.
75
+ 2. **A formula field on the entity** — declarative, computed on every read, no
76
+ physical column:
77
+
78
+ ```json
79
+ { "fieldName": "days_out", "label": "\"Days Out\"", "dataType": "formula",
80
+ "flags": "{\"formula\":\"daysBetween(checkout_date, now())\"}" }
81
+ ```
82
+
83
+ The formula language is the platform `ExpressionEvaluator` — whitelisted
84
+ functions only (`daysBetween`, `now`, `coalesce`, arithmetic, comparisons).
85
+ **No** ternary, **no** `in`, **no** `^`. The same evaluator powers
86
+ compliance-sweep conditions (see [Add a compliance-sweep job](./add-a-compliance-job.md)).
87
+
88
+ ## Registering a job on the first config row
89
+
90
+ The pattern jobs use to auto-register themselves — an unconditional
91
+ `AFTER_CREATE` `EXECUTE_SERVICE`:
92
+
93
+ ```json
94
+ {
95
+ "entityType": "entity_aggregation_config",
96
+ "name": "ensure_aggregation_sweep_job_registered",
97
+ "triggerEvent": "AFTER_CREATE",
98
+ "conditions": null,
99
+ "actions": "[{\"type\": \"EXECUTE_SERVICE\", \"service\": \"ensureEntityAggregationSweepJobRegistered\"}]",
100
+ "priority": 10,
101
+ "active": true
102
+ }
103
+ ```
104
+
105
+ `conditions: null` means "always". See [Seed data on install](./seed-data-on-install.md).
106
+
107
+ ## Ground yourself first
108
+
109
+ ```bash
110
+ erp schema pull entity-rule-definition
111
+ erp examples patterns --kind entities
112
+ ```
113
+
114
+ ## How to verify it worked
115
+
116
+ After publishing:
117
+
118
+ ```bash
119
+ erp api get "/api/v1/entity-rules?entityType=oeq_checkout"
120
+ ```
121
+
122
+ lists your rule with `"active": true`. Then trigger it:
123
+
124
+ ```bash
125
+ erp api post "/api/v1/entities/oeq_checkout/records" \
126
+ --body '{"checkout_number":"CO-9","equipment_id":1,"employee_id":1,"checkout_date":"2026-09-10","due_date":"2026-09-01","status":"DRAFT"}'
127
+ ```
128
+
129
+ ```json
130
+ { "error": "Due date cannot be before the checkout date." }
131
+ ```
132
+
133
+ ## Common mistakes
134
+
135
+ | Symptom | Cause | Fix |
136
+ | --- | --- | --- |
137
+ | a `!= null` / `!=null` comparison never matches | action-engine quirk | use `is_not_null` |
138
+ | rule fires on every update, not just the transition | no `__previous` guard | add `{ "field": "status__previous", "op": "neq", "value": "..." }` |
139
+ | `SET_VALUE` doesn't stick | used `AFTER_*` (write already happened) | use `BEFORE_CREATE` / `BEFORE_UPDATE` |
140
+ | formula field errors | used ternary / `in` / unsupported function | rewrite with whitelisted functions only |
141
+ | install rejects the rule file | `actions` is a bare object, not an array/string | `actions` is an array (or a stringified array) |
142
+
143
+ ## What to read next
144
+
145
+ - [Add an approval workflow](./add-an-approval-workflow.md)
146
+ - [Add a compliance-sweep job](./add-a-compliance-job.md)
147
+ - [Seed config and reference data on install](./seed-data-on-install.md)
@@ -0,0 +1,121 @@
1
+ ---
2
+ title: Add translations (i18n)
3
+ audience: tenant
4
+ ---
5
+
6
+ # Add translations (i18n)
7
+
8
+ ## What you're doing
9
+
10
+ Every user-visible string on a page is an **i18n key**, not literal text. Page
11
+ titles, button labels, column headers, KPI labels, enum option labels — all
12
+ keys. You supply the actual words in `spk-assembly/metadata/i18n/en.json` (and
13
+ `fr.json`, `de.json`, … for other locales).
14
+
15
+ If a key has no entry, the UI renders the raw key
16
+ (`office-equipment.equipment-register.title`) — ugly, and flagged by the linter.
17
+
18
+ ## The naming convention
19
+
20
+ ```
21
+ <pluginId>.<page-name>.<what>
22
+ ```
23
+
24
+ Examples from the tutorial:
25
+
26
+ | Key | Value |
27
+ | --- | --- |
28
+ | `office-equipment.equipment-register.title` | `Equipment Register` |
29
+ | `office-equipment.equipment-register.newBtn` | `New` |
30
+ | `office-equipment.equipment-register.column.asset_tag` | `Asset Tag` |
31
+ | `office-equipment.equipment-register.kpi.available` | `Available` |
32
+ | `office-equipment.equipment-register.status.AVAILABLE` | `Available` |
33
+ | `office-equipment.equipment-checkout.status.OVERDUE` | `Overdue` |
34
+ | `office-equipment.application.title` | `Office Equipment` |
35
+ | `office-equipment.module.title` | `Equipment` |
36
+
37
+ ## The complete example
38
+
39
+ `spk-assembly/metadata/i18n/en.json` (excerpt — the
40
+ [real file](../tutorial/example-plugin/spk-assembly/metadata/i18n/en.json) has
41
+ ~70 keys):
42
+
43
+ ```json
44
+ {
45
+ "office-equipment.application.title": "Office Equipment",
46
+ "office-equipment.module.title": "Equipment",
47
+ "office-equipment.equipment-register.title": "Equipment Register",
48
+ "office-equipment.equipment-register.subtitle": "Office Equipment tutorial module",
49
+ "office-equipment.equipment-register.newBtn": "New",
50
+ "office-equipment.equipment-register.exportBtn": "Export CSV",
51
+ "office-equipment.equipment-register.kpi.total": "Total",
52
+ "office-equipment.equipment-register.kpi.available": "Available",
53
+ "office-equipment.equipment-register.kpi.assigned": "Assigned",
54
+ "office-equipment.equipment-register.column.asset_tag": "Asset Tag",
55
+ "office-equipment.equipment-register.column.name": "Name",
56
+ "office-equipment.equipment-register.column.status": "Status",
57
+ "office-equipment.equipment-register.status.AVAILABLE": "Available",
58
+ "office-equipment.equipment-register.status.ASSIGNED": "Assigned",
59
+ "office-equipment.equipment-register.form.title": "Equipment",
60
+ "office-equipment.equipment-register.form.asset_tag": "Asset Tag",
61
+ "office-equipment.equipment-register.form.saveBtn": "Save",
62
+ "office-equipment.equipment-register.form.cancelBtn": "Cancel"
63
+ }
64
+ ```
65
+
66
+ It's a flat object — dotted keys, string values. No nesting.
67
+
68
+ ## Line by line — where each key type comes from
69
+
70
+ | In a page file | i18n key referenced |
71
+ | --- | --- |
72
+ | `definition.title` | the page title |
73
+ | a block's `properties.textKey` / `labelKey` (with `source: "static"`) | any static label |
74
+ | a grid column's `headerKey` | column header |
75
+ | a `core.select` option's `labelKey` | dropdown option label |
76
+ | `designer.displayNameKey` / `descriptionKey` | Studio explorer label |
77
+ | `metadata/application/*.json` `titleKey`, `metadata/module/*.json` `titleKey` | app / module name |
78
+
79
+ Every one of those must have a matching entry in `en.json`.
80
+
81
+ ## Adding another language
82
+
83
+ Add `fr.json` with the same keys, French values. Missing keys in `fr.json` fall
84
+ back to `en.json`. The ERP picks the file by the user's locale.
85
+
86
+ ## Keeping it in sync automatically
87
+
88
+ You don't have to hand-track keys. A small script that walks your page/menu/app
89
+ files collecting every `*Key` / `headerKey` / static `title` value, then writes
90
+ `en.json` with a humanised default for any missing key, is the practical
91
+ approach — the tutorial's build uses exactly this. The linter is your safety
92
+ net.
93
+
94
+ ## How to verify it worked
95
+
96
+ ```bash
97
+ erp plugin test office-equipment/spk-assembly
98
+ ```
99
+
100
+ A missing key is reported as:
101
+
102
+ ```
103
+ I18N-001 equipment-register.json: i18n key
104
+ "office-equipment.equipment-register.kpi.total" not found in metadata/i18n/en.json
105
+ ```
106
+
107
+ A clean run means every referenced key resolves.
108
+
109
+ ## Common mistakes
110
+
111
+ | Symptom | Cause | Fix |
112
+ | --- | --- | --- |
113
+ | UI shows `office-equipment.x.y` literally | key missing from `en.json` | add it (linter tells you which) |
114
+ | `en.json` is nested objects | wrong shape | flat `{ "a.b.c": "text" }` |
115
+ | enum chips show the raw enum value | no `...status.<VALUE>` keys | add one per enum value |
116
+ | translation not applied | `fr.json` missing keys, or wrong locale on the user | keys fall back to `en`; check the user's locale |
117
+
118
+ ## What to read next
119
+
120
+ - [Build a page](./build-a-page.md)
121
+ - [Validate and test a plugin](./validate-and-test.md)
@@ -0,0 +1,149 @@
1
+ ---
2
+ title: Add menus
3
+ audience: tenant
4
+ ---
5
+
6
+ # Add menus
7
+
8
+ ## What you're doing
9
+
10
+ Putting your pages in the ERP's navigation. A **menu** is a tree of nodes, each
11
+ pointing at a route. One file under `spk-assembly/metadata/menu/`.
12
+
13
+ There is a second, easy-to-miss step: a **master menu scaffold** file that some
14
+ deployments keep as the merged sidenav. If your plugin's module belongs to a
15
+ larger application (e.g. HCM), you update that too — see below.
16
+
17
+ ## The complete example
18
+
19
+ `spk-assembly/metadata/menu/office-equipment-menu.json` —
20
+ [real file](../tutorial/example-plugin/spk-assembly/metadata/menu/office-equipment-menu.json):
21
+
22
+ ```json
23
+ {
24
+ "name": "office-equipment-menu",
25
+ "description": "Navigation contributed by the Office Equipment plugin.",
26
+ "definition": {
27
+ "contractVersion": 1,
28
+ "name": "office-equipment-menu",
29
+ "nodes": [
30
+ {
31
+ "id": "office-equipment.equipment",
32
+ "name": "equipment",
33
+ "displayName": "Office Equipment",
34
+ "sequence": 0,
35
+ "navigation": { "route": null, "openMode": "current-tab" },
36
+ "visibility": { "visible": true, "enabled": true, "devices": [] },
37
+ "color": null,
38
+ "children": [
39
+ {
40
+ "id": "office-equipment.equipment.equipment-register",
41
+ "name": "equipment-register",
42
+ "displayName": "Equipment Register",
43
+ "sequence": 0,
44
+ "children": [],
45
+ "navigation": { "route": "/office-equipment/equipment-register", "openMode": "current-tab" },
46
+ "visibility": { "visible": true, "enabled": true, "devices": [] },
47
+ "color": null
48
+ },
49
+ {
50
+ "id": "office-equipment.equipment.equipment-catalog",
51
+ "name": "equipment-catalog",
52
+ "displayName": "Equipment Catalog",
53
+ "sequence": 1,
54
+ "children": [],
55
+ "navigation": { "route": "/office-equipment/equipment-catalog", "openMode": "current-tab" },
56
+ "visibility": { "visible": true, "enabled": true, "devices": [] },
57
+ "color": null
58
+ },
59
+ {
60
+ "id": "office-equipment.equipment.equipment-checkout",
61
+ "name": "equipment-checkout",
62
+ "displayName": "Equipment Checkout",
63
+ "sequence": 2,
64
+ "children": [],
65
+ "navigation": { "route": "/office-equipment/equipment-checkout", "openMode": "current-tab" },
66
+ "visibility": { "visible": true, "enabled": true, "devices": [] },
67
+ "color": null
68
+ }
69
+ ]
70
+ }
71
+ ]
72
+ },
73
+ "modules": ["office-equipment"]
74
+ }
75
+ ```
76
+
77
+ ## Line by line
78
+
79
+ - **`nodes`** — the tree. A parent node with `navigation.route: null` is a group
80
+ header; its `children` are the clickable items.
81
+ - **`id`** — dotted, unique, conventionally `<pluginId>.<group>.<page>`.
82
+ - **`name`** — a short slug.
83
+ - **`displayName`** — the label shown. (For a translated label use
84
+ `displayNameKey` with an i18n key instead.)
85
+ - **`sequence`** — sort order among siblings.
86
+ - **`navigation.route`** — the page's `route.pattern`, *exactly*. This is the
87
+ internal routing key, not the browser URL.
88
+ - **`visibility.devices`** — `[]` means all devices; `["mobile"]` restricts.
89
+ - **`modules`** — the module id(s) this menu contributes to. Match your pages'
90
+ `modules`.
91
+
92
+ ## Scaffold with the CLI
93
+
94
+ ```bash
95
+ erp menu create office-equipment office-equipment-menu \
96
+ --display-name "Equipment Register" --route /office-equipment/equipment-register --icon devices
97
+ ```
98
+
99
+ produces a valid one-node menu you then flesh out. (Run it from PowerShell on
100
+ Windows, or the leading `/` in `--route` gets mangled by Git Bash.)
101
+
102
+ ## The master-scaffold sync note
103
+
104
+ Some applications (the HCM suite is the canonical case) keep a single
105
+ hand-maintained "master menu" file — e.g. `hcm-foundation-main-menu.json` — that
106
+ is the merged sidenav shown whether or not a given plugin is installed. If your
107
+ module attaches to such an application, a new page needs its node added in **two
108
+ places**:
109
+
110
+ 1. your own `metadata/menu/<plugin>-menu.json` (above), and
111
+ 2. the application's master menu scaffold.
112
+
113
+ Skipping (2) means the item is missing from the merged sidenav for anyone who
114
+ sees the app but hasn't installed your plugin. A **standalone** plugin that ships
115
+ its own `metadata/application/*.json` (like the tutorial) has no master scaffold
116
+ to sync — one menu file is enough.
117
+
118
+ ## Ground yourself first
119
+
120
+ ```bash
121
+ erp schema pull menu
122
+ ```
123
+
124
+ ## How to verify it worked
125
+
126
+ ```bash
127
+ erp schema validate spk-assembly/metadata/menu/office-equipment-menu.json --schema menu
128
+ ```
129
+
130
+ ```
131
+ OK — ... matches schema "menu"
132
+ ```
133
+
134
+ After publishing, open the ERP and confirm the group and its items appear in the
135
+ sidenav under your application, and each item navigates to the right page.
136
+
137
+ ## Common mistakes
138
+
139
+ | Symptom | Cause | Fix |
140
+ | --- | --- | --- |
141
+ | menu item 404s | `navigation.route` doesn't match the page's `route.pattern` | copy it exactly |
142
+ | item appears but page is blank | page's `modules` array doesn't include this module | align them |
143
+ | item missing from the merged app sidenav | didn't sync the master menu scaffold | add the node there too |
144
+ | `erp menu create` route is a Windows path | Git Bash `/` mangling | run from PowerShell / `MSYS_NO_PATHCONV=1` |
145
+
146
+ ## What to read next
147
+
148
+ - [Add translations (i18n)](./add-i18n.md)
149
+ - [Make a plugin work on desktop, tablet, and mobile](./responsive-plugin.md)