@sparkerp/plugin-sdk 0.1.0 → 1.0.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 (174) hide show
  1. package/bundle/blocks.json +169 -0
  2. package/bundle/catalog.json +614 -5
  3. package/bundle/docs/applications/hcm/admin-access-policies.md +38 -0
  4. package/bundle/docs/applications/hcm/admin-approval-hierarchies.md +33 -0
  5. package/bundle/docs/applications/hcm/admin-data-transfer.md +40 -0
  6. package/bundle/docs/applications/hcm/admin-hcm-users.md +35 -0
  7. package/bundle/docs/applications/hcm/admin-logs.md +57 -0
  8. package/bundle/docs/applications/hcm/admin-permissions-catalog.md +64 -0
  9. package/bundle/docs/applications/hcm/ai-intelligence-overview.md +37 -0
  10. package/bundle/docs/applications/hcm/ai-intelligence-people-risk.md +33 -0
  11. package/bundle/docs/applications/hcm/ai-intelligence-recruitment.md +37 -0
  12. package/bundle/docs/applications/hcm/ai-intelligence-tools.md +39 -0
  13. package/bundle/docs/applications/hcm/analytics-operations.md +31 -0
  14. package/bundle/docs/applications/hcm/analytics-overview.md +31 -0
  15. package/bundle/docs/applications/hcm/analytics-people.md +36 -0
  16. package/bundle/docs/applications/hcm/analytics-tools.md +35 -0
  17. package/bundle/docs/applications/hcm/assets-audits.md +30 -0
  18. package/bundle/docs/applications/hcm/assets-custody.md +36 -0
  19. package/bundle/docs/applications/hcm/assets-inventory.md +34 -0
  20. package/bundle/docs/applications/hcm/assets-maintenance.md +25 -0
  21. package/bundle/docs/applications/hcm/assets-software-licenses.md +27 -0
  22. package/bundle/docs/applications/hcm/attendance-core.md +46 -0
  23. package/bundle/docs/applications/hcm/attendance-exceptions.md +33 -0
  24. package/bundle/docs/applications/hcm/attendance-location.md +25 -0
  25. package/bundle/docs/applications/hcm/attendance-policies.md +23 -0
  26. package/bundle/docs/applications/hcm/attendance-reports.md +20 -0
  27. package/bundle/docs/applications/hcm/benefits-allowances.md +39 -0
  28. package/bundle/docs/applications/hcm/benefits-analytics.md +25 -0
  29. package/bundle/docs/applications/hcm/benefits-employee-processes.md +44 -0
  30. package/bundle/docs/applications/hcm/benefits-plans.md +60 -0
  31. package/bundle/docs/applications/hcm/communications-announcements.md +31 -0
  32. package/bundle/docs/applications/hcm/communications-history.md +23 -0
  33. package/bundle/docs/applications/hcm/communications-notifications.md +31 -0
  34. package/bundle/docs/applications/hcm/communications-templates.md +35 -0
  35. package/bundle/docs/applications/hcm/compensation-allowances-benefits.md +44 -0
  36. package/bundle/docs/applications/hcm/compensation-analytics.md +30 -0
  37. package/bundle/docs/applications/hcm/compensation-bonus-incentive.md +35 -0
  38. package/bundle/docs/applications/hcm/compensation-cycles.md +49 -0
  39. package/bundle/docs/applications/hcm/compensation-equity.md +36 -0
  40. package/bundle/docs/applications/hcm/compensation-salary.md +47 -0
  41. package/bundle/docs/applications/hcm/compliance-analytics.md +25 -0
  42. package/bundle/docs/applications/hcm/compliance-audits-calendar.md +52 -0
  43. package/bundle/docs/applications/hcm/compliance-employee-tracking.md +41 -0
  44. package/bundle/docs/applications/hcm/compliance-regulatory.md +31 -0
  45. package/bundle/docs/applications/hcm/country-packs-admin.md +33 -0
  46. package/bundle/docs/applications/hcm/country-packs-compliance.md +31 -0
  47. package/bundle/docs/applications/hcm/country-packs-localization.md +37 -0
  48. package/bundle/docs/applications/hcm/country-packs.md +102 -0
  49. package/bundle/docs/applications/hcm/dashboard.md +38 -0
  50. package/bundle/docs/applications/hcm/designations.md +32 -0
  51. package/bundle/docs/applications/hcm/emp-documents-analytics.md +27 -0
  52. package/bundle/docs/applications/hcm/emp-documents-core.md +33 -0
  53. package/bundle/docs/applications/hcm/emp-documents-letters.md +40 -0
  54. package/bundle/docs/applications/hcm/emp-documents-signatures.md +24 -0
  55. package/bundle/docs/applications/hcm/emp-documents-workflow.md +32 -0
  56. package/bundle/docs/applications/hcm/employee-directory.md +55 -0
  57. package/bundle/docs/applications/hcm/employee-documents.md +51 -0
  58. package/bundle/docs/applications/hcm/employee-info-background.md +41 -0
  59. package/bundle/docs/applications/hcm/employee-info-core.md +32 -0
  60. package/bundle/docs/applications/hcm/employee-info-documents.md +31 -0
  61. package/bundle/docs/applications/hcm/employee-info-other.md +21 -0
  62. package/bundle/docs/applications/hcm/employee-movements.md +46 -0
  63. package/bundle/docs/applications/hcm/employee-reference-data.md +40 -0
  64. package/bundle/docs/applications/hcm/employee-relations-analytics.md +23 -0
  65. package/bundle/docs/applications/hcm/employee-relations-cases.md +26 -0
  66. package/bundle/docs/applications/hcm/employee-relations-conflicts-feedback.md +25 -0
  67. package/bundle/docs/applications/hcm/employee-relations-processes.md +34 -0
  68. package/bundle/docs/applications/hcm/employee-services-daily.md +32 -0
  69. package/bundle/docs/applications/hcm/employee-services-manager.md +21 -0
  70. package/bundle/docs/applications/hcm/employee-services-more.md +41 -0
  71. package/bundle/docs/applications/hcm/employee-services-overview.md +43 -0
  72. package/bundle/docs/applications/hcm/expenses-analytics.md +26 -0
  73. package/bundle/docs/applications/hcm/expenses-claims.md +47 -0
  74. package/bundle/docs/applications/hcm/expenses-self-service.md +30 -0
  75. package/bundle/docs/applications/hcm/expenses-setup.md +43 -0
  76. package/bundle/docs/applications/hcm/health-safety-analytics.md +26 -0
  77. package/bundle/docs/applications/hcm/health-safety-incidents.md +44 -0
  78. package/bundle/docs/applications/hcm/health-safety-medical.md +25 -0
  79. package/bundle/docs/applications/hcm/health-safety-training.md +29 -0
  80. package/bundle/docs/applications/hcm/helpdesk.md +28 -0
  81. package/bundle/docs/applications/hcm/holiday-calendar.md +69 -0
  82. package/bundle/docs/applications/hcm/hr-policies.md +64 -0
  83. package/bundle/docs/applications/hcm/hr-settings.md +98 -0
  84. package/bundle/docs/applications/hcm/index.md +272 -0
  85. package/bundle/docs/applications/hcm/industry-it-software.md +52 -0
  86. package/bundle/docs/applications/hcm/job-classifications.md +48 -0
  87. package/bundle/docs/applications/hcm/learning-analytics.md +21 -0
  88. package/bundle/docs/applications/hcm/learning-assessments.md +22 -0
  89. package/bundle/docs/applications/hcm/learning-catalog.md +25 -0
  90. package/bundle/docs/applications/hcm/learning-certifications.md +27 -0
  91. package/bundle/docs/applications/hcm/learning-enrollment.md +26 -0
  92. package/bundle/docs/applications/hcm/learning-instructors-providers.md +21 -0
  93. package/bundle/docs/applications/hcm/leave-accrual-and-carryforward.md +30 -0
  94. package/bundle/docs/applications/hcm/leave-balance.md +26 -0
  95. package/bundle/docs/applications/hcm/leave-dashboard-and-reports.md +32 -0
  96. package/bundle/docs/applications/hcm/leave-encashment.md +27 -0
  97. package/bundle/docs/applications/hcm/leave-requests.md +26 -0
  98. package/bundle/docs/applications/hcm/leave-setup.md +30 -0
  99. package/bundle/docs/applications/hcm/leave-team-and-calendar.md +25 -0
  100. package/bundle/docs/applications/hcm/navigation-and-approvals.md +61 -0
  101. package/bundle/docs/applications/hcm/offboarding-and-exit.md +71 -0
  102. package/bundle/docs/applications/hcm/onboarding-documents-verification.md +43 -0
  103. package/bundle/docs/applications/hcm/onboarding-orientation-probation.md +65 -0
  104. package/bundle/docs/applications/hcm/onboarding-overview.md +47 -0
  105. package/bundle/docs/applications/hcm/onboarding-provisioning-assets.md +48 -0
  106. package/bundle/docs/applications/hcm/onboarding-reports.md +38 -0
  107. package/bundle/docs/applications/hcm/onboarding-templates-checklists.md +38 -0
  108. package/bundle/docs/applications/hcm/onboarding-to-confirmation.md +45 -0
  109. package/bundle/docs/applications/hcm/org-structure.md +53 -0
  110. package/bundle/docs/applications/hcm/org-units.md +86 -0
  111. package/bundle/docs/applications/hcm/organizations.md +81 -0
  112. package/bundle/docs/applications/hcm/payroll-analytics.md +31 -0
  113. package/bundle/docs/applications/hcm/payroll-post-run.md +40 -0
  114. package/bundle/docs/applications/hcm/payroll-runs.md +30 -0
  115. package/bundle/docs/applications/hcm/payroll-setup.md +38 -0
  116. package/bundle/docs/applications/hcm/payroll-tax.md +21 -0
  117. package/bundle/docs/applications/hcm/payroll-transactions.md +43 -0
  118. package/bundle/docs/applications/hcm/performance-analytics.md +23 -0
  119. package/bundle/docs/applications/hcm/performance-appraisals.md +50 -0
  120. package/bundle/docs/applications/hcm/performance-continuous-feedback.md +23 -0
  121. package/bundle/docs/applications/hcm/performance-cycles-and-goals.md +54 -0
  122. package/bundle/docs/applications/hcm/performance-okrs.md +33 -0
  123. package/bundle/docs/applications/hcm/performance-pips.md +29 -0
  124. package/bundle/docs/applications/hcm/performance-ratings.md +25 -0
  125. package/bundle/docs/applications/hcm/positions.md +67 -0
  126. package/bundle/docs/applications/hcm/recruitment-agencies-and-sources.md +26 -0
  127. package/bundle/docs/applications/hcm/recruitment-analytics.md +25 -0
  128. package/bundle/docs/applications/hcm/recruitment-candidates.md +33 -0
  129. package/bundle/docs/applications/hcm/recruitment-offers.md +32 -0
  130. package/bundle/docs/applications/hcm/recruitment-pipeline.md +46 -0
  131. package/bundle/docs/applications/hcm/recruitment-requisitions-and-openings.md +33 -0
  132. package/bundle/docs/applications/hcm/roles-permissions.md +129 -0
  133. package/bundle/docs/applications/hcm/separation-clearance.md +33 -0
  134. package/bundle/docs/applications/hcm/separation-documents.md +28 -0
  135. package/bundle/docs/applications/hcm/separation-final-settlement.md +24 -0
  136. package/bundle/docs/applications/hcm/separation-reports.md +23 -0
  137. package/bundle/docs/applications/hcm/separation-resignation.md +43 -0
  138. package/bundle/docs/applications/hcm/settings-extensibility.md +31 -0
  139. package/bundle/docs/applications/hcm/settings-general.md +29 -0
  140. package/bundle/docs/applications/hcm/settings-integrations.md +14 -0
  141. package/bundle/docs/applications/hcm/settings-module-defaults.md +41 -0
  142. package/bundle/docs/applications/hcm/settings-process.md +28 -0
  143. package/bundle/docs/applications/hcm/shift-scheduling.md +33 -0
  144. package/bundle/docs/applications/hcm/talent-analytics.md +26 -0
  145. package/bundle/docs/applications/hcm/talent-career.md +31 -0
  146. package/bundle/docs/applications/hcm/talent-competencies-skills.md +30 -0
  147. package/bundle/docs/applications/hcm/talent-profiles.md +40 -0
  148. package/bundle/docs/applications/hcm/talent-succession.md +42 -0
  149. package/bundle/docs/applications/hcm/teams-and-tags.md +46 -0
  150. package/bundle/docs/applications/hcm/timesheets.md +33 -0
  151. package/bundle/docs/applications/hcm/travel-advances-expenses.md +34 -0
  152. package/bundle/docs/applications/hcm/travel-analytics.md +28 -0
  153. package/bundle/docs/applications/hcm/travel-bookings.md +35 -0
  154. package/bundle/docs/applications/hcm/travel-requests.md +38 -0
  155. package/bundle/docs/applications/hcm/workforce-org-design.md +22 -0
  156. package/bundle/docs/applications/hcm/workforce-planning-analytics.md +26 -0
  157. package/bundle/docs/applications/hcm/workforce-planning-core.md +36 -0
  158. package/bundle/docs/applications/hcm/workforce-planning-scenarios.md +29 -0
  159. package/bundle/docs/guides/add-app-owned-roles-and-permissions.md +144 -0
  160. package/bundle/docs/guides/extend-a-shipped-application.md +132 -0
  161. package/bundle/docs/guides/index.md +2 -0
  162. package/bundle/docs/reference/entity-aggregation-config.md +1 -1
  163. package/bundle/docs/reference/entity-document-generator-config.md +1 -1
  164. package/bundle/docs/tutorial/01-create-the-plugin.md +7 -1
  165. package/bundle/docs/tutorial/07-return-due-reminder-job.md +28 -5
  166. package/bundle/docs/tutorial/08-menus-i18n-publish.md +5 -5
  167. package/bundle/manifest.json +4 -4
  168. package/bundle/schemas/page.schema.json +13 -0
  169. package/bundle/schemas/plugin-manifest.schema.json +13 -0
  170. package/bundle/validators/block-engine.mjs +167 -7
  171. package/bundle/validators/page-engine.mjs +226 -21
  172. package/erp-cli/authoring-root.mjs +12 -1
  173. package/erp-cli/erp.mjs +334 -4
  174. package/package.json +1 -1
