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.
- package/bin/app.js +20 -255
- package/bin/hub.js +0 -16
- package/bin/hubEvent.js +5 -98
- package/bin/tunnelTarget.js +0 -4
- package/engine/bpm/workflow.js +6 -7
- package/engine/cc/configValidate.js +0 -53
- package/engine/cc/guides/V2-SCREEN-AUTHORING.md +512 -531
- package/engine/cc/layer3Behavior.js +1 -2
- package/engine/cc/layer3Schema.js +1 -3
- package/engine/cc/livePipelines.js +3 -9
- package/engine/cc/publish.js +105 -86
- package/engine/domain/appConfig.js +18 -108
- package/engine/domain/applicationConfig.js +12 -87
- package/engine/domain/dialect.js +0 -138
- package/engine/domain/domainNavigation.js +50 -86
- package/engine/domain/entityConfigLoader.js +4 -16
- package/engine/domain/numericScale.js +0 -2
- package/engine/domain/system.js +0 -2
- package/engine/ext/publicTheme.js +1 -8
- package/engine/orm/apiRoute.js +61 -187
- package/engine/orm/entityConfig.js +2 -32
- package/engine/orm/finaAuth.js +0 -0
- package/engine/orm/finaEndpoint.js +0 -19
- package/engine/orm/imgQuery.js +0 -92
- package/engine/orm/listQuery.js +1 -6
- package/package.json +1 -1
- package/worker/cliScriptWorker.js +0 -5
- package/engine/attachments/store.js +0 -195
- package/engine/domain/domainRegistration.js +0 -65
- package/engine/fina/approval.js +0 -141
- package/engine/fina/approvalService.js +0 -59
- package/engine/fina/attachments.js +0 -165
- package/engine/fina/client.js +0 -84
- package/engine/fina/config.js +0 -26
- package/engine/fina/context.js +0 -58
- package/engine/fina/document.js +0 -67
- package/engine/fina/documentWrite.js +0 -173
- package/engine/fina/documents/index.js +0 -28
- package/engine/fina/documents/jv.js +0 -105
- package/engine/fina/documents/jvWrite.js +0 -260
- package/engine/fina/documents/si.js +0 -40
- package/engine/fina/errors.js +0 -17
- package/engine/fina/features.js +0 -86
- package/engine/fina/guard.js +0 -102
- package/engine/fina/identity.js +0 -87
- package/engine/fina/live.js +0 -19
- package/engine/fina/lock.js +0 -35
- package/engine/fina/master.js +0 -331
- package/engine/fina/masters/coa.js +0 -104
- package/engine/fina/masters/currency.js +0 -39
- package/engine/fina/masters/department.js +0 -32
- package/engine/fina/masters/index.js +0 -21
- package/engine/fina/masters/project.js +0 -53
- package/engine/fina/period.js +0 -56
- package/engine/fina/print.js +0 -136
- package/engine/fina/readPolicy.js +0 -67
- package/engine/fina/recurring.js +0 -200
- package/engine/fina/route.js +0 -61
- package/engine/fina/routes/attachment.js +0 -41
- package/engine/fina/routes/document.js +0 -66
- package/engine/fina/routes/master.js +0 -26
- package/engine/fina/routes/period.js +0 -24
- package/engine/fina/routes/print.js +0 -19
- package/engine/fina/routes/recurring.js +0 -27
- package/engine/fina/routes/session.js +0 -23
- package/engine/fina/routes/setup.js +0 -58
- package/engine/fina/routes/submission.js +0 -46
- package/engine/fina/routes/template.js +0 -31
- package/engine/fina/settings.js +0 -159
- package/engine/fina/sql.js +0 -34
- package/engine/fina/submission.js +0 -290
- package/engine/fina/submissionCompare.js +0 -171
- package/engine/fina/submissionRepo.js +0 -114
- package/engine/fina/template.js +0 -118
- package/engine/fina/userMap.js +0 -79
- package/engine/orm/companyLocale.js +0 -74
- package/engine/orm/userApiDataset.js +0 -153
- package/migrations/postgres/1790600000000__fina.sql +0 -33
- package/migrations/postgres/1790700000000__attachment.sql +0 -29
- 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-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
- every
|
|
482
|
-
- every
|
|
483
|
-
-
|
|
484
|
-
-
|
|
485
|
-
|
|
486
|
-
-
|
|
487
|
-
- every
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
-
|
|
499
|
-
-
|
|
500
|
-
-
|
|
501
|
-
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
-
|
|
505
|
-
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
|
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). |
|