@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,157 @@
1
+ ---
2
+ title: "1. Create the plugin"
3
+ audience: tenant
4
+ ---
5
+
6
+ # 1. Create the plugin
7
+
8
+ ## Scaffold
9
+
10
+ ```bash
11
+ erp plugin create example-plugin --name "Office Equipment" --type business-app
12
+ ```
13
+
14
+ > The folder is `example-plugin/` to match this docs tree. The plugin **id** we
15
+ > use is `office-equipment` — set below. (In real life pick one name and use it
16
+ > for both.)
17
+
18
+ You get `example-plugin/spk-assembly/` with empty `metadata/*` folders and a
19
+ `plugin.json` with `"mainClass": null`.
20
+
21
+ ## Edit the manifest
22
+
23
+ `spk-assembly/plugin.json` — the finished version
24
+ ([real file](./example-plugin/spk-assembly/plugin.json)):
25
+
26
+ ```json
27
+ {
28
+ "id": "office-equipment",
29
+ "name": "Office Equipment",
30
+ "version": "1.0.3",
31
+ "type": "business-application",
32
+ "schemaName": "erp_core",
33
+ "vendor": "ACME Corp",
34
+ "license": "Proprietary",
35
+ "licenseTier": "free",
36
+ "category": "custom",
37
+ "erpVersion": "*",
38
+ "minErpVersion": "1.0",
39
+ "maxErpVersion": "99.0",
40
+ "mainClass": null,
41
+ "dependencies": [],
42
+ "optionalDependencies": [],
43
+ "owner": "business",
44
+ "editable": false,
45
+ "extendable": true,
46
+ "capabilitiesProvided": [],
47
+ "capabilitiesRequired": [],
48
+ "featureFlags": [],
49
+ "configSchemaJson": null,
50
+ "roles": [
51
+ {
52
+ "name": "Office Equipment Manager",
53
+ "code": "OFFICE_EQUIPMENT_MANAGER",
54
+ "description": "Full control of the Office Equipment module.",
55
+ "permissionKeys": [
56
+ "OeqCategory.View", "OeqCategory.Create", "OeqCategory.Edit",
57
+ "OeqEquipment.View", "OeqEquipment.Create", "OeqEquipment.Edit",
58
+ "OeqCheckout.View", "OeqCheckout.Create", "OeqCheckout.Submit", "OeqCheckout.Approve",
59
+ "Dashboard.View"
60
+ ]
61
+ }
62
+ ],
63
+ "runtimeModes": ["embedded"],
64
+ "serviceDeployment": null
65
+ }
66
+ ```
67
+
68
+ Key choices:
69
+
70
+ - **`id: "office-equipment"`** — used in every route, page id, and i18n key
71
+ from here on. Chosen once.
72
+ - **`role.name` and `role.code`** are prefixed (`Office Equipment Manager` /
73
+ `OFFICE_EQUIPMENT_MANAGER`). A plain "Equipment Manager" would collide with
74
+ another installed plugin's role and fail the install — this actually happened
75
+ while writing the tutorial.
76
+ - **`schemaName: "erp_core"`** — the scaffold default. See the note in
77
+ [Add an entity](../guides/add-an-entity.md) about when a dedicated schema is
78
+ worth it; for this module `erp_core` is fine end to end, including the
79
+ reminder job in chapter 7.
80
+
81
+ ## No Java class
82
+
83
+ This tutorial module ships **zero Java**. `plugin.json` keeps `"mainClass":
84
+ null` exactly as the scaffold wrote it, and there is no `spk-assembly/lib/`.
85
+ On install the platform loads the plugin through a built-in generic no-op PF4J
86
+ entry point — start/stop/enable/disable/upgrade, migrations, and data seeding
87
+ all run normally. Everything the module does is JSON under
88
+ `spk-assembly/metadata/`.
89
+
90
+ You would only add a Java class (a `Plugin` subclass, compiled against `pf4j` +
91
+ `slf4j-api`, jar in `spk-assembly/lib/`, `mainClass` set to its FQN) for
92
+ behaviour the Entity / Query / Rule / Workflow / Job engines genuinely can't
93
+ express — this module never needs one.
94
+
95
+ ## The application and module (standalone plugin)
96
+
97
+ A standalone plugin (one that isn't part of a bigger suite like HCM) ships its
98
+ own application and module so its pages are routable.
99
+
100
+ `spk-assembly/metadata/application/office-equipment-app.json`
101
+ ([real file](./example-plugin/spk-assembly/metadata/application/office-equipment-app.json)):
102
+
103
+ ```json
104
+ {
105
+ "name": "office-equipment-app",
106
+ "description": "The Office Equipment plugin's own Application.",
107
+ "definition": {
108
+ "id": "office-equipment",
109
+ "titleKey": "office-equipment.application.title",
110
+ "urlSlug": "office-equipment",
111
+ "homeRoute": "/app/office-equipment/equipment/office-equipment/equipment-register",
112
+ "tier": "solution",
113
+ "icon": "devices",
114
+ "color": "#2563EB"
115
+ }
116
+ }
117
+ ```
118
+
119
+ `spk-assembly/metadata/module/office-equipment-module.json`
120
+ ([real file](./example-plugin/spk-assembly/metadata/module/office-equipment-module.json)):
121
+
122
+ ```json
123
+ {
124
+ "name": "office-equipment",
125
+ "description": "The single Module the Office Equipment pages attach to.",
126
+ "definition": {
127
+ "id": "office-equipment-module",
128
+ "titleKey": "office-equipment.module.title",
129
+ "urlSlug": "equipment",
130
+ "tier": "solution",
131
+ "icon": "inventory_2",
132
+ "color": "#2563EB"
133
+ },
134
+ "applications": ["office-equipment-app"]
135
+ }
136
+ ```
137
+
138
+ The reachable URL for a page is
139
+ `/app/<app urlSlug>/<module urlSlug>/<pluginId>/<page name>` — so
140
+ `/app/office-equipment/equipment/office-equipment/equipment-register`.
141
+
142
+ ## Verify
143
+
144
+ ```bash
145
+ erp plugin test example-plugin/spk-assembly
146
+ ```
147
+
148
+ ```
149
+ Plugin Tests
150
+ ────────────────────────────────────────
151
+ ✓ plugin.json:valid-json
152
+ 1 passed, 0 failed, 0 semantic warning(s), 3 skipped
153
+ ```
154
+
155
+ A healthy empty plugin. Next: give it a table.
156
+
157
+ **Next:** [2. The equipment category entity + seeded data](./02-category-entity-and-seed.md)
@@ -0,0 +1,94 @@
1
+ ---
2
+ title: "2. The equipment category entity + seeded data"
3
+ audience: tenant
4
+ ---
5
+
6
+ # 2. The equipment category entity + seeded data
7
+
8
+ ## The entity
9
+
10
+ `spk-assembly/metadata/entities/oeq_category.json`
11
+ ([real file](./example-plugin/spk-assembly/metadata/entities/oeq_category.json)):
12
+
13
+ ```json
14
+ {
15
+ "entity": {
16
+ "name": "oeq_category",
17
+ "tableName": "oeq_category",
18
+ "label": "{\"en\":\"Equipment Category\"}",
19
+ "category": "office-equipment",
20
+ "icon": "category",
21
+ "color": "#2563EB",
22
+ "pkStrategy": "identity"
23
+ },
24
+ "fields": [
25
+ { "fieldName": "category_code", "label": "\"Category Code\"", "dataType": "text", "nullable": false, "required": true, "isUnique": true, "isIndexed": true, "isDisplayField": true, "displayOrder": 1 },
26
+ { "fieldName": "category_name", "label": "\"Category Name\"", "dataType": "text", "nullable": false, "required": true, "displayOrder": 2 },
27
+ { "fieldName": "description", "label": "\"Description\"", "dataType": "text", "nullable": true, "required": false, "displayOrder": 3 },
28
+ { "fieldName": "status", "label": "\"Status\"", "dataType": "enum", "nullable": false, "required": true, "isIndexed": true, "defaultValue": "ACTIVE", "typeParams": "{\"enumValues\":[\"ACTIVE\",\"INACTIVE\"]}", "displayOrder": 4 },
29
+ { "fieldName": "seeded_by", "label": "\"Seeded By\"", "dataType": "text", "nullable": true, "required": false, "displayOrder": 5 }
30
+ ]
31
+ }
32
+ ```
33
+
34
+ Note the two JSON-encoding rules: `entity.label` is `"{\"en\":\"...\"}"` (an
35
+ i18n object as a string), each field `label` is `"\"...\""` (a JSON string
36
+ literal). A bare string in either place fails the install.
37
+
38
+ `seeded_by` is a plain text column the seeder will stamp with the plugin id.
39
+
40
+ ## The seed data
41
+
42
+ `spk-assembly/metadata/seed-data/office-equipment-categories.json`
43
+ ([real file](./example-plugin/spk-assembly/metadata/seed-data/office-equipment-categories.json)):
44
+
45
+ ```json
46
+ {
47
+ "entity": "oeq_category",
48
+ "keyFields": ["category_code"],
49
+ "source": "office-equipment",
50
+ "rows": [
51
+ { "category_code": "LAPTOP", "category_name": "Laptops", "description": "Portable computers", "status": "ACTIVE", "seeded_by": "office-equipment" },
52
+ { "category_code": "MONITOR", "category_name": "Monitors", "description": "External displays", "status": "ACTIVE", "seeded_by": "office-equipment" },
53
+ { "category_code": "PHONE", "category_name": "Phones", "description": "Mobile handsets", "status": "ACTIVE", "seeded_by": "office-equipment" },
54
+ { "category_code": "HEADSET", "category_name": "Headsets", "description": "Audio headsets", "status": "ACTIVE", "seeded_by": "office-equipment" }
55
+ ]
56
+ }
57
+ ```
58
+
59
+ - `keyFields: ["category_code"]` — the natural key. Re-installs match on this and
60
+ never duplicate.
61
+ - `source: "office-equipment"` — stamped into `seeded_by`.
62
+
63
+ ## Verify
64
+
65
+ ```bash
66
+ erp schema validate spk-assembly/metadata/entities/oeq_category.json --schema entity-definition
67
+ erp schema validate spk-assembly/metadata/seed-data/office-equipment-categories.json --schema plugin-seed-data
68
+ ```
69
+
70
+ Both print `OK — ... matches schema ...`.
71
+
72
+ After the module is published (chapter 8), this is what the seeding did — from
73
+ the real install log:
74
+
75
+ ```
76
+ PluginDataSeedInstaller: office-equipment-categories.json -> entity oeq_category (tenant 2): 4 created, 0 updated, 0 unchanged
77
+ ```
78
+
79
+ and the rows are live:
80
+
81
+ ```bash
82
+ $ erp api get "/api/v1/entities/oeq_category/records/query?size=10"
83
+ { "rows": [
84
+ { "id": 1, "category_code": "LAPTOP", "category_name": "Laptops", "status": "ACTIVE", "seeded_by": "office-equipment" },
85
+ { "id": 2, "category_code": "MONITOR", "category_name": "Monitors", "status": "ACTIVE", "seeded_by": "office-equipment" },
86
+ { "id": 3, "category_code": "PHONE", "category_name": "Phones", "status": "ACTIVE", "seeded_by": "office-equipment" },
87
+ { "id": 4, "category_code": "HEADSET", "category_name": "Headsets", "status": "ACTIVE", "seeded_by": "office-equipment" }
88
+ ], "total": 4 }
89
+ ```
90
+
91
+ Publish again with a fifth category and only that one is inserted — the seeder is
92
+ idempotent by `keyFields`.
93
+
94
+ **Next:** [3. The equipment register entity](./03-equipment-entity.md)
@@ -0,0 +1,121 @@
1
+ ---
2
+ title: "3. The equipment register entity"
3
+ audience: tenant
4
+ ---
5
+
6
+ # 3. The equipment register entity
7
+
8
+ ## The entity
9
+
10
+ `spk-assembly/metadata/entities/oeq_equipment.json`
11
+ ([real file](./example-plugin/spk-assembly/metadata/entities/oeq_equipment.json)):
12
+
13
+ ```json
14
+ {
15
+ "entity": {
16
+ "name": "oeq_equipment",
17
+ "tableName": "oeq_equipment",
18
+ "label": "{\"en\":\"Equipment\"}",
19
+ "category": "office-equipment",
20
+ "icon": "devices",
21
+ "color": "#2563EB",
22
+ "pkStrategy": "identity"
23
+ },
24
+ "fields": [
25
+ { "fieldName": "asset_tag", "label": "\"Asset Tag\"", "dataType": "text", "nullable": false, "required": true, "isUnique": true, "isIndexed": true, "isDisplayField": true, "displayOrder": 1 },
26
+ { "fieldName": "name", "label": "\"Name\"", "dataType": "text", "nullable": false, "required": true, "displayOrder": 2 },
27
+ { "fieldName": "category_id", "label": "\"Category Id\"", "dataType": "long", "nullable": true, "required": false, "isIndexed": true, "displayOrder": 3 },
28
+ { "fieldName": "category_name", "label": "\"Category\"", "dataType": "text", "nullable": true, "required": false, "displayOrder": 4 },
29
+ { "fieldName": "serial_number", "label": "\"Serial Number\"", "dataType": "text", "nullable": true, "required": false, "displayOrder": 5 },
30
+ { "fieldName": "status", "label": "\"Status\"", "dataType": "enum", "nullable": false, "required": true, "isIndexed": true, "defaultValue": "AVAILABLE", "typeParams": "{\"enumValues\":[\"AVAILABLE\",\"ASSIGNED\",\"MAINTENANCE\",\"RETIRED\"]}", "displayOrder": 6 },
31
+ { "fieldName": "purchase_date", "label": "\"Purchase Date\"", "dataType": "date", "nullable": true, "required": false, "displayOrder": 7 },
32
+ { "fieldName": "warranty_expiry_date", "label": "\"Warranty Expiry Date\"", "dataType": "date", "nullable": true, "required": false, "displayOrder": 8 },
33
+ { "fieldName": "notes", "label": "\"Notes\"", "dataType": "text", "nullable": true, "required": false, "displayOrder": 9 }
34
+ ]
35
+ }
36
+ ```
37
+
38
+ ## Design notes
39
+
40
+ - **`category_id` (long) + `category_name` (text)** — we *denormalize* the
41
+ category name so the grid can show it without a join. The create dialog writes
42
+ both. (The alternative — a `reference` flag — needs a registered provider;
43
+ chapter 5 uses that for the employee field, where one exists.)
44
+ - **`status` enum** with `AVAILABLE` as default.
45
+ - **`asset_tag`** is `isDisplayField` and `isUnique`.
46
+
47
+ ## The provider
48
+
49
+ Every entity you want a grid over needs a data provider.
50
+ `spk-assembly/metadata/provider/oeq-equipment-provider.json`
51
+ ([real file](./example-plugin/spk-assembly/metadata/provider/oeq-equipment-provider.json)):
52
+
53
+ ```json
54
+ {
55
+ "name": "oeq-equipment-provider",
56
+ "description": "Data Provider for oeq_equipment (grid + CRUD).",
57
+ "definition": {
58
+ "kind": "rest",
59
+ "connectionRef": "self",
60
+ "basePath": "/api/v1/entities/oeq_equipment/records",
61
+ "supports": ["search", "get", "create", "update"]
62
+ },
63
+ "metadata": {}, "modules": []
64
+ }
65
+ ```
66
+
67
+ ## The category lookup plumbing
68
+
69
+ The register page's create dialog will have a **category picker**. That needs a
70
+ Data View producing `{ value, label }` pairs and a `search` Data Service over it.
71
+
72
+ `spk-assembly/metadata/data_view/oeq-category-select-options-view.json`
73
+ ([real file](./example-plugin/spk-assembly/metadata/data_view/oeq-category-select-options-view.json)) —
74
+ selects `t.id` as `value` and a concatenated `category_code - category_name` as
75
+ `label`.
76
+
77
+ `spk-assembly/metadata/data_service/oeq-category-search.json`
78
+ ([real file](./example-plugin/spk-assembly/metadata/data_service/oeq-category-search.json)):
79
+
80
+ ```json
81
+ {
82
+ "name": "oeq-category-search",
83
+ "description": "Typeahead category search for the category lookup field.",
84
+ "definition": {
85
+ "operation": "search",
86
+ "source": { "kind": "dataView", "dataViewName": "oeq-category-select-options-view" },
87
+ "filters": [ { "field": "t.category_name", "operator": "contains", "value": "${param.search}" } ],
88
+ "parameters": [ { "name": "search", "type": "string" } ]
89
+ },
90
+ "metadata": {}, "modules": []
91
+ }
92
+ ```
93
+
94
+ ## The KPI Data Services
95
+
96
+ The register page shows Total / Available / Assigned. Three `count` services plus
97
+ a `composite`:
98
+
99
+ - `oeq-equipment-count-total.json` — `count`, no filter
100
+ - `oeq-equipment-count-available.json` — `count`, `filters: [{ "field": "status", "operator": "eq", "value": "AVAILABLE" }]`
101
+ - `oeq-equipment-count-assigned.json` — `count`, filter `status = ASSIGNED`
102
+ - `oeq-equipment-kpis.json` — `composite` with three steps `total`/`available`/`assigned`
103
+
104
+ ([all four real files](./example-plugin/spk-assembly/metadata/data_service/))
105
+
106
+ ## Verify
107
+
108
+ ```bash
109
+ node developer-docs/examples/test-examples.mjs
110
+ ```
111
+
112
+ validates every one of these files against its schema. After publishing:
113
+
114
+ ```bash
115
+ $ erp api post "/api/v1/data-services/oeq-equipment-kpis/execute" --body "{}"
116
+ { "results": { "total": { "value": 0 }, "available": { "value": 0 }, "assigned": { "value": 0 } } }
117
+ ```
118
+
119
+ That `results.<step>.value` shape is what the page will bind.
120
+
121
+ **Next:** [4. The equipment register page](./04-equipment-register-page.md)
@@ -0,0 +1,153 @@
1
+ ---
2
+ title: "4. The equipment register page"
3
+ audience: tenant
4
+ ---
5
+
6
+ # 4. The equipment register page
7
+
8
+ The full page file is large — every widget is spelled out — so read
9
+ [`equipment-register.json`](./example-plugin/spk-assembly/metadata/page/equipment-register.json)
10
+ open in another tab. This chapter walks its structure section by section.
11
+
12
+ ## The wrapper
13
+
14
+ ```json
15
+ {
16
+ "name": "equipment-register",
17
+ "description": "Office Equipment tutorial - Equipment Register.",
18
+ "definition": {
19
+ "contractVersion": 1,
20
+ "id": "office-equipment.equipment-register",
21
+ "version": "1.0.0",
22
+ "publisher": "office-equipment",
23
+ "title": "office-equipment.equipment-register.title",
24
+ "usesAppShell": true,
25
+ "route": { "pattern": "/office-equipment/equipment-register", "params": [] },
26
+ "designer": {
27
+ "displayNameKey": "office-equipment.equipment-register.title",
28
+ "descriptionKey": "office-equipment.equipment-register.title",
29
+ "icon": "devices", "category": "list", "propertyGroups": [], "preview": { "kind": "page" }
30
+ },
31
+ "rows": [ { "id": "row-1", "columns": [ { "id": "col-1", "items": [ /* the 6 sections */ ] } ] } ]
32
+ },
33
+ "metadata": { "dataSource": "oeq-equipment-provider" },
34
+ "modules": ["office-equipment"]
35
+ }
36
+ ```
37
+
38
+ - **`metadata.dataSource: "oeq-equipment-provider"`** — the whole grid wiring. It
39
+ matches the provider file's `name`. Page-level.
40
+ - **`modules: ["office-equipment"]`** — the module `name`, so the page lists in
41
+ the sidenav.
42
+ - **`id`** must be `<pluginId>.<name>`.
43
+
44
+ ## Section 1 — the mount loader
45
+
46
+ A hidden `core.container` whose `mounted` event calls the KPI composite and
47
+ seeds page state:
48
+
49
+ ```json
50
+ "events": { "mounted": { "source": "action-chain", "actions": [
51
+ { "id": "a0", "order": 0, "type": "callApi",
52
+ "config": { "connectionRef": "self", "path": "/api/v1/data-services/oeq-equipment-kpis/execute", "httpMethod": "POST", "params": {} },
53
+ "output": "kpi" },
54
+ { "id": "a1", "order": 1, "type": "setValue", "config": { "field": "page.kpiTotal", "value": "${kpi.results.total.value}" } },
55
+ { "id": "a2", "order": 2, "type": "setValue", "config": { "field": "page.kpiAvailable", "value": "${kpi.results.available.value}" } },
56
+ { "id": "a3", "order": 3, "type": "setValue", "config": { "field": "page.kpiAssigned", "value": "${kpi.results.assigned.value}" } },
57
+ { "id": "a4", "order": 4, "type": "setValue", "config": { "field": "page.gridRefreshTick", "value": 0 } },
58
+ { "id": "a5", "order": 5, "type": "setValue", "config": { "field": "page.formDialogOpen", "value": false } }
59
+ ] } }
60
+ ```
61
+
62
+ `${kpi.results.total.value}` — composite response shape. This was verified live.
63
+
64
+ ## Section 2 — the header
65
+
66
+ A flex `core.container` with the title/subtitle labels on the left and two
67
+ buttons on the right: **Export CSV** (a `downloadFile` action to
68
+ `/api/v1/entities/oeq_equipment/records/export?format=csv`) and **New** (clears
69
+ the form keys, opens the dialog).
70
+
71
+ ## Section 3 — the KPI row
72
+
73
+ Three `core.kpi-card` blocks bound to `page.kpiTotal` / `kpiAvailable` /
74
+ `kpiAssigned`, each with a `labelKey`, `format: "number"`, `colorToken`, `icon`.
75
+
76
+ ## Section 4 — the filter row
77
+
78
+ A `core.text-input` bound to `page.gridExternalFilter.search` and a
79
+ `core.select` bound to `page.gridExternalFilter.status`. Each has a `committed`
80
+ handler writing `${event.new}` back to its key.
81
+
82
+ ## Section 5 — the grid
83
+
84
+ ```json
85
+ {
86
+ "blockType": "core.grid",
87
+ "properties": {
88
+ "columns": { "source": "static", "value": [
89
+ { "name": "asset_tag", "type": "string", "headerKey": "office-equipment.equipment-register.column.asset_tag" },
90
+ { "name": "name", "type": "string", "headerKey": "...column.name" },
91
+ { "name": "category_name", "type": "string", "headerKey": "...column.category_name" },
92
+ { "name": "serial_number", "type": "string", "headerKey": "...column.serial_number" },
93
+ { "name": "status", "type": "string", "headerKey": "...column.status", "renderAs": "chip",
94
+ "colorMap": { "AVAILABLE": "success", "ASSIGNED": "info", "MAINTENANCE": "warning", "RETIRED": "default" } }
95
+ ] },
96
+ "pageSize": { "source": "static", "value": 25 },
97
+ "externalFilter": { "source": "binding", "binding": { "scope": "page", "key": "gridExternalFilter" } },
98
+ "refreshTrigger": { "source": "binding", "binding": { "scope": "page", "key": "gridRefreshTick" } }
99
+ }
100
+ }
101
+ ```
102
+
103
+ The grid has **no** data property — it reads the page's `metadata.dataSource`.
104
+ `category_name` shows the denormalized value.
105
+
106
+ ## Section 6 — the create dialog
107
+
108
+ A `core.dialog` bound to `page.formDialogOpen`. Inside: text inputs for asset
109
+ tag, name, serial number, notes; a `core.select` for status; `core.date-picker`
110
+ for the two dates; and a **`core.lookup`** for the category:
111
+
112
+ ```json
113
+ {
114
+ "blockType": "core.lookup",
115
+ "properties": {
116
+ "value": { "source": "binding", "binding": { "scope": "page", "key": "formCategoryId" } },
117
+ "recordType": { "source": "static", "value": "OeqCategory" },
118
+ "optionsSourceKey": { "source": "static", "value": "oeq-category-search" }
119
+ },
120
+ "events": { "committed": { "source": "action-chain", "actions": [
121
+ { "id": "a0", "order": 0, "type": "setValue", "config": { "field": "page.formCategoryId", "value": "${event.new}" } }
122
+ ] } }
123
+ }
124
+ ```
125
+
126
+ The Save button `POST`s to `/api/v1/entities/oeq_equipment/records` with a
127
+ `params` map built from `${page.form*}`, closes the dialog, toggles
128
+ `page.gridRefreshTick`, and shows a toast.
129
+
130
+ ## Verify
131
+
132
+ ```bash
133
+ erp plugin test example-plugin/spk-assembly
134
+ ```
135
+
136
+ ```
137
+ ✓ page:equipment-register.json
138
+ 4 passed, 0 failed, 0 semantic warning(s), 3 skipped
139
+ ```
140
+
141
+ `0 semantic warning(s)` means PAGE-DS-001 (data source wiring) and I18N-001
142
+ (missing keys) both passed. After publish, drive the Save chain from the API:
143
+
144
+ ```bash
145
+ $ erp api post "/api/v1/entities/oeq_equipment/records" \
146
+ --body '{"asset_tag":"LAP-001","name":"Dell Latitude 7440","category_id":1,"category_name":"Laptops","status":"AVAILABLE"}'
147
+ { "id": 1, "asset_tag": "LAP-001", "name": "Dell Latitude 7440", "status": "AVAILABLE",
148
+ "created_by": "you@example.com", "created_at": "2026-09-10T08:08:33.282+00:00" }
149
+ ```
150
+
151
+ That row now appears in the grid.
152
+
153
+ **Next:** [5. The checkout entity, page, and lookups](./05-checkout-entity-and-page.md)
@@ -0,0 +1,116 @@
1
+ ---
2
+ title: "5. The checkout entity, page, and lookups"
3
+ audience: tenant
4
+ ---
5
+
6
+ # 5. The checkout entity, page, and lookups
7
+
8
+ ## The entity
9
+
10
+ `spk-assembly/metadata/entities/oeq_checkout.json`
11
+ ([real file](./example-plugin/spk-assembly/metadata/entities/oeq_checkout.json)):
12
+
13
+ ```json
14
+ {
15
+ "entity": {
16
+ "name": "oeq_checkout",
17
+ "tableName": "oeq_checkout",
18
+ "label": "{\"en\":\"Equipment Checkout\"}",
19
+ "category": "office-equipment",
20
+ "icon": "assignment_ind",
21
+ "color": "#2563EB",
22
+ "pkStrategy": "identity"
23
+ },
24
+ "fields": [
25
+ { "fieldName": "checkout_number", "label": "\"Checkout Number\"", "dataType": "text", "nullable": false, "required": true, "isUnique": true, "isIndexed": true, "isDisplayField": true, "displayOrder": 1 },
26
+ { "fieldName": "equipment_id", "label": "\"Equipment Id\"", "dataType": "long", "nullable": false, "required": true, "isIndexed": true, "displayOrder": 2 },
27
+ { "fieldName": "equipment_tag", "label": "\"Equipment\"", "dataType": "text", "nullable": true, "required": false, "displayOrder": 3 },
28
+ { "fieldName": "employee_id", "label": "\"Employee Id\"", "dataType": "long", "nullable": false, "required": true, "isIndexed": true, "flags": "{\"reference\":{\"entityType\":\"Employee\"}}", "displayOrder": 4 },
29
+ { "fieldName": "checkout_date", "label": "\"Checkout Date\"", "dataType": "date", "nullable": false, "required": true, "displayOrder": 5 },
30
+ { "fieldName": "due_date", "label": "\"Due Date\"", "dataType": "date", "nullable": false, "required": true, "isIndexed": true, "displayOrder": 6 },
31
+ { "fieldName": "purpose", "label": "\"Purpose\"", "dataType": "text", "nullable": true, "required": false, "displayOrder": 9 },
32
+ { "fieldName": "status", "label": "\"Status\"", "dataType": "enum", "nullable": false, "required": true, "isIndexed": true, "defaultValue": "DRAFT", "typeParams": "{\"enumValues\":[\"DRAFT\",\"PENDING_APPROVAL\",\"APPROVED\",\"CHECKED_OUT\",\"DUE_SOON\",\"OVERDUE\",\"RETURNED\",\"REJECTED\"]}", "displayOrder": 10 }
33
+ ]
34
+ }
35
+ ```
36
+
37
+ ### The reference flag
38
+
39
+ ```json
40
+ "flags": "{\"reference\":{\"entityType\":\"Employee\"}}"
41
+ ```
42
+
43
+ on `employee_id` turns on automatic id → name resolution. Every read of an
44
+ `oeq_checkout` row now includes a sibling `employee_id_label`. **Verified live:**
45
+
46
+ ```bash
47
+ $ erp api post "/api/v1/entities/oeq_checkout/records" \
48
+ --body '{"checkout_number":"CO-001","equipment_id":3,"employee_id":1,"checkout_date":"2026-09-10","due_date":"2026-10-10","status":"CHECKED_OUT"}'
49
+ { "id": 1, "checkout_number": "CO-001", "employee_id": 1,
50
+ "employee_id_label": "Aria Chen", "status": "CHECKED_OUT" }
51
+ ```
52
+
53
+ `"Employee"` is a registered provider (backed by the HCM employee plugin), so the
54
+ flag works. For `equipment` — which is *your own* entity, with no registered
55
+ provider — we denormalize `equipment_tag` instead.
56
+
57
+ ### The status enum
58
+
59
+ `DUE_SOON` and `OVERDUE` are in the enum because the reminder job (chapter 7)
60
+ flips the status into them.
61
+
62
+ ## The lookups
63
+
64
+ The checkout create dialog needs an **equipment** picker and an **employee**
65
+ picker.
66
+
67
+ - **Equipment** — your own entity, so build the pair:
68
+ `oeq-equipment-select-options-view.json` (Data View: `t.id` → `value`,
69
+ `asset_tag - name` → `label`) and `oeq-equipment-search.json` (`search` Data
70
+ Service over it). The dialog's `core.lookup` sets
71
+ `optionsSourceKey: "oeq-equipment-search"`.
72
+ - **Employee** — a registered reference type. The `core.lookup` sets
73
+ `optionsSourceKey: "employee-search"` (the platform-provided search service)
74
+ and `recordType: "Employee"`.
75
+
76
+ ## The page
77
+
78
+ [`equipment-checkout.json`](./example-plugin/spk-assembly/metadata/page/equipment-checkout.json)
79
+ follows the same 6-section shape as the register page, with two differences:
80
+
81
+ 1. **KPIs** are Currently Out / Overdue, from `oeq-checkout-kpis` (a `composite`
82
+ of `count` services filtered on `status = CHECKED_OUT` and `status = OVERDUE`).
83
+ 2. **A detail dialog.** The grid's `rowClicked` fetches the record into
84
+ `page.detailRecord` and opens `page.detailDialogOpen`. Inside, an overview
85
+ card shows `detailRecord.checkout_number`, `equipment_tag`,
86
+ `employee_id_label`, `status`, and an actions card with a **Submit for
87
+ approval** button (chapter 6) and a Close button.
88
+
89
+ The grid's status column chip map includes the new values:
90
+
91
+ ```json
92
+ "colorMap": { "DRAFT": "default", "PENDING_APPROVAL": "warning", "APPROVED": "info",
93
+ "CHECKED_OUT": "success", "DUE_SOON": "warning", "OVERDUE": "error",
94
+ "RETURNED": "default", "REJECTED": "error" }
95
+ ```
96
+
97
+ ## Verify
98
+
99
+ ```bash
100
+ erp plugin test example-plugin/spk-assembly
101
+ ```
102
+
103
+ ```
104
+ ✓ page:equipment-checkout.json
105
+ ✓ page:equipment-register.json
106
+ ✓ page:equipment-catalog.json
107
+ ```
108
+
109
+ After publish, the reference flag and the KPI service:
110
+
111
+ ```bash
112
+ $ erp api post "/api/v1/data-services/oeq-checkout-kpis/execute" --body "{}"
113
+ { "results": { "open": { "value": 1 }, "overdue": { "value": 0 } } }
114
+ ```
115
+
116
+ **Next:** [6. The checkout approval workflow](./06-checkout-approval-workflow.md)