@@ -0,0 +1,132 @@
1
+ ---
2
+ title: Extend a shipped application (e.g. HCM)
3
+ audience: tenant
4
+ ---
5
+
6
+ # Extend a shipped application
7
+
8
+ This page answers a question the other guides don't: *"HCM (or CRM, or any
9
+ other shipped application) already has the screen I want — how do I add to
10
+ it without forking the product?"* It uses HCM as the worked example because
11
+ it's the largest shipped application, but the pattern is identical for every
12
+ other one.
13
+
14
+ ## What you're doing
15
+
16
+ There are exactly three ways to extend a shipped application. Picking the
17
+ wrong one is the most common mistake — start here.
18
+
19
+ | You want to... | Use | Why |
20
+ |---|---|---|
21
+ | Add a field to an existing screen (e.g. a new column on the Employee form) | **Custom Fields** (no code) — see [Document Settings, Custom Fields, Custom Forms & Numbering Sequences](../applications/hcm/settings-extensibility.md) | It's a tenant admin setting, not a development task. No plugin needed. |
22
+ | Add a new screen, report, KPI, or workflow that *reads* existing data (e.g. a dashboard of employees whose certifications expire soon) | **A companion plugin** that reads the shipped module's entities through a **Data Service / Data View** (read-only) | This is what the rest of this page walks through. |
23
+ | Add a new approval step or business rule triggered by an existing entity changing | **A workflow or business rule** attached via metadata to the existing entity | See [Add an approval workflow](./add-an-approval-workflow.md) and [Add business rules and expressions](./add-business-rules.md) — no Java, and no edit to the shipped plugin. |
24
+
25
+ What you must **never** do: edit `hcm-employee`'s (or any shipped plugin's)
26
+ own Java source or its metadata files. Those are the platform's, upgraded
27
+ independently of your tenant, and per
28
+ [[feedback-avoid-frequent-core-code-changes]] only a real, confirmed bug
29
+ justifies touching another module's code — a feature request never does.
30
+ Every legitimate extension in the table above is additive: a new plugin, a
31
+ new metadata file, a new rule. Nothing you write ever modifies a file that
32
+ ships with `hcm-employee`, `hcm-leave`, or any other application module.
33
+
34
+ ## The complete example
35
+
36
+ A companion plugin, `hcm-cert-tracker`, that adds one new read-only page to
37
+ HCM: **Employees by Department** — a KPI built entirely from data that
38
+ already lives in the shipped `hcm-employee` module's own database table,
39
+ without touching that module at all.
40
+
41
+ `spk-assembly/metadata/data_view/employees-by-department-view.json`:
42
+
43
+ ```json
44
+ {
45
+ "name": "hcm-cert-tracker-employees-by-department-view",
46
+ "description": "Read-only view over hcm-employee's own `employee` table — active headcount grouped by department. This plugin never writes to this table.",
47
+ "definition": {
48
+ "source": { "table": "employee", "alias": "e", "excludeDeleted": false, "schema": "hcm" },
49
+ "joins": [
50
+ { "table": "org_unit", "alias": "dept", "type": "INNER", "on": [{ "leftRef": "e.department_id", "rightRef": "dept.id" }], "excludeDeleted": false, "schema": "hcm" }
51
+ ],
52
+ "fields": [{ "ref": "dept.name", "outputName": "label" }],
53
+ "calculatedFields": [],
54
+ "filter": "{\"and\":[{\"field\":\"e.employment_status\",\"operator\":\"eq\",\"value\":\"active\"}]}",
55
+ "groupBy": ["dept.name"],
56
+ "aggregations": [{ "ref": "e.id", "fn": "COUNT", "outputName": "value" }],
57
+ "sort": [{ "ref": "dept.name", "descending": false }],
58
+ "pagination": { "defaultPageSize": 50, "maxPageSize": 100 },
59
+ "permissionKey": null
60
+ },
61
+ "metadata": {},
62
+ "modules": ["hcm-cert-tracker-dashboard"]
63
+ }
64
+ ```
65
+
66
+ `spk-assembly/metadata/data_service/employees-by-department.json`:
67
+
68
+ ```json
69
+ {
70
+ "name": "hcm-cert-tracker-employees-by-department",
71
+ "operation": "search",
72
+ "source": { "kind": "dataView", "ref": "hcm-cert-tracker-employees-by-department-view" }
73
+ }
74
+ ```
75
+
76
+ The page then binds a `core.donut-chart` (or `core.list`) block to
77
+ `POST /api/v1/data-services/hcm-cert-tracker-employees-by-department/execute`
78
+ — the exact same `callApi` → `setValue` → binding pattern in
79
+ [Wire a page's data](./wire-a-pages-data.md).
80
+
81
+ ## Line by line
82
+
83
+ - **`source.schema: "hcm"`** — every HCM module's tables live in the shared
84
+ `hcm` Postgres schema, not a per-plugin schema. Get this from the shipped
85
+ module's own real, already-installed `data_view` files (as done here,
86
+ copied from `hcm-employee`'s own
87
+ `department-headcount-distribution-view.json`) — never guess it, and never
88
+ trust a plugin's `plugin.json` `schemaName` field, which can be stale (see
89
+ [[feedback-entity-engine-tables-must-route-to-app-schema]]).
90
+ - **`source.table: "employee"`** — the real table name. Reverse-engineer real
91
+ table/column names the same way: read an existing, shipped `data_view`
92
+ JSON from the module you're extending. Every shipped HCM module's
93
+ `metadata/data_view/` directory is real, readable reference material for
94
+ exactly this purpose.
95
+ - **This view is read-only** — a `dataView`/`dataService` pair can only
96
+ `search`/`get`/`count`; there is no write path through this mechanism.
97
+ Writing to another module's table is not supported and not safe — if you
98
+ need to change HCM data, do it through HCM's own real forms/APIs, not by
99
+ reaching into its schema.
100
+ - **`modules: ["hcm-cert-tracker-dashboard"]`** — scopes this data service to
101
+ your own plugin's page, not to `hcm-employee`'s.
102
+
103
+ ## How to verify it worked
104
+
105
+ 1. `erp plugin build && erp plugin publish --env dev`
106
+ 2. `curl -X POST $BASE/api/v1/data-services/hcm-cert-tracker-employees-by-department/execute -H "Authorization: Bearer $TOKEN"` and confirm it returns real `{label, value}` rows matching your tenant's actual employee/department data — spot-check one row against the HCM Employee Directory screen itself.
107
+ 3. Confirm `hcm-employee`'s own files are untouched: `git status` inside `backend/modules/hcm-employee` should show nothing.
108
+
109
+ ## Common mistakes
110
+
111
+ - **Editing the shipped module instead of reading it.** If you find yourself
112
+ opening `hcm-employee`'s source to add a field or endpoint, stop — that's
113
+ Custom Fields or a companion plugin, not a source edit.
114
+ - **Guessing the schema name.** Always confirm it from a real, already-shipped
115
+ `data_view` file in the module you're reading from.
116
+ - **Building a bespoke REST controller instead of a Data Service.** See
117
+ [[feedback-prefer-dataview-over-bespoke-rest-for-reads]] — if you're
118
+ reading rows, a `dataView`/`dataService` pair is almost always the right
119
+ tool, not a hand-written `@RestController`.
120
+ - **Forgetting `TenantContext`.** If any part of your companion plugin adds a
121
+ `NO_AUTH` endpoint that touches this data outside the normal
122
+ tenant-request path, it must wrap the call in
123
+ `TenantContext.set()`/`finally clear()` — see
124
+ [[feedback-no-auth-endpoints-need-manual-tenant-context]].
125
+
126
+ ## What to read next
127
+
128
+ - [Add a data provider, data view, or data service](./add-a-data-provider.md) — the full reference for what this guide's worked example used.
129
+ - [Build a page](./build-a-page.md) and [Wire a page's data](./wire-a-pages-data.md) — to add the screen this data feeds.
130
+ - [Add a KPI or aggregation](./add-a-kpi.md) — for the KPI-card version of this same pattern.
131
+ - [Add an approval workflow](./add-an-approval-workflow.md) — for the "add a workflow step to an existing entity" extension path.
132
+ - [Add your own app-owned roles & permissions](./add-app-owned-roles-and-permissions.md) — the correct pattern if your companion plugin needs its own admin role, instead of widening a shipped module's access checks.
@@ -39,6 +39,8 @@ Every code sample is a real file under
39
39
 
40
40
  - [Add business rules and expressions](./add-business-rules.md)
41
41
  - [Add an approval workflow](./add-an-approval-workflow.md)
42
+ - [Add your own app-owned roles & permissions](./add-app-owned-roles-and-permissions.md)
43
+ - [Extend a shipped application (e.g. HCM)](./extend-a-shipped-application.md)
42
44
 
43
45
  ## Scheduled jobs (no Java)
44
46
 
@@ -27,7 +27,7 @@ Pull the full JSON Schema: `erp schema pull entity-aggregation-config`  ·&
27
27
  | `agg_field` | string | | Required for sum/avg/min/max; the numeric column to fold. |
28
28
  | `group_by_field` | string | | Optional; one result bucket per distinct value. |
29
29
  | `target_entity` | string | yes | |
30
- | `target_key_field` | string | yes | Column on target_entity that holds the bucket key (a declared field). Special value "id": the bucket key IS a row's own primary key — the fold is written straight back onto target_entity row #<key> (never a create). Use with group_by_field yielding the parent id (e.g. maintenance_id), target_entity = that parent entity, and a blank target_key_prefix — this is the true 'roll child lines up onto the parent record' form (MaintenanceCostSyncJob). |
30
+ | `target_key_field` | string | yes | Column on target_entity that holds the bucket key (a declared field). Special value "id": the bucket key IS a row's own primary key — the fold is written straight back onto target_entity row #&lt;key&gt; (never a create). Use with group_by_field yielding the parent id (e.g. maintenance_id), target_entity = that parent entity, and a blank target_key_prefix — this is the true 'roll child lines up onto the parent record' form (MaintenanceCostSyncJob). |
31
31
  | `target_key_prefix` | string | | Prepended to the bucket key when writing (so multiple configs can share one summary entity without collisions). |
32
32
  | `result_key` | string | | Used as the bucket key when group_by_field is blank; defaults to sweep_code. |
33
33
  | `target_value_field` | string | yes | |
@@ -24,7 +24,7 @@ Pull the full JSON Schema: `erp schema pull entity-document-generator-config` &n
24
24
  | `format` | string | | one of: `json`, `csv` |
25
25
  | `cabinet_id` | integer | | DMS cabinet id (engine-file) the artifact is stored in. Give this OR cabinet_name. |
26
26
  | `cabinet_name` | string | | Portable alternative to cabinet_id: the job resolves a cabinet by this name for the tenant, creating it if absent. Preferred for seed-data rows (no hardcoded id). |
27
- | `file_name_template` | string | | Generated file name. Supports ${id} ${date} ${ts} ${uuid} and ${field:<name>}. Default: <document_code>-${id}-${date}.<ext>. |
27
+ | `file_name_template` | string | | Generated file name. Supports ${id} ${date} ${ts} ${uuid} and ${field:&lt;name&gt;}. Default: &lt;document_code&gt;-${id}-${date}.&lt;ext&gt;. |
28
28
  | `include_fields` | string | | Comma-separated source fields to include in the artifact. Blank = all fields. |
29
29
  | `child_entity` | string | | Optional child entity whose rows (matched by child_match_field == source id) are embedded (json) alongside the record. |
30
30
  | `child_match_field` | string | | FK column on child_entity pointing at the source row's id. |
@@ -8,12 +8,18 @@ audience: tenant
8
8
  ## Scaffold
9
9
 
10
10
  ```bash
11
- erp plugin create example-plugin --name "Office Equipment" --type business-app
11
+ erp plugin create example-plugin --name "Office Equipment" --type business-application
12
12
  ```
13
13
 
14
14
  > The folder is `example-plugin/` to match this docs tree. The plugin **id** we
15
15
  > use is `office-equipment` — set below. (In real life pick one name and use it
16
16
  > for both.)
17
+ >
18
+ > The `--type` value is written into `plugin.json` verbatim, with no
19
+ > validation against what the rest of the platform actually uses — pass
20
+ > `business-application` exactly (not `business-app` or anything else), since
21
+ > that's the real convention every shipped plugin (`hcm-foundation`,
22
+ > `crm-foundation`, ...) uses.
17
23
 
