biz-a-cli 2.3.80-15419 → 2.3.80

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 (80) hide show
  1. package/bin/app.js +20 -255
  2. package/bin/hub.js +0 -16
  3. package/bin/hubEvent.js +5 -98
  4. package/bin/tunnelTarget.js +0 -4
  5. package/engine/bpm/workflow.js +6 -7
  6. package/engine/cc/configValidate.js +0 -53
  7. package/engine/cc/guides/V2-SCREEN-AUTHORING.md +512 -531
  8. package/engine/cc/layer3Behavior.js +1 -2
  9. package/engine/cc/layer3Schema.js +1 -3
  10. package/engine/cc/livePipelines.js +3 -9
  11. package/engine/cc/publish.js +105 -86
  12. package/engine/domain/appConfig.js +18 -108
  13. package/engine/domain/applicationConfig.js +12 -87
  14. package/engine/domain/dialect.js +0 -138
  15. package/engine/domain/domainNavigation.js +50 -86
  16. package/engine/domain/entityConfigLoader.js +4 -16
  17. package/engine/domain/numericScale.js +0 -2
  18. package/engine/domain/system.js +0 -2
  19. package/engine/ext/publicTheme.js +1 -8
  20. package/engine/orm/apiRoute.js +61 -187
  21. package/engine/orm/entityConfig.js +2 -32
  22. package/engine/orm/finaAuth.js +0 -0
  23. package/engine/orm/finaEndpoint.js +0 -19
  24. package/engine/orm/imgQuery.js +0 -92
  25. package/engine/orm/listQuery.js +1 -6
  26. package/package.json +1 -1
  27. package/worker/cliScriptWorker.js +0 -5
  28. package/engine/attachments/store.js +0 -195
  29. package/engine/domain/domainRegistration.js +0 -65
  30. package/engine/fina/approval.js +0 -141
  31. package/engine/fina/approvalService.js +0 -59
  32. package/engine/fina/attachments.js +0 -165
  33. package/engine/fina/client.js +0 -84
  34. package/engine/fina/config.js +0 -26
  35. package/engine/fina/context.js +0 -58
  36. package/engine/fina/document.js +0 -67
  37. package/engine/fina/documentWrite.js +0 -173
  38. package/engine/fina/documents/index.js +0 -28
  39. package/engine/fina/documents/jv.js +0 -105
  40. package/engine/fina/documents/jvWrite.js +0 -260
  41. package/engine/fina/documents/si.js +0 -40
  42. package/engine/fina/errors.js +0 -17
  43. package/engine/fina/features.js +0 -86
  44. package/engine/fina/guard.js +0 -102
  45. package/engine/fina/identity.js +0 -87
  46. package/engine/fina/live.js +0 -19
  47. package/engine/fina/lock.js +0 -35
  48. package/engine/fina/master.js +0 -331
  49. package/engine/fina/masters/coa.js +0 -104
  50. package/engine/fina/masters/currency.js +0 -39
  51. package/engine/fina/masters/department.js +0 -32
  52. package/engine/fina/masters/index.js +0 -21
  53. package/engine/fina/masters/project.js +0 -53
  54. package/engine/fina/period.js +0 -56
  55. package/engine/fina/print.js +0 -136
  56. package/engine/fina/readPolicy.js +0 -67
  57. package/engine/fina/recurring.js +0 -200
  58. package/engine/fina/route.js +0 -61
  59. package/engine/fina/routes/attachment.js +0 -41
  60. package/engine/fina/routes/document.js +0 -66
  61. package/engine/fina/routes/master.js +0 -26
  62. package/engine/fina/routes/period.js +0 -24
  63. package/engine/fina/routes/print.js +0 -19
  64. package/engine/fina/routes/recurring.js +0 -27
  65. package/engine/fina/routes/session.js +0 -23
  66. package/engine/fina/routes/setup.js +0 -58
  67. package/engine/fina/routes/submission.js +0 -46
  68. package/engine/fina/routes/template.js +0 -31
  69. package/engine/fina/settings.js +0 -159
  70. package/engine/fina/sql.js +0 -34
  71. package/engine/fina/submission.js +0 -290
  72. package/engine/fina/submissionCompare.js +0 -171
  73. package/engine/fina/submissionRepo.js +0 -114
  74. package/engine/fina/template.js +0 -118
  75. package/engine/fina/userMap.js +0 -79
  76. package/engine/orm/companyLocale.js +0 -74
  77. package/engine/orm/userApiDataset.js +0 -153
  78. package/migrations/postgres/1790600000000__fina.sql +0 -33
  79. package/migrations/postgres/1790700000000__attachment.sql +0 -29
  80. package/migrations/postgres/1790800000000__fina_jv_submission.sql +0 -41