18
24
  You get `example-plugin/spk-assembly/` with empty `metadata/*` folders and a
19
25
  `plugin.json` with `"mainClass": null`.
@@ -78,12 +78,35 @@ numbers) before the write, so a config targeting a `boolean`/`integer`/`numeric`
78
78
  `set_field` is fully supported. An earlier draft of this tutorial hit a SQL type
79
79
  error doing exactly that — that platform bug is fixed.
80
80
 
81
- ## 3. The job registers itself
81
+ ## 3. Ship the rule that registers the job
82
+
83
+ Unlike this doc's own first draft claimed, **the platform does not ship this
84
+ rule for you** — every plugin using a shared sweep-style entity
85
+ (`entity_status_date_sweep_config`, `entity_aggregation_config`,
86
+ `entity_compliance_config`, ...) ships its own copy of the one small
87
+ `AFTER_CREATE` rule that registers the corresponding job, the same way
88
+ `hcm-assets` ships its own `entity_aggregation_config_register.json` /
89
+ `entity_compliance_config_register.json`. Skip this file and your config rows
90
+ sit in the table forever with no job ever scheduled to read them — confirmed
91
+ live (2026-09-22): a tenant with pre-existing `entity_status_date_sweep_config`
92
+ rows from another plugin still had no `engine-entity.status-date-sweep` job at
93
+ all until this rule shipped.
94
+
95
+ `spk-assembly/metadata/rules/entity_status_date_sweep_config_register.json`
96
+ ([real file](./example-plugin/spk-assembly/metadata/rules/entity_status_date_sweep_config_register.json)):
82
97
 
83
- The platform ships an `AFTER_CREATE` rule on `entity_status_date_sweep_config`
84
- that calls `ensureEntityStatusDateSweepJobRegistered`. So the first row you seed
85
- auto-registers the job for your tenant — **you ship no register rule for this
86
- job** (unlike the aggregation/cadence/compliance jobs).
98
+ ```json
99
+ {
100
+ "entityType": "entity_status_date_sweep_config",
101
+ "name": "ensure_status_date_sweep_job_registered",
102
+ "description": "Register the generic EntityStatusDateSweepJob for this tenant on first config row.",
103
+ "triggerEvent": "AFTER_CREATE",
104
+ "conditions": null,
105
+ "actions": "[{\"type\": \"EXECUTE_SERVICE\", \"service\": \"ensureEntityStatusDateSweepJobRegistered\"}]",
106
+ "priority": 10,
107
+ "active": true
108
+ }
109
+ ```
87
110
 
88
111
  ## Verify
89
112
 
@@ -44,7 +44,7 @@ erp plugin test example-plugin/spk-assembly
44
44
  ```
45
45
 
46
46
  ```
47
- validated 3 page(s) — clean
47
+ OK 3 page(s) valid
48
48
 
49
49
  Plugin Tests
50
50
  ────────────────────────────────────────