@@ -1,531 +1,512 @@
1
- # Writing version 2 screens — authoring guide
2
-
3
- **Applies to:** Biz-A platform as of 2026-10-09 (Doc 2 steps 1–3, Doc 8 M2) · **Status:** reference, v1.17
4
- **Audience:** anyone writing Layer 3 domain config — implementors, and the AI in Doc 1, which reads this as context.
5
- **Source of truth:** Doc 2 (`Doc 2 Biza_ai_ready.md`) and its implementation plan (`DOC2-IMPLEMENTATION-PLAN-2026-09-25.md`).
6
- **Where it lives:** `cli/engine/cc/guides/V2-SCREEN-AUTHORING.md` — Control Center hands it to the AI that writes
7
- configuration (Phase B), and checks the rules below on every AI write. `docs/V2-SCREEN-AUTHORING.md` is an
8
- identical copy; a test keeps the two equal, so edit the one under `cli` and copy it.
9
- Every rule below is enforced by the platform or pinned by a test; where a rule is only a convention, it says so.
10
-
11
- A version 2 screen is **configuration only**: no load code, no save code, no deleted-row tracking, no handlers.
12
- The platform loads the record with its details, keeps the form and its data in step, and saves the whole
13
- graph in one transaction. If a v2 screen seems to need a handler, first check whether an effect below already
14
- does the job.
15
-
16
- ---
17
-
18
- ## 1. The rules (read these first)
19
-
20
- 1. **Every screen you write is `version: 2`.** Never produce new v1 config. `version` sits on the **entryPoint**
21
- (per screen), not on `metadata` — `metadata.version` is the package's semver and means something else.
22
- 2. **An entryPoint names its `entity`.** The entity's table is what the screen loads and saves.
23
- 3. **Collections are keyed by the detail TABLE name** — a repeat by its `key`, a grid by its `props.detailKey` —
24
- and only a declared `one-to-many` relationship makes a repeat or grid a collection. Anything else is UI-only and is neither loaded nor saved — with no error.
25
- 4. **Every collection a v2 screen edits declares `orderBy`** on its relationship, and the order column is a
26
- `number` attribute. The platform numbers rows from their screen position on save. Save **refuses** a screen
27
- that edits an unordered collection.
28
- 5. **Never show the order column on a v2 screen** — the platform owns it and overwrites whatever is typed.
29
- 6. **Owned details declare `ownership: 'parent'`.** Delete cascades through exactly these; `shared` and
30
- `independent` relationships are never followed.
31
- 7. **Bindings declare `effects`, not a `handler`.** A binding may have one or the other, never both.
32
- 8. **A collection you save must be complete.** Deletion is inferred from absence: a row missing from the save
33
- is deleted. Never paginate, filter or lazy-load an edited collection — or, if a component must, mark it
34
- incomplete (§6.4) so the save is refused instead.
35
- 9. **Give list columns a `title`** when the attribute has no `label`, or the header shows the raw column name.
36
- 10. **Keep config comments free of apostrophes and quote marks** — the admin App Config parser counts them.
37
- 11. **A form tells the user what happened, and goes back to where it was opened.** Its `SAVE` carries
38
- `onSuccess` (a success `NOTIF`, then `NAVIGATE` with `back: true`) and `onInvalid` (a `NOTIF` saying what to
39
- complete) — without `onInvalid` an incomplete form does nothing at all. A `DELETE` goes back the same way.
40
- Give `back` a `route` (the entity's list) for a form opened directly, from a bookmark or a refresh.
41
- 12. **Every required attribute can be filled.** It is a field the user edits, or the item carries a
42
- `defaultValue`, or the attribute has one. A field the form only **shows** — a `label` item, or a `statusBadge`
43
- or `readonly` attribute — gets the item's `defaultValue` when it is required (a workflow state: its initial
44
- state), or the form is never valid.
45
- 13. **The CRUD baseline — every entity a user maintains gets all of it.**
46
- - **Form:** its fields, **Simpan** (rule 11) and **Batal** — a button bound to `NAVIGATE` with `back: true`
47
- (the entity's list as `route`), which leaves without saving.
48
- - **List:** **Tambah** (§7) and, on every row, **Ubah** and **Hapus** — one row button column
49
- `{ type: 'button', buttons: [{ icon: 'edit', caption: 'Ubah' }, { icon: 'delete', caption: 'Hapus' }] }`,
50
- bound by icon: `edit` → `NAVIGATE ['form', <form>, { $ref: 'event.id' }]`, `delete` →
51
- `DELETE { id: { $ref: 'event.id' }, confirm: '<question>' }`. The platform shows a row's actions when the row
52
- is hovered (always, on a touch screen), so no row needs a click to open. A **review list** — one with a fixed
53
- `filter`, like the records waiting for an approval (§3.3) — only opens its records: **Lihat** for a document
54
- with a workflow, Ubah otherwise; no Tambah or Hapus — those belong to the entity's main list.
55
- - **A lookup's master data:** a required lookup picks from an entity some screen maintains — that entity's own
56
- list with Tambah, its form and a menu item, in this domain or another. Without one nothing can be picked and
57
- the record can never be saved.
58
- - **A document with a workflow** is moved through its states in **view mode**: **Lihat** opens its form
59
- read-only, with a button per transition the user may take — clicking one moves the record to that
60
- transition's next state — and **Tutup**; Simpan and Batal show only while editing (§3.3).
61
-
62
- ---
63
-
64
- ## 2. A complete example
65
-
66
- The Add-Info template screens in Supertail (`supertail/src/config/setupConfig.js`) are the reference: a list,
67
- and a master → detail → subdetail form, with no handler code at all. (Supertail's own screens also name Supertail's
68
- own styles — `styleRef: 'screenShell'`, `'inlineRow'` …; they are left out here. A domain names only the styles its
69
- own `ui.styles` declares.)
70
-
71
- ### 2.1 Model — what the screens depend on
72
-
73
- ```js
74
- entities: {
75
- st_item_add_info_template: { table: 'st_item_add_info_template', attributes: { template_code: {...}, template_name: {...}, active: {...} } },
76
- st_item_add_info_template_det: { table: 'st_item_add_info_template_det', attributes: { field_label: {...}, sequence_no: { type: 'number', ... }, ... } },
77
- st_item_add_info_options: { table: 'st_item_add_info_options', attributes: { value: {...}, label: {...}, sequence_no: { type: 'number', uiIntent: 'number' } } },
78
- },
79
- relationships: {
80
- rel_item_add_info_template_det: {
81
- type: 'one-to-many', from: 'st_item_add_info_template', to: 'st_item_add_info_template_det',
82
- foreignKey: 'st_item_add_info_template_id', ownership: 'parent',
83
- orderBy: 'sequence_no', // rule 4
84
- },
85
- rel_item_add_info_options: {
86
- type: 'one-to-many', from: 'st_item_add_info_template_det', to: 'st_item_add_info_options',
87
- foreignKey: 'st_add_info_tmpl_det_id', // declared: the <parent>_id convention is too long for Firebird
88
- ownership: 'parent', orderBy: 'sequence_no',
89
- },
90
- },
91
- ```
92
-
93
- ### 2.2 The form
94
-
95
- ```js
96
- entryPoints: {
97
- stItemAddInfoTemplateV2: {
98
- entity: 'st_item_add_info_template',
99
- structureRef: 'stItemAddInfoTemplateV2Structure',
100
- version: 2,
101
- eventBindings: [
102
- { emitter: 'Simpan Add-Info Template V2', trigger: 'onClick', // the button label, exactly
103
- effects: [{ type: 'SAVE',
104
- onSuccess: [{ type: 'NOTIF', level: 'success', message: 'Add-Info Template tersimpan.' },
105
- { type: 'NAVIGATE', back: true, route: ['list', 'supertail-stItemAddInfoTemplateListV2'] }],
106
- onInvalid: [{ type: 'NOTIF', level: 'warning', message: 'Lengkapi isian yang wajib.' }] }] }, // rule 11
107
- { emitter: 'Hapus Add-Info Template V2', trigger: 'onClick',
108
- effects: [{ type: 'DELETE', confirm: 'Hapus template ini beserta semua field dan opsinya?',
109
- confirmText: 'Hapus', cancelText: 'Batal',
110
- onSuccess: [{ type: 'NOTIF', level: 'success', message: 'Add-Info Template dihapus.' },
111
- { type: 'NAVIGATE', back: true, route: ['list', 'supertail-stItemAddInfoTemplateListV2'] }] }] },
112
- { emitter: 'Batal', trigger: 'onClick', // rule 13: leave without saving
113
- effects: [{ type: 'NAVIGATE', back: true, route: ['list', 'supertail-stItemAddInfoTemplateListV2'] }] },
114
- ],
115
- },
116
- },
117
- structures: {
118
- stItemAddInfoTemplateV2Structure: {
119
- type: 'form',
120
- items: [
121
- { className: 'row g-2', items: [ // a row of fields (§3.4): no key, no type
122
- { className: 'col-12 col-md', items: [{ key: 'template_code', type: 'input', props: { label: 'Kode', required: true } }] },
123
- { className: 'col-12 col-md', items: [{ key: 'template_name', type: 'input', props: { label: 'Nama', required: true } }] },
124
- { className: 'col-12 col-md-auto', items: [{ key: 'active', type: 'bool', defaultValue: 1, props: { label: 'Aktif', showAsCheckBox: true } }] },
125
- ] },
126
- { type: 'repeat', key: 'st_item_add_info_template_det', // the DETAIL TABLE (rule 3)
127
- props: { label: 'Field', addBtn: { text: 'Tambah Field', icon: 'add' }, deleteBtn: { text: 'Hapus Field', icon: 'delete' } },
128
- items: [
129
- { key: 'field_label', ... },
130
- { type: 'repeat', key: 'st_item_add_info_options', props: {...}, items: [ { key: 'value', ... }, ... ] },
131
- ] },
132
- { type: 'button', icon: 'save', label: 'Simpan Add-Info Template V2' },
133
- { type: 'button', icon: 'delete', label: 'Hapus Add-Info Template V2' },
134
- { type: 'button', icon: 'close', label: 'Batal' },
135
- ],
136
- },
137
- },
138
- ```
139
-
140
- ### 2.3 The list
141
-
142
- ```js
143
- entryPoints: {
144
- stItemAddInfoTemplateListV2: {
145
- entity: 'st_item_add_info_template',
146
- structureRef: 'stItemAddInfoTemplateListV2Structure',
147
- version: 2,
148
- eventBindings: [
149
- { emitter: 'add', trigger: 'onClick',
150
- effects: [{ type: 'NAVIGATE', route: ['form', 'supertail-stItemAddInfoTemplateV2', 'null'] }] },
151
- { emitter: 'edit', trigger: 'onClick', // rule 13: row actions
152
- effects: [{ type: 'NAVIGATE', route: ['form', 'supertail-stItemAddInfoTemplateV2', { $ref: 'event.id' }] }] },
153
- { emitter: 'delete', trigger: 'onClick',
154
- effects: [{ type: 'DELETE', id: { $ref: 'event.id' }, confirm: 'Hapus template ini beserta semua field dan opsinya?' }] },
155
- ],
156
- },
157
- },
158
- structures: {
159
- stItemAddInfoTemplateListV2Structure: {
160
- type: 'list', caption: 'Add-Info Template',
161
- items: [ { key: 'template_code', title: 'Kode' }, { key: 'template_name', title: 'Nama' }, { key: 'active', title: 'Aktif' },
162
- { type: 'button', buttons: [{ icon: 'edit', caption: 'Ubah' }, { icon: 'delete', caption: 'Hapus' }] } ],
163
- buttons: [{ icon: 'add', caption: 'Tambah' }],
164
- },
165
- },
166
- ```
167
-
168
- Routes are `form/<app>-<entryPoint>/<id>` and `list/<app>-<entryPoint>`; `null` as the id opens a new record.
169
- Publishing generates each screen's template — nothing else needs to exist.
170
-
171
- ---
172
-
173
- ## 3. Form screens
174
-
175
- ### 3.1 Items
176
-
177
- | Item | Shape | Notes |
178
- |---|---|---|
179
- | Field | `{ key, type?, props?, defaultValue?, styleRef? }` | `key` is an attribute of the entity that owns this level. Without `type`, the attribute's `uiIntent` decides the control. Explicit `props` win over the attribute. `defaultValue` fills a new record (rule 12). A column class goes on a group around the field, never on the field (§3.4). |
180
- | Layout row / group | `{ className, items }` or `{ styleRef, items }` — **no `key`, no `type`** | Groups fields of the **same** entity. `className` takes Bootstrap's grid classes — rows and columns (§3.4). A `styleRef` names a style of this domain's own `ui.styles`; nothing else resolves. |
181
- | Repeat (a collection) | `{ type: 'repeat', key: <detail table>, props, items }` | Its fields resolve against the **detail** entity (labels, validation). Nests for subdetail. Buttons: `props.addBtn` / `props.deleteBtn` as `{ text, icon }` — `addText` alone renders an unlabeled button. |
182
- | Grid (a one-level collection) | `{ type: 'detail-grid', key: 'grid_<detail table>', props: { detailKey: <detail table>, columns, addBtn, deleteBtn } }` | The grid's own `key` names the grid — no attribute, never the detail table: the form holds a plain field under that key, and with the table's name the grid could add no row. Columns are `{ key, label, type?, default? }`. `default` initialises rows the user adds, never loaded rows. A hidden `id` column is added automatically. A grid hides its add and delete unless declared: a grid the user edits carries `addBtn: { show: true, label: 'Tambah <row>' }` and `deleteBtn: { show: true, icon: 'delete', key: '__hapus_<detail table>', label: ' ' }` (the delete column's own key, no attribute). Optional (Doc 8 M5a): `iconOnly: true` on either shows the icon alone, its word becoming the tooltip and spoken name (`label` for add, `text` for delete); `deleteBtn.confirm: true` or a question asks before a row goes; `addBtn.requireKeys: ['<column>']` keeps the add button disabled, saying why (`blockedText`), while a row has no value in those columns. An empty money / number cell shows blank, 0 still shows 0. Money and number cells are shown and typed in the company number format (Number format setting: ID 1.500,50 / US 1,500.50) and keep a number; a column's `arg1` names its own locale. |
183
- | Button | `{ type: 'button', icon, label, styleRef }` | Its `label`, exactly, is the emitter a binding names. |
184
- | Template (static HTML) | `{ type: 'template', template }` | Rendered through Angular's HTML sanitiser (scripts and event handlers are removed). Inline `style="…"` works: the platform turns each into a generated class. A style containing `{ } < >` is dropped. |
185
-
186
- **A lookup** — a field that picks a record of another entity — is an attribute holding that record's key
187
- (`uiIntent: 'lookup'`) and a relationship
188
- `{ type: 'many-to-one', from: '<this entity>', to: '<the entity it picks from>', foreignKey: '<the field>', ownership: 'shared' }`.
189
- The platform lists the records of the relationship's `to`; declared one-to-many from the other side, it would list
190
- the wrong table. Each record is shown — and searched — by the attribute that names it, which that entity declares:
191
- `display: '<the attribute that names one record>'` (a name or a number — `display: 'nama_lead'` on Account), beside
192
- its `table` and `identity`; without it the lookup shows the first attribute, whatever that is. Deleting a record a
193
- lookup still points to is refused (`IN_USE`).
194
- It lists every record of that entity unless its attribute narrows it:
195
- `lookupOptions: { filter: [{ junction: '', column: '<an attribute of the entity it picks from>', operator: '=', value1: "'<value>'" }] }`
196
- (e.g. only offers whose state is the accepted one; operators `=`, `<>`, `<`, `>`, `<=`, `>=`, `in`, `not in`,
197
- `is null`, `is not null`; the user's search is added to it). A lookup attribute declared `unique` (one record per
198
- pick — one offer makes one contract) hides the records another record already picked, keeping this record's own
199
- pick; nothing else to declare, and the database refuses a duplicate anyway (`DUPLICATE`).
200
-
201
- Any item may carry `expressions: { hide: '<expression>' }`, evaluated against `model` and `formState` while the
202
- form is open. `formState.readonly` is `true` in view mode (§3.2), so an item can show only while editing
203
- (`hide: 'formState.readonly'`) or only while viewing (`hide: '!formState.readonly'`).
204
-
205
- ⚠️ A repeat or grid whose key is **not** a declared detail table is UI-only: the platform treats it as screen
206
- state, loads nothing into it and saves nothing from it — silently.
207
-
208
- ### 3.2 What the platform does for you
209
-
210
- - **Load.** Opening `form/<app>-<entryPoint>/<id>` reads the master and every collection on the screen in one
211
- request, each collection in its declared order (`orderBy`, then `id`). A new record (`null`) reads nothing.
212
- - **Form ↔ data.** Typing updates the form data immediately, grid rows included; an effect that changes the data
213
- (FORM_PATCH) shows on screen immediately. Nothing to wire.
214
- - **Save** (the `SAVE` effect). Sends the record and its collections; the server works out what was added,
215
- changed and removed and writes only that, in one transaction (§6). A new record gets its key back, the form
216
- reloads it, and the address changes from `/null` to that key.
217
- - **Row order.** Rows are numbered 1..n from their screen position into the `orderBy` column on every save.
218
- Unchanged positions write nothing.
219
- - **View mode.** A form opened with `queryParams: { mode: 'view' }` (the address ends `?mode=view`) shows its record
220
- read-only: no field can be changed — select, date, repeat and grid included — collections show no add or delete,
221
- and `SAVE` / `DELETE` change nothing. It is the same form, not a second screen; `formState.readonly` is `true`.
222
- A form in a dialog is never in view mode.
223
-
224
- ### 3.3 A document with a workflow
225
-
226
- A document whose life is a workflow — a BPM workflow whose `entityType` is the entity — is moved through its states
227
- in **view mode** (§3.2): **Lihat** opens the record read-only on its own form, with a button for each transition the
228
- user may take, and **Tutup**. One form serves both editing and viewing — never a second "view" or "approval" form.
229
-
230
- - **The entity declares it:** `workflow: { key: '<workflowKey>', stateAttribute: '<attribute>' }`. The platform
231
- starts the workflow when the form creates a record (at its initial state) and writes every state change onto the
232
- state attribute, so lists and forms show the state like any other field, and a save never overwrites it.
233
- - **The state attribute** is a `string`, `uiIntent: 'statusBadge'`, `required`. The form shows it as
234
- `{ key: '<attribute>', type: 'label', defaultValue: '<initialStateCode>' }` (rule 12); the list shows it as a column.
235
- - **The form** carries, besides its fields:
236
- - **Simpan** and **Batal**, shown only while editing — `expressions: { hide: 'formState.readonly' }` (a Hapus
237
- button on the form, if any, too);
238
- - **Tutup**, shown only in view mode — `expressions: { hide: '!formState.readonly' }` — bound to `NAVIGATE` with
239
- `back: true`, the entity's list as `route`;
240
- - **the transition buttons:** the item `{ type: 'workflow-actions', key: 'aksi_workflow' }` — its key is no
241
- attribute. The platform fills it from the screen and shows it in view mode only (a transition leaves the
242
- form, which would throw away unsaved edits). Each button is a transition the signed-in user may take: their
243
- roles are the role codes mapped to their user groups (an administrator holds every role). Pressing one moves
244
- the record to that transition's next state, and the form goes back to where it was opened (the list, shown
245
- fresh with the new state); opened directly, it reloads the record instead.
246
- - **Lihat** is a row action `{ icon: 'visibility', caption: 'Lihat' }`, bound `visibility` / `onClick` to
247
- `NAVIGATE ['form', <form>, { $ref: 'event.id' }]` with `queryParams: { mode: 'view' }`. Where it lives is the
248
- customer's choice, recorded on the process map as the lifecycle's `transitionUi`:
249
- - `view` — the default: on the rows of the entity's main list, before Ubah and Hapus;
250
- - `menus` — a review list for each stage that waits for someone (a map SCREEN with that `stage`), each reached
251
- from its own menu item: a list over the same entity with a fixed `filter` on the state attribute,
252
- `filter: [{ junction: '', column: '<table>.<state column>', operator: '=', value1: "'<STATE>'" }]`, whose only
253
- row action is Lihat — no Tambah, Ubah or Hapus. The main list keeps Ubah and Hapus.
254
- - A menu never opens a listed entity's form as a new record.
255
-
256
- ```js
257
- // the form (bindings: Simpan → SAVE, rule 11; Batal and Tutup → [{ type: 'NAVIGATE', back: true, route: ['list', '<list>'] }])
258
- items: [
259
- { key: 'judul' },
260
- { key: 'status_code', type: 'label', defaultValue: 'BARU' },
261
- { type: 'workflow-actions', key: 'aksi_workflow' },
262
- { type: 'button', icon: 'save', label: 'Simpan', expressions: { hide: 'formState.readonly' } },
263
- { type: 'button', icon: 'close', label: 'Batal', expressions: { hide: 'formState.readonly' } },
264
- { type: 'button', icon: 'close', label: 'Tutup', expressions: { hide: '!formState.readonly' } },
265
- ],
266
-
267
- // the main list (transitionUi view): the row button column and the Lihat binding
268
- { type: 'button', buttons: [{ icon: 'visibility', caption: 'Lihat' }, { icon: 'edit', caption: 'Ubah' }, { icon: 'delete', caption: 'Hapus' }] },
269
- { emitter: 'visibility', trigger: 'onClick',
270
- effects: [{ type: 'NAVIGATE', route: ['form', '<form>', { $ref: 'event.id' }], queryParams: { mode: 'view' } }] },
271
- ```
272
-
273
- ### 3.4 Rows of fields
274
-
275
- A form is one field per line unless it says otherwise — fine on a phone, wasteful on a desktop. Biz-A has
276
- Bootstrap 5's grid: put short fields side by side in rows.
277
-
278
- - **A row** is a group `{ className: 'row g-2', items: [ <column>, … ] }`; **a column** is a group around ONE field,
279
- `{ className: 'col-12 col-md', items: [{ key: '<attribute>' }] }`. The columns of a row share its width equally on a
280
- screen at least 768 px wide, and stack one per line below it. `col-12 col-md-4`, `col-md-6` or `col-md-8` size a
281
- column instead; `col-12 col-md-auto` fits it to its content (a checkbox).
282
- - ⚠️ The column class goes on the group, never on the field item: several field types (date, money, lookup, label,
283
- button) also put the field's own class on an inner element, where a width class shrinks the field.
284
- - **Which fields:** 2–3 short ones per row — codes, names, dates, numbers, money, choices, lookups — related ones
285
- together (number and date; customer and contact). Long text, a repeat, a grid, the workflow buttons and the form's
286
- buttons stay full width, outside rows.
287
- - **By the screen's usage** — the device of the map's usage context for its actors or the screen: desktop, tablet or
288
- kiosk → rows; mobile only → no rows, one field per line; not known → rows (they stack on a phone anyway).
289
-
290
- ```js
291
- items: [
292
- { className: 'row g-2', items: [
293
- { className: 'col-12 col-md', items: [{ key: 'nomor' }] },
294
- { className: 'col-12 col-md', items: [{ key: 'tanggal' }] },
295
- { className: 'col-12 col-md', items: [{ key: 'customer_id' }] },
296
- ] },
297
- { key: 'catatan' }, // long text: full width
298
- { type: 'repeat', key: 'penawaran_line', props: { ... }, items: [ ... ] },
299
- ]
300
- ```
301
-
302
- ### 3.5 Values the system fills
303
-
304
- Three properties hand a value to the platform instead of the user — declared on the model, never computed by the form.
305
-
306
- - **A number the system gives** — `numbering` on a `string` attribute:
307
-
308
- ```js
309
- nomor_kontrak: { type: 'string', size: 30, required: true, unique: true, label: 'Nomor Kontrak',
310
- numbering: { format: 'KK/{YYYY}/{MM}/{seq:4}', reset: 'year' } },
311
- ```
312
-
313
- The database gives the number when the record is created (KK/2026/10/0001) and never changes it; whatever a form
314
- sends is ignored. The format is text with `{YYYY}` `{YY}` `{MM}` `{RM}` (the month in Roman numerals) `{DD}` and
315
- exactly one `{seq:N}` (the counter, padded to N digits); the rest only letters, digits, space and `/ . _ -`.
316
- `reset` — `year`, `month`, `day` or `never` — says when the counter starts again at 1, and the number must show
317
- that period (`{YYYY}` or `{YY}` for `year`). The attribute is `unique`, has no `defaultValue`, and its `size` fits
318
- the longest number. The form shows it read-only and never asks for it; a new record shows it empty until saved,
319
- so give the item a `props.placeholder` in the user's language ("Dibuat otomatis saat disimpan"). When the customer
320
- has no format of their own: `<the document's initials>/{YYYY}/{MM}/{seq:4}`, restarting every year.
321
- PostgreSQL databases only — the publish refuses it elsewhere.
322
- - **A value taken from the record a lookup picks** — `follows` on an attribute:
323
-
324
- ```js
325
- customer_id: { type: 'number', uiIntent: 'lookup', required: true,
326
- follows: { via: 'surat_penawaran_id', attribute: 'customer_id' } },
327
- ```
328
-
329
- `via` is a lookup attribute of the same entity (with its many-to-one relationship, §3.1); `attribute` is an
330
- attribute of the entity it picks from, of the same type. When the user picks, the form copies the value at once and
331
- shows it read-only; the database copies it again when the record is saved — on create, and whenever another record
332
- is picked — so nothing else can set it. A later change to the picked record does not reach records already saved.
333
- Required only when `via` is required.
334
- - **Lines that start from the picked record's lines** — `startsFrom` on the owned one-to-many relationship:
335
-
336
- ```js
337
- kontrakItems: { type: 'one-to-many', from: 'KontrakKerja', to: 'KontrakItem', foreignKey: 'kontrak_kerja_id',
338
- ownership: 'parent', orderBy: 'urutan',
339
- startsFrom: { via: 'surat_penawaran_id', from: 'PenawaranItem' } },
340
- ```
341
-
342
- When the user picks, the lines of the picked record (`from`: a line entity the picked entity owns) are copied into
343
- the form's collection — only while it is empty, so lines the user already has are never overwritten — and are then
344
- edited freely; the save inserts them as this record's own. Attributes with the same name copy; `fields: { <this
345
- line's attribute>: <the picked line's attribute> }` adds the ones named differently. Keys, the parent key and the
346
- row order never copy. Keep the collection (repeat or grid) on the same screen as the lookup.
347
-
348
- ---
349
-
350
- ## 4. Bindings and expressions
351
-
352
- ```js
353
- { emitter: '<button label | list | icon or caption of a list button>', trigger: 'onClick' | 'onRowClick' | ...,
354
- effects: [ ... ] }
355
- ```
356
-
357
- - An effects binding runs its effects in order. A binding with `effects` must not also name a `handler`.
358
- - Any value inside an effect may be an expression, resolved when the event fires:
359
- - `{ $ref: 'event.id' }` — a path read from `event`, `form` or `ctx` (e.g. `form.model.template_code`);
360
- - `{ $ref: '…', $default: x }` — `x` when the path does not exist;
361
- - `{ $const: x }` — `x` literally, never resolved.
362
- - List emitters: `'list'` with trigger `onRowClick` (the whole row); a top or inline list button is matched by its
363
- **icon**, or by its caption when it has no icon. `event.id` is the row's key in both cases; a row click also
364
- carries `event.row`, the whole row.
365
-
366
- ---
367
-
368
- ## 5. Effects
369
-
370
- | Effect | Parameters | Behaviour |
371
- |---|---|---|
372
- | `SAVE` | `onSuccess?`, `onError?`, `onInvalid?` (effect lists) | v2 forms only. Invalid form → fields are marked, `onInvalid` runs, nothing is sent. Success → the record is reloaded, then `onSuccess`. Failure → `onError`, or the server's message is shown. |
373
- | `DELETE` | `id?`, `confirm?` (`false` or question text), `confirmText?`, `cancelText?`, `onSuccess?`, `onError?` | On a v2 form: the open record (saved records only). From a list row: the record `id` names (`{ $ref: 'event.id' }`), of the list's entity; the list reloads afterwards. Asks through the platform confirm dialog unless `confirm: false`. Anything but an explicit yes deletes nothing and runs neither list. Cascades by config (§6.3). |
374
- | `NAVIGATE` | `route` (array), `back?`, `queryParams?`, `reload?` (default `true`) | Opens a screen. `['form', '<app>-<entryPoint>', <id or 'null'>]`, `['list', '<app>-<entryPoint>']`. `back: true` returns to the screen this one was opened from, shown fresh; `route` is then only the fallback for a screen opened directly, and with neither nothing happens. A route is always an array. `queryParams: { mode: 'view' }` opens a form in view mode (§3.2). `queryParams: { copyFrom: <id> }` with the id `'null'` opens a NEW form filled from that record (Salin, §7). `replaceUrl: true` (Doc 8 M5a) turns the current screen into the target IN PLACE, so going back skips it (Ubah on a record opened to view). |
375
- | `NOTIF` | `message`, `level?` (`success` \| `error` \| other), `sticky?`, `duration?` | A snackbar. `success` lasts 3 s. |
376
- | `FORM_PATCH` | `patch` (object of field values) | Writes values into the form data; shows immediately. On a list it writes the list's `model`, which `enabled` reads (§7). |
377
- | `FORM_ERROR` | `field`, `message` | Shows an error naming the field. |
378
- | `OPEN_DIALOG` | `entryPointName`, `model?`, `afterClosedHandler?` | Opens another entryPoint in a dialog. |
379
-
380
- ---
381
-
382
- ## 6. Saving and deleting — the contract
383
-
384
- ### 6.1 How a save is understood
385
-
386
- The server compares what is sent with what is stored:
387
-
388
- | Sent | Stored | Result |
389
- |---|---|---|
390
- | row with a key | same values | nothing written |
391
- | row with a key | different values | UPDATE of the changed columns only |
392
- | row without a key | — | INSERT (the key is generated) |
393
- | — (row missing from a sent collection) | row | **DELETE** (soft or hard, per entity) |
394
- | collection **not sent at all** | rows | left alone |
395
-
396
- A field changed and then changed back writes nothing; untouched rows keep their keys; subdetail rows survive a
397
- save of their parent row.
398
-
399
- ### 6.2 Concurrency
400
-
401
- A loaded record carries a row version. If someone else changed it meanwhile, save and delete are refused with
402
- `409 CONCURRENCY_CONFLICT` and write nothing — reload and retry.
403
-
404
- ### 6.3 Delete
405
-
406
- `DELETE` removes the record and, at any depth, every row reached through an `ownership: 'parent'`
407
- relationship, through each declared foreign key — deepest first, each table by its own `deleteStrategy`
408
- (`soft` keeps the row and stamps it; anything else deletes it), in one transaction. A record that another row
409
- still **references** through a `shared` relationship (e.g. a category using a template) is refused with
410
- `409 IN_USE` naming the referencing table; nothing is deleted.
411
-
412
- ### 6.4 Completeness — the `_incomplete` mark
413
-
414
- Because a missing row means "delete it", a partially loaded collection must never be saved as if complete.
415
- Whoever loads a collection partially marks its parent row:
416
-
417
- ```js
418
- { st_order: { id: 7, _incomplete: ['st_order_line'], st_order_line: [ /* one page */ ] } }
419
- ```
420
-
421
- A save carrying a marked collection is refused with `400 INCOMPLETE_COLLECTION` naming it, before anything is
422
- read or written. A marked collection that is not sent is fine. No v2 read or component marks anything today —
423
- whoever first adds paging or filtering to an edited collection must set the mark.
424
-
425
- ### 6.5 Server errors a screen can meet
426
-
427
- | Code | Status | Meaning |
428
- |---|---|---|
429
- | `CONCURRENCY_CONFLICT` | 409 | changed by someone else since loaded |
430
- | `IN_USE` | 409 | cannot delete — still referenced by the named table |
431
- | `DUPLICATE` | 409 | a value (or combination) a unique key already holds — named by the field's label |
432
- | `REQUIRED` | 400 | a required value is missing — named by the field's label |
433
- | `REFERENCE_NOT_FOUND` | 409 | a lookup points at a record that no longer exists — named by the field's label |
434
- | `INCOMPLETE_COLLECTION` | 400 | a collection marked incomplete was sent |
435
- | `GRAPH_READ_KEY_REQUIRED` | 400 | a graph read without a key |
436
- | `SHARED_RELATIONSHIP` | 400 | a collection whose relationship is not owned by its parent was sent |
437
- | `UNSAFE_SQL` | 400 | a name or value the ORM will not put into SQL |
438
- | `CROSS_TENANT_WRITE` / `AMBIGUOUS_TENANT` | 403 / 400 | a write outside the caller's tenant / with no tenant |
439
-
440
- Without an `onError`, the message is shown to the user as the server wrote it — never with SQL in it.
441
-
442
- ---
443
-
444
- ## 7. List screens
445
-
446
- - A structure with `type: 'list'` over the entryPoint's `entity`. Items are columns `{ key, title? }`; top
447
- `buttons` are `{ icon, caption }`. Optional `filter` (fixed conditions), `distinct`, `caption`.
448
- - Paging, per-column filter buttons, soft-deleted rows and tenant scope are handled by the platform.
449
- - A `list` / `onRowClick` binding makes rows clickable (pointer cursor). The row's key is fetched as a hidden
450
- column only when such a binding exists; clicking a row button never also fires the row click.
451
- - A list of an entity that has a form opens it both ways, as in §2.3 (rule 13): the `{ icon: 'add', caption: 'Tambah' }`
452
- button bound `add` / `onClick` to the form with `'null'`, and a row button column whose `edit` opens the form
453
- with `event.id` and whose `delete` deletes the row's record. Row buttons show when the row is hovered or holds
454
- the keyboard focus, and always on a touch screen. A row click (`list` / `onRowClick`) is optional. The rows of a
455
- document with a workflow also carry Lihat, where the customer chose so (§3.3).
456
- - **Search:** `search: true` puts a search box above the list; it finds the rows whose text columns contain what
457
- is typed. `search: { columns: ['<key>', …], placeholder: '…' }` names the columns it searches.
458
- - **Print:** `print: true` (or `print: { title: '…' }`) adds a print button: the rows of the current search, filter
459
- and order, as one printable table.
460
- - **Salin** (copy as new): a row button `{ icon: 'content_copy', caption: 'Salin' }` bound
461
- `{ emitter: 'content_copy', trigger: 'onClick', effects: [{ type: 'NAVIGATE', route: ['form', '<form>', 'null'],
462
- queryParams: { copyFrom: { $ref: 'event.id' } } }] }`. The form opens as a NEW record holding everything the
463
- row's record holds — its lines too, its date, its unique code — except its key, its number (given again on save)
464
- and its workflow state. Nothing is saved until the user saves; a unique code left unchanged is refused then.
465
- - **Enabled per row:** any list button may carry `enabled`, an expression over `row` (the row's values by
466
- attribute key) and `model` (the list's own data, written by `FORM_PATCH`), e.g.
467
- `{ icon: 'edit', caption: 'Ubah', enabled: "model.canEdit && row.status !== 'POSTED'" }`. A button whose
468
- expression is false — or does not evaluate — is shown disabled. The server still refuses what the user may not
469
- do; `enabled` only spares the click.
470
- - A **selection** of rows with mass actions exists too, but a mass action runs an application handler, which a
471
- Layer 3 config never names — it is not for Control Center (`docs/LIST-ENGINE.md`).
472
-
473
- ---
474
-
475
- ## 8. Checking your config
476
-
477
- Pin the silent failures in the application's own tests, as Supertail does in
478
- `supertail/src/supertaillib/test/v2_add_info_template.test.js`. Control Center checks the screen rules on every
479
- AI write (`cli/engine/cc/configValidate.js`), so what the AI commits is what publish and the client accept:
480
-
481
- - every repeat / grid key is a declared detail table, at every level;
482
- - every field is a declared attribute of the entity that owns its level;
483
- - no binding names a handler; every Save/Delete button's label matches its binding's emitter;
484
- - every collection a v2 screen edits declares `orderBy` on a `number` attribute, and no v2 screen shows it;
485
- - NOT NULL columns a new row does not show have a `defaultValue` (repeat) or `default` (grid column);
486
- - a grid a saving form edits shows its add and delete (`addBtn` / `deleteBtn` with `show: true`), and its own `key` is not its detail table;
487
- - every required attribute can be filled; a required field a saving form only shows carries a `defaultValue` (rule 12);
488
- - every `SAVE` has `onInvalid`, and goes back (`NAVIGATE back`) on success (rule 11);
489
- - a form that saves has a cancel button bound to `NAVIGATE back` (rule 13);
490
- - a list of an entity with a form has the add button and its binding, and row actions `edit` and `delete`, each
491
- bound (rule 13) — a review list (a fixed `filter`) only the action that opens its records: Lihat for a document
492
- with a workflow, `edit` otherwise;
493
- - every `NAVIGATE` route is an array naming an entry point of the domain;
494
- - a Salin row action opens a new form with `copyFrom` (§7), and every list button's `enabled` is an expression that
495
- evaluates;
496
- - every required lookup picks from an entity a list with Tambah maintains (rule 13);
497
- - every lookup attribute has its many-to-one relationship, from its entity to the one it picks from (§3.1);
498
- - every lookup filter names attributes of the entity it picks from, with a known operator (§3.1);
499
- - every entity a lookup picks from declares `display`, the attribute that names its records (§3.1);
500
- - every `styleRef` names a style of the domain's own `ui.styles` (§3.4);
501
- - an entity with a workflow declares it (`workflow: { key, stateAttribute }`); every form that saves it has a
502
- `workflow-actions` item whose key is no attribute, a button shown in view mode that goes back (Tutup), and no
503
- button shown in view mode that saves or deletes; and some list of it opens its records with Lihat (§3.3);
504
- - no menu opens a listed entity's form as a new record (§3.3).
505
- - every numbered attribute is a unique string with a valid format and no default; every `follows` and `startsFrom`
506
- names a lookup of its entity and what the picked entity has (§3.5).
507
-
508
- ---
509
-
510
- ## 9. Version history
511
-
512
- | Version | Date | Change |
513
- |---|---|---|
514
- | 1.0 | 2026-09-26 | First edition: forms (repeat, grid, layout rows), lists (row click, New), effects incl. SAVE and DELETE, row order, the completeness mark, error codes. |
515
- | 1.1 | 2026-09-26 | Template items: inline `style` now survives (converted to classes). |
516
- | 1.2 | 2026-10-06 | Rules 11–12 (found live on a generated screen whose Save did nothing): SAVE says what happened and goes back to where the form was opened; required fields can be filled. `NAVIGATE back`. The guide moves under `cli/engine/cc/guides/` and becomes the Phase B AI's context; the checklist grows by what Control Center checks. |
517
- | 1.3 | 2026-10-06 | Rule 13, the CRUD baseline (Agung): form Simpan + Batal; list Tambah + row actions Ubah / Hapus shown on hover, no row click needed; a workflow's buttons on its document's form. `DELETE` from a list row (`id`). |
518
- | 1.4 | 2026-10-07 | §3.3 a document with a workflow: the entity declares it (started on create, state kept on its attribute), the form shows the state and the `workflow-actions` buttons (roles from user groups), a review or approval is a filtered list — never a new-record menu. |
519
- | 1.5 | 2026-10-07 | A review list (a fixed `filter`) only opens its records: Ubah alone — found live when the CRUD rule gave an approval list Tambah and Hapus. |
520
- | 1.6 | 2026-10-07 | View mode (Agung): Lihat opens the same form read-only (`queryParams: { mode: 'view' }`) with the transition buttons and Tutup; Simpan / Batal hide there (`expressions` on `formState.readonly`). Where Lihat lives is the customer's choice on the map (`transitionUi`): the main list, or a review list per waiting stage. |
521
- | 1.7 | 2026-10-07 | §3.4 rows of fields (Agung): short fields side by side on Bootstrap's grid — a row group, a column group per field — by the screen's usage. The Supertail-only row styles leave the guide; a `styleRef` names the domain's own `ui.styles`. |
522
- | 1.8 | 2026-10-07 | §3.1: a grid hides its add and delete unless declared — an edited grid declares both (found live: a generated offer grid took no new lines). |
523
- | 1.9 | 2026-10-07 | §3.1 / rule 3: a grid's own `key` names the grid, never its detail table (that is `props.detailKey`) — found live: with the same name, adding a row failed (`this._controls.push is not a function`). |
524
- | 1.10 | 2026-10-07 | Rule 13: a required lookup's master data is maintained by some screen (a list with Tambah, its form, a menu item) — found live: a required customer lookup with no customer screen, so no offer could be saved. |
525
- | 1.11 | 2026-10-07 | §3.1: how a lookup is declared — many-to-one from the entity holding the field to the one it picks from (found live: declared one-to-many from the master, the customer lookup listed offers). |
526
- | 1.12 | 2026-10-07 | §3.5 values the system fills (Agung): a document number the database gives (`numbering`; default `<initials>/{YYYY}/{MM}/{seq:4}`, restarting every year), a value taken from the record a lookup picks (`follows`), lines that start from the picked record's lines (`startsFrom`). |
527
- | 1.13 | 2026-10-07 | §6.5: a refused save is named by the field's label — `DUPLICATE`, `REQUIRED`, `REFERENCE_NOT_FOUND` (found live: a duplicate reached the user as the raw database message with the statement). |
528
- | 1.14 | 2026-10-07 | §3.1: what a lookup lists (Agung) — a filter its attribute declares (only accepted offers), and a `unique` lookup hides the records another record already picked. |
529
- | 1.15 | 2026-10-07 | §3.3: after a transition the form goes back to where it was opened (Agung) — it used to stay and reload. |
530
- | 1.16 | 2026-10-07 | §3.1: an entity names its records by its `display` attribute, which every lookup to it shows and searches (found live: the account lookup showed the first attribute, not the name). |
531
- | 1.17 | 2026-10-09 | §7 the list engine (Doc 8 M2, Agung): a search box, a print button, Salin (a new form filled from the row, `copyFrom`), `enabled` per row over `row` and the list's `model` (written by `FORM_PATCH`). Selection and mass actions are application (Layer 2) features. |
1
+ # Writing version 2 screens — authoring guide
2
+
3
+ **Applies to:** Biz-A platform as of 2026-10-07 (Doc 2 steps 1–3) · **Status:** reference, v1.16
4
+ **Audience:** anyone writing Layer 3 domain config — implementors, and the AI in Doc 1, which reads this as context.
5
+ **Source of truth:** Doc 2 (`Doc 2 Biza_ai_ready.md`) and its implementation plan (`DOC2-IMPLEMENTATION-PLAN-2026-09-25.md`).
6
+ **Where it lives:** `cli/engine/cc/guides/V2-SCREEN-AUTHORING.md` — Control Center hands it to the AI that writes
7
+ configuration (Phase B), and checks the rules below on every AI write. `docs/V2-SCREEN-AUTHORING.md` is an
8
+ identical copy; a test keeps the two equal, so edit the one under `cli` and copy it.
9
+ Every rule below is enforced by the platform or pinned by a test; where a rule is only a convention, it says so.
10
+
11
+ A version 2 screen is **configuration only**: no load code, no save code, no deleted-row tracking, no handlers.
12
+ The platform loads the record with its details, keeps the form and its data in step, and saves the whole
13
+ graph in one transaction. If a v2 screen seems to need a handler, first check whether an effect below already
14
+ does the job.
15
+
16
+ ---
17
+
18
+ ## 1. The rules (read these first)
19
+
20
+ 1. **Every screen you write is `version: 2`.** Never produce new v1 config. `version` sits on the **entryPoint**
21
+ (per screen), not on `metadata` — `metadata.version` is the package's semver and means something else.
22
+ 2. **An entryPoint names its `entity`.** The entity's table is what the screen loads and saves.
23
+ 3. **Collections are keyed by the detail TABLE name** — a repeat by its `key`, a grid by its `props.detailKey` —
24
+ and only a declared `one-to-many` relationship makes a repeat or grid a collection. Anything else is UI-only and is neither loaded nor saved — with no error.
25
+ 4. **Every collection a v2 screen edits declares `orderBy`** on its relationship, and the order column is a
26
+ `number` attribute. The platform numbers rows from their screen position on save. Save **refuses** a screen
27
+ that edits an unordered collection.
28
+ 5. **Never show the order column on a v2 screen** — the platform owns it and overwrites whatever is typed.
29
+ 6. **Owned details declare `ownership: 'parent'`.** Delete cascades through exactly these; `shared` and
30
+ `independent` relationships are never followed.
31
+ 7. **Bindings declare `effects`, not a `handler`.** A binding may have one or the other, never both.
32
+ 8. **A collection you save must be complete.** Deletion is inferred from absence: a row missing from the save
33
+ is deleted. Never paginate, filter or lazy-load an edited collection — or, if a component must, mark it
34
+ incomplete (§6.4) so the save is refused instead.
35
+ 9. **Give list columns a `title`** when the attribute has no `label`, or the header shows the raw column name.
36
+ 10. **Keep config comments free of apostrophes and quote marks** — the admin App Config parser counts them.
37
+ 11. **A form tells the user what happened, and goes back to where it was opened.** Its `SAVE` carries
38
+ `onSuccess` (a success `NOTIF`, then `NAVIGATE` with `back: true`) and `onInvalid` (a `NOTIF` saying what to
39
+ complete) — without `onInvalid` an incomplete form does nothing at all. A `DELETE` goes back the same way.
40
+ Give `back` a `route` (the entity's list) for a form opened directly, from a bookmark or a refresh.
41
+ 12. **Every required attribute can be filled.** It is a field the user edits, or the item carries a
42
+ `defaultValue`, or the attribute has one. A field the form only **shows** — a `label` item, or a `statusBadge`
43
+ or `readonly` attribute — gets the item's `defaultValue` when it is required (a workflow state: its initial
44
+ state), or the form is never valid.
45
+ 13. **The CRUD baseline — every entity a user maintains gets all of it.**
46
+ - **Form:** its fields, **Simpan** (rule 11) and **Batal** — a button bound to `NAVIGATE` with `back: true`
47
+ (the entity's list as `route`), which leaves without saving.
48
+ - **List:** **Tambah** (§7) and, on every row, **Ubah** and **Hapus** — one row button column
49
+ `{ type: 'button', buttons: [{ icon: 'edit', caption: 'Ubah' }, { icon: 'delete', caption: 'Hapus' }] }`,
50
+ bound by icon: `edit` → `NAVIGATE ['form', <form>, { $ref: 'event.id' }]`, `delete` →
51
+ `DELETE { id: { $ref: 'event.id' }, confirm: '<question>' }`. The platform shows a row's actions when the row
52
+ is hovered (always, on a touch screen), so no row needs a click to open. A **review list** — one with a fixed
53
+ `filter`, like the records waiting for an approval (§3.3) — only opens its records: **Lihat** for a document
54
+ with a workflow, Ubah otherwise; no Tambah or Hapus — those belong to the entity's main list.
55
+ - **A lookup's master data:** a required lookup picks from an entity some screen maintains — that entity's own
56
+ list with Tambah, its form and a menu item, in this domain or another. Without one nothing can be picked and
57
+ the record can never be saved.
58
+ - **A document with a workflow** is moved through its states in **view mode**: **Lihat** opens its form
59
+ read-only, with a button per transition the user may take — clicking one moves the record to that
60
+ transition's next state — and **Tutup**; Simpan and Batal show only while editing (§3.3).
61
+
62
+ ---
63
+
64
+ ## 2. A complete example
65
+
66
+ The Add-Info template screens in Supertail (`supertail/src/config/setupConfig.js`) are the reference: a list,
67
+ and a master → detail → subdetail form, with no handler code at all. (Supertail's own screens also name Supertail's
68
+ own styles — `styleRef: 'screenShell'`, `'inlineRow'` …; they are left out here. A domain names only the styles its
69
+ own `ui.styles` declares.)
70
+
71
+ ### 2.1 Model — what the screens depend on
72
+
73
+ ```js
74
+ entities: {
75
+ st_item_add_info_template: { table: 'st_item_add_info_template', attributes: { template_code: {...}, template_name: {...}, active: {...} } },
76
+ st_item_add_info_template_det: { table: 'st_item_add_info_template_det', attributes: { field_label: {...}, sequence_no: { type: 'number', ... }, ... } },
77
+ st_item_add_info_options: { table: 'st_item_add_info_options', attributes: { value: {...}, label: {...}, sequence_no: { type: 'number', uiIntent: 'number' } } },
78
+ },
79
+ relationships: {
80
+ rel_item_add_info_template_det: {
81
+ type: 'one-to-many', from: 'st_item_add_info_template', to: 'st_item_add_info_template_det',
82
+ foreignKey: 'st_item_add_info_template_id', ownership: 'parent',
83
+ orderBy: 'sequence_no', // rule 4
84
+ },
85
+ rel_item_add_info_options: {
86
+ type: 'one-to-many', from: 'st_item_add_info_template_det', to: 'st_item_add_info_options',
87
+ foreignKey: 'st_add_info_tmpl_det_id', // declared: the <parent>_id convention is too long for Firebird
88
+ ownership: 'parent', orderBy: 'sequence_no',
89
+ },
90
+ },
91
+ ```
92
+
93
+ ### 2.2 The form
94
+
95
+ ```js
96
+ entryPoints: {
97
+ stItemAddInfoTemplateV2: {
98
+ entity: 'st_item_add_info_template',
99
+ structureRef: 'stItemAddInfoTemplateV2Structure',
100
+ version: 2,
101
+ eventBindings: [
102
+ { emitter: 'Simpan Add-Info Template V2', trigger: 'onClick', // the button label, exactly
103
+ effects: [{ type: 'SAVE',
104
+ onSuccess: [{ type: 'NOTIF', level: 'success', message: 'Add-Info Template tersimpan.' },
105
+ { type: 'NAVIGATE', back: true, route: ['list', 'supertail-stItemAddInfoTemplateListV2'] }],
106
+ onInvalid: [{ type: 'NOTIF', level: 'warning', message: 'Lengkapi isian yang wajib.' }] }] }, // rule 11
107
+ { emitter: 'Hapus Add-Info Template V2', trigger: 'onClick',
108
+ effects: [{ type: 'DELETE', confirm: 'Hapus template ini beserta semua field dan opsinya?',
109
+ confirmText: 'Hapus', cancelText: 'Batal',
110
+ onSuccess: [{ type: 'NOTIF', level: 'success', message: 'Add-Info Template dihapus.' },
111
+ { type: 'NAVIGATE', back: true, route: ['list', 'supertail-stItemAddInfoTemplateListV2'] }] }] },
112
+ { emitter: 'Batal', trigger: 'onClick', // rule 13: leave without saving
113
+ effects: [{ type: 'NAVIGATE', back: true, route: ['list', 'supertail-stItemAddInfoTemplateListV2'] }] },
114
+ ],
115
+ },
116
+ },
117
+ structures: {
118
+ stItemAddInfoTemplateV2Structure: {
119
+ type: 'form',
120
+ items: [
121
+ { className: 'row g-2', items: [ // a row of fields (§3.4): no key, no type
122
+ { className: 'col-12 col-md', items: [{ key: 'template_code', type: 'input', props: { label: 'Kode', required: true } }] },
123
+ { className: 'col-12 col-md', items: [{ key: 'template_name', type: 'input', props: { label: 'Nama', required: true } }] },
124
+ { className: 'col-12 col-md-auto', items: [{ key: 'active', type: 'bool', defaultValue: 1, props: { label: 'Aktif', showAsCheckBox: true } }] },
125
+ ] },
126
+ { type: 'repeat', key: 'st_item_add_info_template_det', // the DETAIL TABLE (rule 3)
127
+ props: { label: 'Field', addBtn: { text: 'Tambah Field', icon: 'add' }, deleteBtn: { text: 'Hapus Field', icon: 'delete' } },
128
+ items: [
129
+ { key: 'field_label', ... },
130
+ { type: 'repeat', key: 'st_item_add_info_options', props: {...}, items: [ { key: 'value', ... }, ... ] },
131
+ ] },
132
+ { type: 'button', icon: 'save', label: 'Simpan Add-Info Template V2' },
133
+ { type: 'button', icon: 'delete', label: 'Hapus Add-Info Template V2' },
134
+ { type: 'button', icon: 'close', label: 'Batal' },
135
+ ],
136
+ },
137
+ },
138
+ ```
139
+
140
+ ### 2.3 The list
141
+
142
+ ```js
143
+ entryPoints: {
144
+ stItemAddInfoTemplateListV2: {
145
+ entity: 'st_item_add_info_template',
146
+ structureRef: 'stItemAddInfoTemplateListV2Structure',
147
+ version: 2,
148
+ eventBindings: [
149
+ { emitter: 'add', trigger: 'onClick',
150
+ effects: [{ type: 'NAVIGATE', route: ['form', 'supertail-stItemAddInfoTemplateV2', 'null'] }] },
151
+ { emitter: 'edit', trigger: 'onClick', // rule 13: row actions
152
+ effects: [{ type: 'NAVIGATE', route: ['form', 'supertail-stItemAddInfoTemplateV2', { $ref: 'event.id' }] }] },
153
+ { emitter: 'delete', trigger: 'onClick',
154
+ effects: [{ type: 'DELETE', id: { $ref: 'event.id' }, confirm: 'Hapus template ini beserta semua field dan opsinya?' }] },
155
+ ],
156
+ },
157
+ },
158
+ structures: {
159
+ stItemAddInfoTemplateListV2Structure: {
160
+ type: 'list', caption: 'Add-Info Template',
161
+ items: [ { key: 'template_code', title: 'Kode' }, { key: 'template_name', title: 'Nama' }, { key: 'active', title: 'Aktif' },
162
+ { type: 'button', buttons: [{ icon: 'edit', caption: 'Ubah' }, { icon: 'delete', caption: 'Hapus' }] } ],
163
+ buttons: [{ icon: 'add', caption: 'Tambah' }],
164
+ },
165
+ },
166
+ ```
167
+
168
+ Routes are `form/<app>-<entryPoint>/<id>` and `list/<app>-<entryPoint>`; `null` as the id opens a new record.
169
+ Publishing generates each screen's template — nothing else needs to exist.
170
+
171
+ ---
172
+
173
+ ## 3. Form screens
174
+
175
+ ### 3.1 Items
176
+
177
+ | Item | Shape | Notes |
178
+ |---|---|---|
179
+ | Field | `{ key, type?, props?, defaultValue?, styleRef? }` | `key` is an attribute of the entity that owns this level. Without `type`, the attribute's `uiIntent` decides the control. Explicit `props` win over the attribute. `defaultValue` fills a new record (rule 12). A column class goes on a group around the field, never on the field (§3.4). |
180
+ | Layout row / group | `{ className, items }` or `{ styleRef, items }` — **no `key`, no `type`** | Groups fields of the **same** entity. `className` takes Bootstrap's grid classes — rows and columns (§3.4). A `styleRef` names a style of this domain's own `ui.styles`; nothing else resolves. |
181
+ | Repeat (a collection) | `{ type: 'repeat', key: <detail table>, props, items }` | Its fields resolve against the **detail** entity (labels, validation). Nests for subdetail. Buttons: `props.addBtn` / `props.deleteBtn` as `{ text, icon }` — `addText` alone renders an unlabeled button. |
182
+ | Grid (a one-level collection) | `{ type: 'detail-grid', key: 'grid_<detail table>', props: { detailKey: <detail table>, columns, addBtn, deleteBtn } }` | The grid's own `key` names the grid — no attribute, never the detail table: the form holds a plain field under that key, and with the table's name the grid could add no row. Columns are `{ key, label, type?, default? }`. `default` initialises rows the user adds, never loaded rows. A hidden `id` column is added automatically. A grid hides its add and delete unless declared: a grid the user edits carries `addBtn: { show: true, label: 'Tambah <row>' }` and `deleteBtn: { show: true, icon: 'delete', key: '__hapus_<detail table>', label: ' ' }` (the delete column's own key, no attribute). |
183
+ | Button | `{ type: 'button', icon, label, styleRef }` | Its `label`, exactly, is the emitter a binding names. |
184
+ | Template (static HTML) | `{ type: 'template', template }` | Rendered through Angular's HTML sanitiser (scripts and event handlers are removed). Inline `style="…"` works: the platform turns each into a generated class. A style containing `{ } < >` is dropped. |
185
+
186
+ **A lookup** — a field that picks a record of another entity — is an attribute holding that record's key
187
+ (`uiIntent: 'lookup'`) and a relationship
188
+ `{ type: 'many-to-one', from: '<this entity>', to: '<the entity it picks from>', foreignKey: '<the field>', ownership: 'shared' }`.
189
+ The platform lists the records of the relationship's `to`; declared one-to-many from the other side, it would list
190
+ the wrong table. Each record is shown — and searched — by the attribute that names it, which that entity declares:
191
+ `display: '<the attribute that names one record>'` (a name or a number — `display: 'nama_lead'` on Account), beside
192
+ its `table` and `identity`; without it the lookup shows the first attribute, whatever that is. Deleting a record a
193
+ lookup still points to is refused (`IN_USE`).
194
+ It lists every record of that entity unless its attribute narrows it:
195
+ `lookupOptions: { filter: [{ junction: '', column: '<an attribute of the entity it picks from>', operator: '=', value1: "'<value>'" }] }`
196
+ (e.g. only offers whose state is the accepted one; operators `=`, `<>`, `<`, `>`, `<=`, `>=`, `in`, `not in`,
197
+ `is null`, `is not null`; the user's search is added to it). A lookup attribute declared `unique` (one record per
198
+ pick — one offer makes one contract) hides the records another record already picked, keeping this record's own
199
+ pick; nothing else to declare, and the database refuses a duplicate anyway (`DUPLICATE`).
200
+
201
+ Any item may carry `expressions: { hide: '<expression>' }`, evaluated against `model` and `formState` while the
202
+ form is open. `formState.readonly` is `true` in view mode (§3.2), so an item can show only while editing
203
+ (`hide: 'formState.readonly'`) or only while viewing (`hide: '!formState.readonly'`).
204
+
205
+ ⚠️ A repeat or grid whose key is **not** a declared detail table is UI-only: the platform treats it as screen
206
+ state, loads nothing into it and saves nothing from it — silently.
207
+
208
+ ### 3.2 What the platform does for you
209
+
210
+ - **Load.** Opening `form/<app>-<entryPoint>/<id>` reads the master and every collection on the screen in one
211
+ request, each collection in its declared order (`orderBy`, then `id`). A new record (`null`) reads nothing.
212
+ - **Form ↔ data.** Typing updates the form data immediately, grid rows included; an effect that changes the data
213
+ (FORM_PATCH) shows on screen immediately. Nothing to wire.
214
+ - **Save** (the `SAVE` effect). Sends the record and its collections; the server works out what was added,
215
+ changed and removed and writes only that, in one transaction (§6). A new record gets its key back, the form
216
+ reloads it, and the address changes from `/null` to that key.
217
+ - **Row order.** Rows are numbered 1..n from their screen position into the `orderBy` column on every save.
218
+ Unchanged positions write nothing.
219
+ - **View mode.** A form opened with `queryParams: { mode: 'view' }` (the address ends `?mode=view`) shows its record
220
+ read-only: no field can be changed — select, date, repeat and grid included — collections show no add or delete,
221
+ and `SAVE` / `DELETE` change nothing. It is the same form, not a second screen; `formState.readonly` is `true`.
222
+ A form in a dialog is never in view mode.
223
+
224
+ ### 3.3 A document with a workflow
225
+
226
+ A document whose life is a workflow — a BPM workflow whose `entityType` is the entity — is moved through its states
227
+ in **view mode** (§3.2): **Lihat** opens the record read-only on its own form, with a button for each transition the
228
+ user may take, and **Tutup**. One form serves both editing and viewing — never a second "view" or "approval" form.
229
+
230
+ - **The entity declares it:** `workflow: { key: '<workflowKey>', stateAttribute: '<attribute>' }`. The platform
231
+ starts the workflow when the form creates a record (at its initial state) and writes every state change onto the
232
+ state attribute, so lists and forms show the state like any other field, and a save never overwrites it.
233
+ - **The state attribute** is a `string`, `uiIntent: 'statusBadge'`, `required`. The form shows it as
234
+ `{ key: '<attribute>', type: 'label', defaultValue: '<initialStateCode>' }` (rule 12); the list shows it as a column.
235
+ - **The form** carries, besides its fields:
236
+ - **Simpan** and **Batal**, shown only while editing — `expressions: { hide: 'formState.readonly' }` (a Hapus
237
+ button on the form, if any, too);
238
+ - **Tutup**, shown only in view mode — `expressions: { hide: '!formState.readonly' }` — bound to `NAVIGATE` with
239
+ `back: true`, the entity's list as `route`;
240
+ - **the transition buttons:** the item `{ type: 'workflow-actions', key: 'aksi_workflow' }` — its key is no
241
+ attribute. The platform fills it from the screen and shows it in view mode only (a transition leaves the
242
+ form, which would throw away unsaved edits). Each button is a transition the signed-in user may take: their
243
+ roles are the role codes mapped to their user groups (an administrator holds every role). Pressing one moves
244
+ the record to that transition's next state, and the form goes back to where it was opened (the list, shown
245
+ fresh with the new state); opened directly, it reloads the record instead.
246
+ - **Lihat** is a row action `{ icon: 'visibility', caption: 'Lihat' }`, bound `visibility` / `onClick` to
247
+ `NAVIGATE ['form', <form>, { $ref: 'event.id' }]` with `queryParams: { mode: 'view' }`. Where it lives is the
248
+ customer's choice, recorded on the process map as the lifecycle's `transitionUi`:
249
+ - `view` — the default: on the rows of the entity's main list, before Ubah and Hapus;
250
+ - `menus` — a review list for each stage that waits for someone (a map SCREEN with that `stage`), each reached
251
+ from its own menu item: a list over the same entity with a fixed `filter` on the state attribute,
252
+ `filter: [{ junction: '', column: '<table>.<state column>', operator: '=', value1: "'<STATE>'" }]`, whose only
253
+ row action is Lihat — no Tambah, Ubah or Hapus. The main list keeps Ubah and Hapus.
254
+ - A menu never opens a listed entity's form as a new record.
255
+
256
+ ```js
257
+ // the form (bindings: Simpan → SAVE, rule 11; Batal and Tutup → [{ type: 'NAVIGATE', back: true, route: ['list', '<list>'] }])
258
+ items: [
259
+ { key: 'judul' },
260
+ { key: 'status_code', type: 'label', defaultValue: 'BARU' },
261
+ { type: 'workflow-actions', key: 'aksi_workflow' },
262
+ { type: 'button', icon: 'save', label: 'Simpan', expressions: { hide: 'formState.readonly' } },
263
+ { type: 'button', icon: 'close', label: 'Batal', expressions: { hide: 'formState.readonly' } },
264
+ { type: 'button', icon: 'close', label: 'Tutup', expressions: { hide: '!formState.readonly' } },
265
+ ],
266
+
267
+ // the main list (transitionUi view): the row button column and the Lihat binding
268
+ { type: 'button', buttons: [{ icon: 'visibility', caption: 'Lihat' }, { icon: 'edit', caption: 'Ubah' }, { icon: 'delete', caption: 'Hapus' }] },
269
+ { emitter: 'visibility', trigger: 'onClick',
270
+ effects: [{ type: 'NAVIGATE', route: ['form', '<form>', { $ref: 'event.id' }], queryParams: { mode: 'view' } }] },
271
+ ```
272
+
273
+ ### 3.4 Rows of fields
274
+
275
+ A form is one field per line unless it says otherwise — fine on a phone, wasteful on a desktop. Biz-A has
276
+ Bootstrap 5's grid: put short fields side by side in rows.
277
+
278
+ - **A row** is a group `{ className: 'row g-2', items: [ <column>, … ] }`; **a column** is a group around ONE field,
279
+ `{ className: 'col-12 col-md', items: [{ key: '<attribute>' }] }`. The columns of a row share its width equally on a
280
+ screen at least 768 px wide, and stack one per line below it. `col-12 col-md-4`, `col-md-6` or `col-md-8` size a
281
+ column instead; `col-12 col-md-auto` fits it to its content (a checkbox).
282
+ - ⚠️ The column class goes on the group, never on the field item: several field types (date, money, lookup, label,
283
+ button) also put the field's own class on an inner element, where a width class shrinks the field.
284
+ - **Which fields:** 2–3 short ones per row — codes, names, dates, numbers, money, choices, lookups — related ones
285
+ together (number and date; customer and contact). Long text, a repeat, a grid, the workflow buttons and the form's
286
+ buttons stay full width, outside rows.
287
+ - **By the screen's usage** — the device of the map's usage context for its actors or the screen: desktop, tablet or
288
+ kiosk → rows; mobile only → no rows, one field per line; not known → rows (they stack on a phone anyway).
289
+
290
+ ```js
291
+ items: [
292
+ { className: 'row g-2', items: [
293
+ { className: 'col-12 col-md', items: [{ key: 'nomor' }] },
294
+ { className: 'col-12 col-md', items: [{ key: 'tanggal' }] },
295
+ { className: 'col-12 col-md', items: [{ key: 'customer_id' }] },
296
+ ] },
297
+ { key: 'catatan' }, // long text: full width
298
+ { type: 'repeat', key: 'penawaran_line', props: { ... }, items: [ ... ] },
299
+ ]
300
+ ```
301
+
302
+ ### 3.5 Values the system fills
303
+
304
+ Three properties hand a value to the platform instead of the user — declared on the model, never computed by the form.
305
+
306
+ - **A number the system gives** — `numbering` on a `string` attribute:
307
+
308
+ ```js
309
+ nomor_kontrak: { type: 'string', size: 30, required: true, unique: true, label: 'Nomor Kontrak',
310
+ numbering: { format: 'KK/{YYYY}/{MM}/{seq:4}', reset: 'year' } },
311
+ ```
312
+
313
+ The database gives the number when the record is created (KK/2026/10/0001) and never changes it; whatever a form
314
+ sends is ignored. The format is text with `{YYYY}` `{YY}` `{MM}` `{RM}` (the month in Roman numerals) `{DD}` and
315
+ exactly one `{seq:N}` (the counter, padded to N digits); the rest only letters, digits, space and `/ . _ -`.
316
+ `reset` — `year`, `month`, `day` or `never` — says when the counter starts again at 1, and the number must show
317
+ that period (`{YYYY}` or `{YY}` for `year`). The attribute is `unique`, has no `defaultValue`, and its `size` fits
318
+ the longest number. The form shows it read-only and never asks for it; a new record shows it empty until saved,
319
+ so give the item a `props.placeholder` in the user's language ("Dibuat otomatis saat disimpan"). When the customer
320
+ has no format of their own: `<the document's initials>/{YYYY}/{MM}/{seq:4}`, restarting every year.
321
+ PostgreSQL databases only — the publish refuses it elsewhere.
322
+ - **A value taken from the record a lookup picks** — `follows` on an attribute:
323
+
324
+ ```js
325
+ customer_id: { type: 'number', uiIntent: 'lookup', required: true,
326
+ follows: { via: 'surat_penawaran_id', attribute: 'customer_id' } },
327
+ ```
328
+
329
+ `via` is a lookup attribute of the same entity (with its many-to-one relationship, §3.1); `attribute` is an
330
+ attribute of the entity it picks from, of the same type. When the user picks, the form copies the value at once and
331
+ shows it read-only; the database copies it again when the record is saved — on create, and whenever another record
332
+ is picked — so nothing else can set it. A later change to the picked record does not reach records already saved.
333
+ Required only when `via` is required.
334
+ - **Lines that start from the picked record's lines** — `startsFrom` on the owned one-to-many relationship:
335
+
336
+ ```js
337
+ kontrakItems: { type: 'one-to-many', from: 'KontrakKerja', to: 'KontrakItem', foreignKey: 'kontrak_kerja_id',
338
+ ownership: 'parent', orderBy: 'urutan',
339
+ startsFrom: { via: 'surat_penawaran_id', from: 'PenawaranItem' } },
340
+ ```
341
+
342
+ When the user picks, the lines of the picked record (`from`: a line entity the picked entity owns) are copied into
343
+ the form's collection — only while it is empty, so lines the user already has are never overwritten — and are then
344
+ edited freely; the save inserts them as this record's own. Attributes with the same name copy; `fields: { <this
345
+ line's attribute>: <the picked line's attribute> }` adds the ones named differently. Keys, the parent key and the
346
+ row order never copy. Keep the collection (repeat or grid) on the same screen as the lookup.
347
+
348
+ ---
349
+
350
+ ## 4. Bindings and expressions
351
+
352
+ ```js
353
+ { emitter: '<button label | list | icon or caption of a list button>', trigger: 'onClick' | 'onRowClick' | ...,
354
+ effects: [ ... ] }
355
+ ```
356
+
357
+ - An effects binding runs its effects in order. A binding with `effects` must not also name a `handler`.
358
+ - Any value inside an effect may be an expression, resolved when the event fires:
359
+ - `{ $ref: 'event.id' }` — a path read from `event`, `form` or `ctx` (e.g. `form.model.template_code`);
360
+ - `{ $ref: '…', $default: x }` — `x` when the path does not exist;
361
+ - `{ $const: x }` — `x` literally, never resolved.
362
+ - List emitters: `'list'` with trigger `onRowClick` (the whole row); a top or inline list button is matched by its
363
+ **icon**, or by its caption when it has no icon. `event.id` is the row's key in both cases; a row click also
364
+ carries `event.row`, the whole row.
365
+
366
+ ---
367
+
368
+ ## 5. Effects
369
+
370
+ | Effect | Parameters | Behaviour |
371
+ |---|---|---|
372
+ | `SAVE` | `onSuccess?`, `onError?`, `onInvalid?` (effect lists) | v2 forms only. Invalid form → fields are marked, `onInvalid` runs, nothing is sent. Success → the record is reloaded, then `onSuccess`. Failure → `onError`, or the server's message is shown. |
373
+ | `DELETE` | `id?`, `confirm?` (`false` or question text), `confirmText?`, `cancelText?`, `onSuccess?`, `onError?` | On a v2 form: the open record (saved records only). From a list row: the record `id` names (`{ $ref: 'event.id' }`), of the list's entity; the list reloads afterwards. Asks through the platform confirm dialog unless `confirm: false`. Anything but an explicit yes deletes nothing and runs neither list. Cascades by config (§6.3). |
374
+ | `NAVIGATE` | `route` (array), `back?`, `queryParams?`, `reload?` (default `true`) | Opens a screen. `['form', '<app>-<entryPoint>', <id or 'null'>]`, `['list', '<app>-<entryPoint>']`. `back: true` returns to the screen this one was opened from, shown fresh; `route` is then only the fallback for a screen opened directly, and with neither nothing happens. A route is always an array. `queryParams: { mode: 'view' }` opens a form in view mode (§3.2). |
375
+ | `NOTIF` | `message`, `level?` (`success` \| `error` \| other), `sticky?`, `duration?` | A snackbar. `success` lasts 3 s. |
376
+ | `FORM_PATCH` | `patch` (object of field values) | Writes values into the form data; shows immediately. |
377
+ | `FORM_ERROR` | `field`, `message` | Shows an error naming the field. |
378
+ | `OPEN_DIALOG` | `entryPointName`, `model?`, `afterClosedHandler?` | Opens another entryPoint in a dialog. |
379
+
380
+ ---
381
+
382
+ ## 6. Saving and deleting — the contract
383
+
384
+ ### 6.1 How a save is understood
385
+
386
+ The server compares what is sent with what is stored:
387
+
388
+ | Sent | Stored | Result |
389
+ |---|---|---|
390
+ | row with a key | same values | nothing written |
391
+ | row with a key | different values | UPDATE of the changed columns only |
392
+ | row without a key | — | INSERT (the key is generated) |
393
+ | — (row missing from a sent collection) | row | **DELETE** (soft or hard, per entity) |
394
+ | collection **not sent at all** | rows | left alone |
395
+
396
+ A field changed and then changed back writes nothing; untouched rows keep their keys; subdetail rows survive a
397
+ save of their parent row.
398
+
399
+ ### 6.2 Concurrency
400
+
401
+ A loaded record carries a row version. If someone else changed it meanwhile, save and delete are refused with
402
+ `409 CONCURRENCY_CONFLICT` and write nothing — reload and retry.
403
+
404
+ ### 6.3 Delete
405
+
406
+ `DELETE` removes the record and, at any depth, every row reached through an `ownership: 'parent'`
407
+ relationship, through each declared foreign key — deepest first, each table by its own `deleteStrategy`
408
+ (`soft` keeps the row and stamps it; anything else deletes it), in one transaction. A record that another row
409
+ still **references** through a `shared` relationship (e.g. a category using a template) is refused with
410
+ `409 IN_USE` naming the referencing table; nothing is deleted.
411
+
412
+ ### 6.4 Completeness — the `_incomplete` mark
413
+
414
+ Because a missing row means "delete it", a partially loaded collection must never be saved as if complete.
415
+ Whoever loads a collection partially marks its parent row:
416
+
417
+ ```js
418
+ { st_order: { id: 7, _incomplete: ['st_order_line'], st_order_line: [ /* one page */ ] } }
419
+ ```
420
+
421
+ A save carrying a marked collection is refused with `400 INCOMPLETE_COLLECTION` naming it, before anything is
422
+ read or written. A marked collection that is not sent is fine. No v2 read or component marks anything today —
423
+ whoever first adds paging or filtering to an edited collection must set the mark.
424
+
425
+ ### 6.5 Server errors a screen can meet
426
+
427
+ | Code | Status | Meaning |
428
+ |---|---|---|
429
+ | `CONCURRENCY_CONFLICT` | 409 | changed by someone else since loaded |
430
+ | `IN_USE` | 409 | cannot delete — still referenced by the named table |
431
+ | `DUPLICATE` | 409 | a value (or combination) a unique key already holds — named by the field's label |
432
+ | `REQUIRED` | 400 | a required value is missing — named by the field's label |
433
+ | `REFERENCE_NOT_FOUND` | 409 | a lookup points at a record that no longer exists — named by the field's label |
434
+ | `INCOMPLETE_COLLECTION` | 400 | a collection marked incomplete was sent |
435
+ | `GRAPH_READ_KEY_REQUIRED` | 400 | a graph read without a key |
436
+ | `SHARED_RELATIONSHIP` | 400 | a collection whose relationship is not owned by its parent was sent |
437
+ | `UNSAFE_SQL` | 400 | a name or value the ORM will not put into SQL |
438
+ | `CROSS_TENANT_WRITE` / `AMBIGUOUS_TENANT` | 403 / 400 | a write outside the caller's tenant / with no tenant |
439
+
440
+ Without an `onError`, the message is shown to the user as the server wrote it — never with SQL in it.
441
+
442
+ ---
443
+
444
+ ## 7. List screens
445
+
446
+ - A structure with `type: 'list'` over the entryPoint's `entity`. Items are columns `{ key, title? }`; top
447
+ `buttons` are `{ icon, caption }`. Optional `filter` (fixed conditions), `distinct`, `caption`.
448
+ - Paging, per-column filter buttons, soft-deleted rows and tenant scope are handled by the platform.
449
+ - A `list` / `onRowClick` binding makes rows clickable (pointer cursor). The row's key is fetched as a hidden
450
+ column only when such a binding exists; clicking a row button never also fires the row click.
451
+ - A list of an entity that has a form opens it both ways, as in §2.3 (rule 13): the `{ icon: 'add', caption: 'Tambah' }`
452
+ button bound `add` / `onClick` to the form with `'null'`, and a row button column whose `edit` opens the form
453
+ with `event.id` and whose `delete` deletes the row's record. Row buttons show when the row is hovered or holds
454
+ the keyboard focus, and always on a touch screen. A row click (`list` / `onRowClick`) is optional. The rows of a
455
+ document with a workflow also carry Lihat, where the customer chose so (§3.3).
456
+
457
+ ---
458
+
459
+ ## 8. Checking your config
460
+
461
+ Pin the silent failures in the application's own tests, as Supertail does in
462
+ `supertail/src/supertaillib/test/v2_add_info_template.test.js`. Control Center checks the screen rules on every
463
+ AI write (`cli/engine/cc/configValidate.js`), so what the AI commits is what publish and the client accept:
464
+
465
+ - every repeat / grid key is a declared detail table, at every level;
466
+ - every field is a declared attribute of the entity that owns its level;
467
+ - no binding names a handler; every Save/Delete button's label matches its binding's emitter;
468
+ - every collection a v2 screen edits declares `orderBy` on a `number` attribute, and no v2 screen shows it;
469
+ - NOT NULL columns a new row does not show have a `defaultValue` (repeat) or `default` (grid column);
470
+ - a grid a saving form edits shows its add and delete (`addBtn` / `deleteBtn` with `show: true`), and its own `key` is not its detail table;
471
+ - every required attribute can be filled; a required field a saving form only shows carries a `defaultValue` (rule 12);
472
+ - every `SAVE` has `onInvalid`, and goes back (`NAVIGATE back`) on success (rule 11);
473
+ - a form that saves has a cancel button bound to `NAVIGATE back` (rule 13);
474
+ - a list of an entity with a form has the add button and its binding, and row actions `edit` and `delete`, each
475
+ bound (rule 13) — a review list (a fixed `filter`) only the action that opens its records: Lihat for a document
476
+ with a workflow, `edit` otherwise;
477
+ - every `NAVIGATE` route is an array naming an entry point of the domain;
478
+ - every required lookup picks from an entity a list with Tambah maintains (rule 13);
479
+ - every lookup attribute has its many-to-one relationship, from its entity to the one it picks from (§3.1);
480
+ - every lookup filter names attributes of the entity it picks from, with a known operator (§3.1);
481
+ - every entity a lookup picks from declares `display`, the attribute that names its records (§3.1);
482
+ - every `styleRef` names a style of the domain's own `ui.styles` (§3.4);
483
+ - an entity with a workflow declares it (`workflow: { key, stateAttribute }`); every form that saves it has a
484
+ `workflow-actions` item whose key is no attribute, a button shown in view mode that goes back (Tutup), and no
485
+ button shown in view mode that saves or deletes; and some list of it opens its records with Lihat (§3.3);
486
+ - no menu opens a listed entity's form as a new record (§3.3).
487
+ - every numbered attribute is a unique string with a valid format and no default; every `follows` and `startsFrom`
488
+ names a lookup of its entity and what the picked entity has (§3.5).
489
+
490
+ ---
491
+
492
+ ## 9. Version history
493
+
494
+ | Version | Date | Change |
495
+ |---|---|---|
496
+ | 1.0 | 2026-09-26 | First edition: forms (repeat, grid, layout rows), lists (row click, New), effects incl. SAVE and DELETE, row order, the completeness mark, error codes. |
497
+ | 1.1 | 2026-09-26 | Template items: inline `style` now survives (converted to classes). |
498
+ | 1.2 | 2026-10-06 | Rules 11–12 (found live on a generated screen whose Save did nothing): SAVE says what happened and goes back to where the form was opened; required fields can be filled. `NAVIGATE back`. The guide moves under `cli/engine/cc/guides/` and becomes the Phase B AI's context; the checklist grows by what Control Center checks. |
499
+ | 1.3 | 2026-10-06 | Rule 13, the CRUD baseline (Agung): form Simpan + Batal; list Tambah + row actions Ubah / Hapus shown on hover, no row click needed; a workflow's buttons on its document's form. `DELETE` from a list row (`id`). |
500
+ | 1.4 | 2026-10-07 | §3.3 a document with a workflow: the entity declares it (started on create, state kept on its attribute), the form shows the state and the `workflow-actions` buttons (roles from user groups), a review or approval is a filtered list — never a new-record menu. |
501
+ | 1.5 | 2026-10-07 | A review list (a fixed `filter`) only opens its records: Ubah alone — found live when the CRUD rule gave an approval list Tambah and Hapus. |
502
+ | 1.6 | 2026-10-07 | View mode (Agung): Lihat opens the same form read-only (`queryParams: { mode: 'view' }`) with the transition buttons and Tutup; Simpan / Batal hide there (`expressions` on `formState.readonly`). Where Lihat lives is the customer's choice on the map (`transitionUi`): the main list, or a review list per waiting stage. |
503
+ | 1.7 | 2026-10-07 | §3.4 rows of fields (Agung): short fields side by side on Bootstrap's grid — a row group, a column group per field — by the screen's usage. The Supertail-only row styles leave the guide; a `styleRef` names the domain's own `ui.styles`. |
504
+ | 1.8 | 2026-10-07 | §3.1: a grid hides its add and delete unless declared — an edited grid declares both (found live: a generated offer grid took no new lines). |
505
+ | 1.9 | 2026-10-07 | §3.1 / rule 3: a grid's own `key` names the grid, never its detail table (that is `props.detailKey`) — found live: with the same name, adding a row failed (`this._controls.push is not a function`). |
506
+ | 1.10 | 2026-10-07 | Rule 13: a required lookup's master data is maintained by some screen (a list with Tambah, its form, a menu item) — found live: a required customer lookup with no customer screen, so no offer could be saved. |
507
+ | 1.11 | 2026-10-07 | §3.1: how a lookup is declared — many-to-one from the entity holding the field to the one it picks from (found live: declared one-to-many from the master, the customer lookup listed offers). |
508
+ | 1.12 | 2026-10-07 | §3.5 values the system fills (Agung): a document number the database gives (`numbering`; default `<initials>/{YYYY}/{MM}/{seq:4}`, restarting every year), a value taken from the record a lookup picks (`follows`), lines that start from the picked record's lines (`startsFrom`). |
509
+ | 1.13 | 2026-10-07 | §6.5: a refused save is named by the field's label — `DUPLICATE`, `REQUIRED`, `REFERENCE_NOT_FOUND` (found live: a duplicate reached the user as the raw database message with the statement). |
510
+ | 1.14 | 2026-10-07 | §3.1: what a lookup lists (Agung) — a filter its attribute declares (only accepted offers), and a `unique` lookup hides the records another record already picked. |
511
+ | 1.15 | 2026-10-07 | §3.3: after a transition the form goes back to where it was opened (Agung) — it used to stay and reload. |
512
+ | 1.16 | 2026-10-07 | §3.1: an entity names its records by its `display` attribute, which every lookup to it shows and searches (found live: the account lookup showed the first attribute, not the name). |