@@ -65,24 +65,24 @@ node developer-docs/examples/test-examples.mjs
65
65
  ## Build
66
66
 
67
67
  ```bash
68
- erp plugin build example-plugin/spk-assembly -o example-plugin/office-equipment-1.0.0.spk
68
+ erp plugin build example-plugin/spk-assembly -o example-plugin/office-equipment-1.0.4.spk
69
69
  ```
70
70
 
71
71
  ```
72
72
  validated 3 page(s) — clean
73
- packaged 33 files -> example-plugin/office-equipment-1.0.0.spk (231986 bytes)
73
+ packaged 32 files -> example-plugin/office-equipment-1.0.4.spk (230832 bytes)
74
74
  sha256: ...
75
75
  ```
76
76
 
77
77
  ## Publish
78
78
 
79
79
  ```bash
80
- erp plugin publish example-plugin/office-equipment-1.0.0.spk --tenant 2
80
+ erp plugin publish example-plugin/office-equipment-1.0.4.spk --tenant 2
81
81
  ```
82
82
 
83
83
  ```
84
84
  installed:
85
- {"pluginId":"office-equipment","version":"1.0.0","state":"installed","pf4jState":"STARTED", ...}
85
+ {"pluginId":"office-equipment","version":"1.0.4","state":"installed","pf4jState":"STARTED", ...}
86
86
  ```
87
87
 
88
88
  ## Confirm it's all live
@@ -1,13 +1,13 @@
1
1
  {
2
- "bundleVersion": "2026-09-14.1",
2
+ "bundleVersion": "2026-09-23.1",
3
3
  "platformVersion": "0.0.0",
4
- "generatedAt": "2026-09-14T13:48:11.809Z",
4
+ "generatedAt": "2026-09-23T05:56:28.964Z",
5
5
  "generatedBy": "erp bundle build (tools/erp-cli/erp.mjs bundleBuildCommand)",
6
6
  "schemaCount": 27,
7
- "docCount": 90,
7
+ "docCount": 248,
8
8
  "exampleFileCount": 14,
9
9
  "blockCount": 124,
10
- "catalogGeneratedAt": "2026-09-14T13:48:11.094Z",
10
+ "catalogGeneratedAt": "2026-09-23T05:56:28.078Z",
11
11
  "catalogEngineCount": 43,
12
12
  "catalogContractUnitCount": 74,
13
13
  "sdkMode": "packaged"
@@ -285,6 +285,19 @@
285
285
  "isSessionExpiredPage": { "type": "boolean" },
286
286
  "isAccessDeniedPage": { "type": "boolean" },
287
287
  "devicePersistence": { "type": "array", "items": { "$ref": "#/$defs/devicePersistenceRule" } },
288
+ "events": {
289
+ "type": "array",
290
+ "items": {
291
+ "type": "object",
292
+ "properties": {
293
+ "hook": { "const": "onLoad" },
294
+ "actions": { "type": "array", "items": { "type": "object" }, "description": "Frozen @erp/action-engine ActionDefinition[] - validated against the live ActionRegistry at mount time, same as a block item's own events." }
295
+ },
296
+ "required": ["hook", "actions"],
297
+ "additionalProperties": false
298
+ },
299
+ "description": "Page-level lifecycle hooks - today only \"onLoad\", fired once after the page mounts."
300
+ },
288
301
  "modules": { "type": "array", "items": { "type": "string" }, "description": "App-module ids this page belongs to (e.g. \"hcm-foundation-home\") - TenantPageHost's per-module page listing only surfaces a page if it's present here. Load-bearing in practice for any page reached via the app sidenav/module shell, even on pages authored before this was documented." }
289
302
  },
290
303
  "required": ["contractVersion", "id", "version", "publisher", "title", "rows", "route", "designer"],
@@ -42,6 +42,19 @@
42
42
  "entrypointExport": { "type": "string" }
43
43
  }
44
44
  },
45
+ "publicApis": {
46
+ "type": "array",
47
+ "description": "Added 2026-09-16 (direct user instruction: \"ideally during plugin installation - plugin should tell what are the apis are what is their behaviour\"). Gateway-public API endpoints this plugin declares — installed into the real, admin-editable gateway_public_read_path table at install/upgrade time (PluginPublicApiInstaller in engine-plugin) instead of a human hand-authoring a platform migration or a gateway Java-source change. Absent/empty (every manifest that predates this field, and the overwhelming majority of plugins going forward) means this plugin declares no public APIs — its whole surface stays tenant/session-gated, same as before this field existed.",
48
+ "items": {
49
+ "type": "object",
50
+ "required": ["pathPattern"],
51
+ "properties": {
52
+ "pathPattern": { "type": "string", "description": "Ant-style path, e.g. \"/api/v1/my-plugin/webhook\"." },
53
+ "category": { "type": "string", "enum": ["PUBLIC_READ", "NO_AUTH"], "default": "PUBLIC_READ", "description": "PUBLIC_READ (GET/HEAD only, gateway injects the platform tenant's own id, never trusted from the client) or NO_AUTH (any verb, no tenant context injected at all — genuinely unauthenticated). Defaults to PUBLIC_READ, the narrower/safer of the two, when absent." },
54
+ "description": { "type": "string", "description": "Why this endpoint must be reachable before a tenant/session exists — shown to a platform admin reviewing what a plugin is asking to expose." }
55
+ }
56
+ }
57
+ },
45
58
  "roles": {
46
59
  "type": "array",
47
60
  "items": {
@@ -3384,6 +3384,25 @@ function endsWith(args) {
3384
3384
  const [s, suffix] = args;
3385
3385
  return rvBool(str(s).endsWith(str(suffix)));
3386
3386
  }
3387
+ function displayString(v) {
3388
+ switch (v.type) {
3389
+ case "String":
3390
+ return v.value;
3391
+ case "Integer":
3392
+ return v.value.toString();
3393
+ case "Decimal":
3394
+ return v.value.toString();
3395
+ case "Boolean":
3396
+ return String(v.value);
3397
+ case "Null":
3398
+ return "";
3399
+ default:
3400
+ throw new EvaluationError(`concat() does not support ${v.type} operands`);
3401
+ }
3402
+ }
3403
+ function concatFn(args) {
3404
+ return rvString(args.map(displayString).join(""));
3405
+ }
3387
3406
  function registerStringFunctions(registry, version) {
3388
3407
  registry.register(version, "upper", upper);
3389
3408
  registry.register(version, "lower", lower);
@@ -3392,6 +3411,7 @@ function registerStringFunctions(registry, version) {
3392
3411
  registry.register(version, "substring", substring);
3393
3412
  registry.register(version, "startsWith", startsWith);
3394
3413
  registry.register(version, "endsWith", endsWith);
3414
+ registry.register(version, "concat", concatFn);
3395
3415
  }
3396
3416
  function contains(args) {
3397
3417
  const [collection, needle] = args;
@@ -3438,12 +3458,19 @@ function size(args) {
3438
3458
  function isEmpty(args) {
3439
3459
  return rvBool(sizeOf(args[0]) === 0);
3440
3460
  }
3461
+ function pluck(args) {
3462
+ const [arr, key] = args;
3463
+ if (arr.type !== "Array") throw new EvaluationError(`pluck() expects an Array as its first argument, got ${arr.type}`);
3464
+ if (key.type !== "String") throw new EvaluationError("pluck() expects a String field name as its second argument");
3465
+ return rvArray(arr.value.map((row) => row.type === "Object" ? row.value[key.value] ?? RV_NULL : RV_NULL));
3466
+ }
3441
3467
  function registerCollectionFunctions(registry, version) {
3442
3468
  registry.register(version, "contains", contains);
3443
3469
  registry.register(version, "first", first);
3444
3470
  registry.register(version, "last", last);
3445
3471
  registry.register(version, "size", size);
3446
3472
  registry.register(version, "isEmpty", isEmpty);
3473
+ registry.register(version, "pluck", pluck);
3447
3474
  }
3448
3475
  function ifImpl(args) {
3449
3476
  const [cond, thenValue, elseValue] = args;
@@ -3516,12 +3543,14 @@ var FUNCTION_CATALOG = [
3516
3543
  c({ name: "substring", minArity: 2, maxArity: 3, argKinds: ["String", "Numeric", "Numeric"], returnType: "String", category: "String", description: "Part of a string from a 0-based start position, optionally limited to a length." }),
3517
3544
  c({ name: "startsWith", minArity: 2, maxArity: 2, argKinds: ["String", "String"], returnType: "Boolean", category: "String", description: "True when the string begins with the given prefix." }),
3518
3545
  c({ name: "endsWith", minArity: 2, maxArity: 2, argKinds: ["String", "String"], returnType: "Boolean", category: "String", description: "True when the string ends with the given suffix." }),
3546
+ c({ name: "concat", minArity: 2, maxArity: Infinity, argKinds: ["Any"], returnType: "String", category: "String", description: `Joins 2+ values' display text into one string, e.g. concat("Total: ", page.count).` }),
3519
3547
  // Collection
3520
3548
  c({ name: "contains", minArity: 2, maxArity: 2, argKinds: ["Collection", "Any"], returnType: "Boolean", category: "Collection", description: "True when a list, string, or object contains the given element/substring/key." }),
3521
3549
  c({ name: "first", minArity: 1, maxArity: 1, argKinds: ["Array"], returnType: "Any", category: "Collection", description: "The first element of a list." }),
3522
3550
  c({ name: "last", minArity: 1, maxArity: 1, argKinds: ["Array"], returnType: "Any", category: "Collection", description: "The last element of a list." }),
3523
3551
  c({ name: "size", minArity: 1, maxArity: 1, argKinds: ["Collection"], returnType: "Integer", category: "Collection", description: "Number of elements in a list, characters in a string, or keys in an object." }),
3524
3552
  c({ name: "isEmpty", minArity: 1, maxArity: 1, argKinds: ["Collection"], returnType: "Boolean", category: "Collection", description: "True when a list, string, or object has no elements." }),
3553
+ c({ name: "pluck", minArity: 2, maxArity: 2, argKinds: ["Array", "String"], returnType: "Array", category: "Collection", description: `pluck(rows, "fieldName") \u2014 a new list of that field's value from each object in the list, e.g. turning [{date, value}, ...] into [value, value, ...].` }),
3525
3554
  // Utility
3526
3555
  c({ name: "if", minArity: 3, maxArity: 3, argKinds: ["Boolean", "Any", "Any"], returnType: "Any", category: "Utility", description: "if(condition, thenValue, elseValue) \u2014 returns one of two values based on a condition." }),
3527
3556
  c({ name: "coalesce", minArity: 1, maxArity: Infinity, argKinds: ["Any"], returnType: "Any", category: "Utility", description: "The first argument that is not null." }),
@@ -4744,9 +4773,9 @@ function resolvePath(scopes, rawPath) {
4744
4773
  return value;
4745
4774
  }
4746
4775
  function resolveString(scopes, text) {
4747
- const pluck = PLUCK_TEMPLATE.exec(text);
4748
- if (pluck) {
4749
- const [, path, field] = pluck;
4776
+ const pluck2 = PLUCK_TEMPLATE.exec(text);
4777
+ if (pluck2) {
4778
+ const [, path, field] = pluck2;
4750
4779
  const resolved = runtimeValueToJs(resolvePath(scopes, path));
4751
4780
  if (!Array.isArray(resolved)) {
4752
4781
  throw new ActionError("unresolved-variable", `'${path}|pluck:${field}' requires an array at '${path}'`);
@@ -11057,6 +11086,26 @@ ${failedSteps}` : "")
11057
11086
  });
11058
11087
  if (faulted && this.phase === "ready") this.present();
11059
11088
  }
11089
+ /**
11090
+ * A real, previously-undiscovered gap (2026-09-22, found live authoring the HCM Permissions
11091
+ * "Create Permission" dialog): a `core.text-input` (or any other inputClass block) whose page
11092
+ * JSON only ever sets `properties.value.binding` — the "obviously two-way" way EVERY such field
11093
+ * in this codebase's own already-shipped pages is authored (`hcm-users.json`'s Create User
11094
+ * dialog included) — never actually wrote a keystroke back to that bound page-scope key. `commit`
11095
+ * → `writeOutput` only ever consulted `config.outputs[name]`, a SEPARATE, explicit redirect
11096
+ * nothing in this codebase's own page-authoring convention ever sets; absent that, every write
11097
+ * landed in a `{scope:"block", scopeKey:this.instanceId}` bucket no page action chain can ever
11098
+ * read — the DOM still showed the typed characters (via `RevealableTextField`'s own local echo
11099
+ * state), so this was invisible in every purely-visual check, only surfacing when a create
11100
+ * dialog's Submit button's `${page.createX}` params were inspected against the REAL network
11101
+ * payload, which showed every field empty. Fixed by falling back to the SAME resolved binding
11102
+ * target `resolveProperties()` already computed for the identically-named PROPERTY's own read
11103
+ * side (`this.boundProps`) when no explicit `config.outputs[name]` redirect exists — making a
11104
+ * `source:"binding"` property's own binding genuinely two-way by default, matching what every
11105
+ * page author already (reasonably) assumed it meant. An explicit `config.outputs[name]` still
11106
+ * wins unchanged (e.g. `core.file-upload`'s own redirect to a different scope/key than its
11107
+ * `value` property reads from); this only fills in the previously-silent gap.
11108
+ */
11060
11109
  async writeOutput(name, value) {
11061
11110
  const port = (this.type.definition.outputs ?? []).find((o) => o.name === name);
11062
11111
  if (!port) {
@@ -11064,7 +11113,7 @@ ${failedSteps}` : "")
11064
11113
  return;
11065
11114
  }
11066
11115
  const redirect = this.config.outputs?.[name];
11067
- const target = redirect ? resolveBoundKey(redirect, this.bindingContext()) : { scope: "block", scopeKey: this.instanceId, key: name };
11116
+ const target = redirect ? resolveBoundKey(redirect, this.bindingContext()) : this.boundProps.get(name) ?? { scope: "block", scopeKey: this.instanceId, key: name };
11068
11117
  await this.ctx.state.set(target, value);
11069
11118
  }
11070
11119
  /* ---------------- error isolation & standard states (W2-04) ---------------- */
@@ -12884,6 +12933,47 @@ var imageDefinition = definition({
12884
12933
  type: "string",
12885
12934
  sources: ["static"],
12886
12935
  designer: { group: "content", editor: "text", labelKey: "core.image.property.height" }
12936
+ },
12937
+ {
12938
+ // 2026-09-17 (page-builder "Customize" panel parity) — a clip-path-class
12939
+ // shape, the same fixed vocabulary a real page-builder's own "Shape"
12940
+ // picker offers (never an arbitrary path — that stays a `style`
12941
+ // escape-hatch concern).
12942
+ name: "shape",
12943
+ type: "string",
12944
+ sources: ["static"],
12945
+ default: "none",
12946
+ constraints: { options: [{ value: "none", labelKey: "core.image.shape.none" }, { value: "circle", labelKey: "core.image.shape.circle" }, { value: "rounded", labelKey: "core.image.shape.rounded" }] },
12947
+ designer: { group: "content", editor: "select", labelKey: "core.image.property.shape" }
12948
+ },
12949
+ {
12950
+ /** A native browser tooltip (HTML `title` attribute) — distinct from `alt` (accessibility text, always present) and from a rich hover-card (out of scope here, a real different feature). */
12951
+ name: "tooltip",
12952
+ type: "string",
12953
+ sources: ["static"],
12954
+ designer: { group: "content", editor: "text", labelKey: "core.image.property.tooltip" }
12955
+ },
12956
+ {
12957
+ /** A raw CSS `transform` value, e.g. "rotate(4deg)" — same "text travels as data" posture as `PageTheme.customCss`, never `dangerouslySetInnerHTML`, just an inline style value. */
12958
+ name: "transform",
12959
+ type: "string",
12960
+ sources: ["static"],
12961
+ designer: { group: "content", editor: "text", labelKey: "core.image.property.transform" }
12962
+ },
12963
+ {
12964
+ name: "filter",
12965
+ type: "string",
12966
+ sources: ["static"],
12967
+ default: "none",
12968
+ constraints: {
12969
+ options: [
12970
+ { value: "none", labelKey: "core.image.filter.none" },
12971
+ { value: "grayscale", labelKey: "core.image.filter.grayscale" },
12972
+ { value: "sepia", labelKey: "core.image.filter.sepia" },
12973
+ { value: "blur", labelKey: "core.image.filter.blur" }
12974
+ ]
12975
+ },
12976
+ designer: { group: "content", editor: "select", labelKey: "core.image.property.filter" }
12887
12977
  }
12888
12978
  ],
12889
12979
  events: [],
@@ -12905,7 +12995,11 @@ var imageLogic = {
12905
12995
  src: String(io.props["src"] ?? ""),
12906
12996
  alt: io.props["alt"] !== void 0 ? String(io.props["alt"]) : "",
12907
12997
  width: io.props["width"] !== void 0 ? String(io.props["width"]) : void 0,
12908
- height: io.props["height"] !== void 0 ? String(io.props["height"]) : void 0
12998
+ height: io.props["height"] !== void 0 ? String(io.props["height"]) : void 0,
12999
+ shape: io.props["shape"] !== void 0 ? String(io.props["shape"]) : void 0,
13000
+ tooltip: io.props["tooltip"] !== void 0 ? String(io.props["tooltip"]) : void 0,
13001
+ transform: io.props["transform"] !== void 0 ? String(io.props["transform"]) : void 0,
13002
+ filter: io.props["filter"] !== void 0 ? String(io.props["filter"]) : void 0
12909
13003
  }
12910
13004
  });
12911
13005
  }
@@ -16275,6 +16369,31 @@ var gridDefinition = definition({
16275
16369
  designer: { group: "data", editor: "binding", labelKey: "core.grid.property.refreshTrigger" }
16276
16370
  }
16277
16371
  ],
16372
+ // 2026-09-23 — these are the block's own internal state (written via
16373
+ // `io.setOwnState` in this file's `query()`/handlers, read back into
16374
+ // `node.props` for the adapter), not authored properties: a page author
16375
+ // never sets them, but a plugin/theme developer inspecting `core.grid`
16376
+ // via `erp blocks list --type core.grid` had no way to discover they
16377
+ // exist at all — the empty-grid production bug this session traced back
16378
+ // to exactly this state (rows/total both came from the same query
16379
+ // response, one reached the render, the other didn't) took a live
16380
+ // Playwright session plus a source read to pin down, when this list
16381
+ // alone would have shown the full internal-state surface immediately.
16382
+ outputs: [
16383
+ { name: "rows", type: "json" },
16384
+ { name: "total", type: "number" },
16385
+ { name: "pageIndex", type: "number" },
16386
+ { name: "sort", type: "json" },
16387
+ { name: "filter", type: "json" },
16388
+ { name: "groups", type: "json" },
16389
+ { name: "aggregates", type: "json" },
16390
+ { name: "expandedGroups", type: "json" },
16391
+ { name: "error", type: "string" },
16392
+ { name: "circuitOpen", type: "boolean" },
16393
+ { name: "selectedIds", type: "json" },
16394
+ { name: "density", type: "string" },
16395
+ { name: "groupBy", type: "string" }
16396
+ ],
16278
16397
  events: [
16279
16398
  { name: "selectionChanged", catalogType: "GridSelectionChanged" },
16280
16399
  { name: "rowClicked", catalogType: "GridRowClicked" },
@@ -17182,6 +17301,30 @@ var fileUploadDefinition = definition({
17182
17301
  type: "string",
17183
17302
  sources: ["static", "expression"],
17184
17303
  designer: { group: "data", editor: "text", labelKey: "core.file-upload.property.cabinetId" }
17304
+ },
17305
+ {
17306
+ // 2026-09-21 (HCM Users spec gap-closing pass) — every pre-existing
17307
+ // page never sets this (undefined === "dms"), so it's a zero-risk
17308
+ // addition: `"dms"` keeps today's exact behavior (a real DMS cabinet
17309
+ // upload via the injected `FileUploadSource`, `value` becomes real
17310
+ // `UploadedFileRef`s). `"raw"` skips the DMS upload entirely — `value`
17311
+ // becomes the picked file's own `{filename, contentType, dataUrl}`
17312
+ // straight from the browser's file picker, with no `id`/`url` (nothing
17313
+ // was uploaded anywhere) — meant to feed a `callApi` action's
17314
+ // `multipart: true` body (see that handler's own doc comment), the
17315
+ // real fix for the disclosed "core.file-upload cannot emit raw bytes
17316
+ // into a callApi body" gap (`ai/patterns/import.md`).
17317
+ name: "mode",
17318
+ type: "string",
17319
+ sources: ["static"],
17320
+ default: "dms",
17321
+ constraints: {
17322
+ options: [
17323
+ { value: "dms", labelKey: "core.file-upload.mode.dms" },
17324
+ { value: "raw", labelKey: "core.file-upload.mode.raw" }
17325
+ ]
17326
+ },
17327
+ designer: { group: "behavior", editor: "select", labelKey: "core.file-upload.property.mode" }
17185
17328
  }
17186
17329
  ],
17187
17330
  outputs: [{ name: "value", type: "json" }],
@@ -17194,15 +17337,23 @@ var fileUploadDefinition = definition({
17194
17337
  category: "input",
17195
17338
  propertyGroups: [
17196
17339
  { id: "data", titleKey: "core.designer.group.data" },
17197
- { id: "content", titleKey: "core.designer.group.content" }
17340
+ { id: "content", titleKey: "core.designer.group.content" },
17341
+ { id: "behavior", titleKey: "core.designer.group.behavior" }
17198
17342
  ],
17199
17343
  allowedTargets: ["page", "form", "container", "dashboard"],
17200
17344
  preview: { kind: "field" }
17201
17345
  }
17202
17346
  });
17347
+ var rawFileIdCounter = 0;
17348
+ function nextRawFileId() {
17349
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
17350
+ rawFileIdCounter += 1;
17351
+ return `raw-file-${Date.now()}-${rawFileIdCounter}`;
17352
+ }
17203
17353
  function createFileUploadLogic(uploadSource) {
17204
17354
  return {
17205
17355
  render(io) {
17356
+ const mode = io.props["mode"] === "raw" ? "raw" : "dms";
17206
17357
  const files = Array.isArray(io.props["value"]) ? io.props["value"] : [];
17207
17358
  const accept = typeof io.props["accept"] === "string" ? io.props["accept"] : void 0;
17208
17359
  const multiple = io.props["multiple"] !== false;
@@ -17211,8 +17362,17 @@ function createFileUploadLogic(uploadSource) {
17211
17362
  props: { value: files, accept, multiple },
17212
17363
  handlers: {
17213
17364
  upload: async (payload) => {
17214
- if (!uploadSource) return;
17215
17365
  const incoming = payload ?? [];
17366
+ if (mode === "raw") {
17367
+ await io.runLoading(async () => {
17368
+ const picked = incoming.map((f) => ({ id: nextRawFileId(), ...f }));
17369
+ const next = [...files, ...picked];
17370
+ await io.writeOutput("value", next);
17371
+ await io.emit("committed", { field: "value", old: files, new: next });
17372
+ });
17373
+ return;
17374
+ }
17375
+ if (!uploadSource) return;
17216
17376
  await io.runLoading(async () => {
17217
17377
  const uploaded = [];
17218
17378
  for (const f of incoming) uploaded.push(await uploadSource.upload(f, cabinetId));