@meshmakers/octo-ui 3.4.1480 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -137,6 +137,7 @@ export class CustomerDataSourceDirective extends OctoGraphQlDataSource<CustomerD
137
137
  | `FieldFilterEditorComponent` | Visual filter editor for queries |
138
138
  | `EntityIdInfoComponent` | Entity ID display with copy-to-clipboard dropdown |
139
139
  | `OctoLoaderComponent` | Animated OctoMesh logo loading indicator |
140
+ | `PageComponent` (`MM_PAGE`) | `<mm-page>` page layout: optional header (title, subtitle, actions) + scrolling content, no footer |
140
141
 
141
142
  ## Available Services
142
143
 
@@ -207,6 +208,113 @@ See [`src/lib/branding/BRANDING_USAGE.md`](./src/lib/branding/BRANDING_USAGE.md)
207
208
  for the full list of CSS variables the library updates and the host-app
208
209
  contract for the surface ladder.
209
210
 
211
+ ## Page layout (`mm-page`)
212
+
213
+ `<mm-page>` (`PageComponent`) is the token-based page frame that replaces the
214
+ LCARS header / content panel / footer triple: an optional header (title,
215
+ subtitle, `[mmPageActions]`) and a scrolling content area, no footer. Import
216
+ `MM_PAGE` (component + slot directives); the title defaults to heading level 2
217
+ and the header is a plain `<div>`. See
218
+ [`src/lib/page/README.md`](./src/lib/page/README.md).
219
+
220
+ ```html
221
+ <mm-page pageTitle="Adapters" pageSubtitle="12 registered">
222
+ <div mmPageActions><button kendoButton themeColor="primary">New adapter</button></div>
223
+ <mm-list-view …></mm-list-view>
224
+ </mm-page>
225
+ ```
226
+
227
+ ## Theme tokens (overridable CSS variables)
228
+
229
+ `@include octo.theme()` emits the "Deep Sea" token set on `:root` (dark
230
+ default, light via `prefers-color-scheme` or `data-theme="light"`); source:
231
+ `src/lib/runtime-browser/styles/_theme.scss`. Hosts may override any of these
232
+ custom properties after the include. All `--theme-*` names of the former
233
+ theme are kept; tokens marked *new* were added in AB#5519.
234
+
235
+ | Group | Tokens |
236
+ |---|---|
237
+ | Surfaces | `--theme-bg-app`, `--theme-bg-app-end`, `--theme-bg-surface`, `--theme-bg-elevated`, `--theme-bg-overlay`, `--theme-bg-input`, *new:* `--theme-bg-sunken`, `--theme-bg-selected`, `--theme-bg-hover` |
238
+ | Text | `--theme-text-primary`, `--theme-text-secondary`, `--theme-text-muted`, `--theme-text-on-accent`, `--theme-text-accent`, *new:* `--theme-text-code` |
239
+ | Borders | `--theme-border-subtle`, `--theme-border-strong`, `--theme-border-divider` (= subtle), *new:* `--theme-border-default`, `--theme-focus-ring` (a `box-shadow` value) |
240
+ | Accent / AI (*new*) | `--theme-accent`, `--theme-accent-hover`, `--theme-accent-subtle`, `--theme-accent-2`, `--theme-ai`, `--theme-ai-subtle` |
241
+ | Status | `--theme-status-success`, `-warning`, `-error`, `-info`, *new:* `--theme-status-neutral`, `--theme-status-{success,warning,error,info,neutral}-subtle` |
242
+ | Effects | `--theme-shadow-panel`, `--theme-shadow-popup`; `--theme-glow-primary`, `--theme-glow-accent` (both `none`); `--theme-gradient-page`, `--theme-gradient-header`, `--theme-accent-bar`, `--theme-accent-line`, `--theme-panel-rule` (now flat colours) |
243
+ | Charts | `--theme-chart-1` … `--theme-chart-8` |
244
+ | Ink overlays | `--theme-ink-02` … `--theme-ink-70` (text colour at n % — flips with the theme) |
245
+ | Typography (*new*, theme-invariant) | `--theme-font-display` (Montserrat), `--theme-font-ui` (Roboto), `--theme-font-mono` (Roboto Mono) |
246
+ | Spacing (*new*) | `--theme-space-1` … `--theme-space-8` (4 px grid: 4, 8, 12, 16, 20, 24, 28, 32 px) |
247
+ | Radius (*new*) | `--theme-radius-xs` 2 px (chips), `-sm` 4 px (buttons, inputs), `-md` 6 px (cards, panels), `-lg` 8 px (dialogs, popovers) |
248
+ | Motion (*new*) | `--theme-motion-fast` 120 ms, `--theme-motion-base` 200 ms, `--theme-motion-easing`; both durations become `0ms` under `prefers-reduced-motion: reduce` |
249
+
250
+ Legacy aliases (AB#5526 phase 2): only `--lcars-font-primary` →
251
+ `--theme-font-display`, `--lcars-font-mono` → `--theme-font-mono` and
252
+ `--lcars-transition-fast` → `--theme-motion-fast` + easing remain, kept for the
253
+ Meshmakers App until it migrates. All other `--lcars-*` variables (glows,
254
+ button bases, input focus, radii, panel tokens, `--lcars-transition-normal`)
255
+ were deleted — use the `--theme-*` tokens.
256
+
257
+ Body text: `octo.styles()` sets `body { font-family: var(--theme-font-ui) }`
258
+ (Roboto) since AB#5526; `h1`–`h3`, page and dialog titles, `mm-page` titles and
259
+ KPI numbers use `--theme-font-display` (Montserrat 600).
260
+
261
+ ### Component styling in `octo.styles()` (Deep Sea, AB#5526)
262
+
263
+ - **Buttons are flat**: primary = `--theme-accent` with `--theme-text-on-accent`
264
+ (hover `--theme-accent-hover`), base = `--theme-bg-elevated` with a
265
+ `--theme-border-default` hairline (hover `--theme-bg-hover` +
266
+ `--theme-border-strong`), outline/flat variants transparent; error =
267
+ `--theme-status-error`. Sentence case, `--theme-font-ui`, `--theme-radius-sm`,
268
+ keyboard focus `--theme-focus-ring`. Both themes use the same rules, so the
269
+ former light-only button overrides are no-ops.
270
+ - **No decoration**: no gradients (sliders included), glows, pulses, scanlines
271
+ or text-shadows; grid headers, tabs (Kendo and dockview), process designer
272
+ palette/inspector headers, dialog titles and context menus are sentence case
273
+ in `--theme-font-ui` (the only uppercase left is the avatar initials); popups,
274
+ dialogs and menus use `--theme-bg-overlay` + `--theme-border-default` +
275
+ `--theme-shadow-popup`; the drawer is flat `--theme-bg-elevated`.
276
+ - **`theme-overrides()`**: `light-theme-surface-overrides` was reduced to what
277
+ the tokens cannot express (the old light card, `#d7ebe5` table band, drawer
278
+ and app-bar treatments are gone — both themes agree now). It also emits a
279
+ sentence-case rule (`html:root .k-label, kendo-label, .section-title`) that
280
+ outranks host component styles in both themes.
281
+ - **Legacy LCARS page classes — kept only for the Meshmakers App** (AB#5526
282
+ phase 2; the Refinery Studio no longer uses them): `.lcars-page-header`,
283
+ `.page-title` / `.title-prefix` / `.title-main`, `.header-content` and
284
+ `.lcars-content-panel` (surface card, with its `.k-grid` rule) keep a neutral
285
+ Deep Sea definition; `.lcars-header-accent`, `.lcars-header-line`,
286
+ `.panel-accent-top/-bottom`, `.lcars-footer` and `.footer-*` render nothing
287
+ (`display: none`). Everything else was deleted: `.header-stats`,
288
+ `.stat-badge`, `.lcars-panel`, `.lcars-panel-asymmetric`, `.lcars-header-bar`,
289
+ `.lcars-divider`, `.lcars-text-*`, `.lcars-bg-*`, `.lcars-border-mint`, the
290
+ glow/scanline/pulse utilities, and the `_lcars-button.scss`,
291
+ `_lcars-flat-btn.scss` and `_lcars-input.scss` mixin files (now the internal
292
+ `_button.scss` / `octo-button`, `_flat-button.scss` / `flat-button` and
293
+ `_field-input.scss` / `field-input`). New pages use `<mm-page>`.
294
+
295
+ Deviations from concept §6.2 (contrast-driven): light `--theme-text-accent` /
296
+ `--theme-accent` `#2c7d6d` (concept `#2e8473`, 4.16:1 on the light canvas →
297
+ 4.55:1), light `--theme-accent-hover` `#24685a` (concept `#266f61`),
298
+ `--theme-text-muted` dark `#77869c` (concept `#687890`) and light `#657189`
299
+ (concept `#75829a`), both ≥ 4.5:1 on `--theme-bg-input`. The Kendo bridge points `--kendo-color-primary`,
300
+ `--kendo-color-surface(-alt)`, `--kendo-color-border`, `--kendo-color-base*`,
301
+ `--kendo-border-radius-*` and `--kendo-font-family` at these tokens.
302
+
303
+ Fonts are not bundled: the host loads Montserrat, Roboto and Roboto Mono
304
+ (the Refinery Studio does so via Google Fonts in `index.html`).
305
+
306
+ ## Secondary Entry Points
307
+
308
+ Heavy admin editors live in their own entry points so apps that do not use them keep the
309
+ primary bundle small. Each one imports only the public API of `@meshmakers/octo-ui`.
310
+
311
+ | Entry point | Contents |
312
+ |-------------|----------|
313
+ | `@meshmakers/octo-ui/branding` | Branding services, `provideOctoBranding`, theme switcher |
314
+ | `@meshmakers/octo-ui/branding-settings` | Branding settings page (`BRANDING_ROUTES`) |
315
+ | `@meshmakers/octo-ui/tree-navigation-settings` | Editor for `System.UI/TreeNavigationConfiguration` |
316
+ | `@meshmakers/octo-ui/entity-forms` | Form-driven entity list / create / edit pages (`<mm-entity-page>`, `<mm-entity-list>`, `<mm-entity-form>`, `entityFormRoutes()`), driven by `System.UI/EntityForm` — see [`entity-forms/README.md`](./entity-forms/README.md) |
317
+
210
318
  ## Build
211
319
 
212
320
  ```bash
@@ -0,0 +1,462 @@
1
+ # @meshmakers/octo-ui/entity-forms
2
+
3
+ Form-driven list, create and edit pages for OctoMesh runtime entities (AB#5522).
4
+
5
+ The layout of a page — sections, field order, labels, help texts, editors, visibility rules,
6
+ list columns, capabilities — comes from the tenant's `System.UI/EntityForm` entities
7
+ (System.UI ≥ 2.8.0, seeded by the `System.UI.EntityForms` blueprint). Types without a form, and
8
+ tenants without System.UI, fall back to a built-in copy of the seeded `form-default`, so every
9
+ CK type gets a usable page.
10
+
11
+ This is a **secondary entry point**: it imports only the public API of `@meshmakers/octo-ui`
12
+ and other packages, and hosts that do not use it keep the primary bundle unchanged.
13
+
14
+ ## Building blocks
15
+
16
+ | Export | Purpose |
17
+ |--------|---------|
18
+ | `<mm-entity-page>` (`EntityPageComponent`) | Route component: list, create, edit and singleton flows, save, delete, unsaved-changes guard, breadcrumbs |
19
+ | `entityFormRoutes(opts?)` | The three child routes `''` / `new` / `:rtId` of a page |
20
+ | `<mm-entity-list>` (`EntityListComponent`) | `mm-list-view` of a resolved form (columns, Copy ID, delete, "New" incl. subtype picker) |
21
+ | `<mm-entity-form>` (`EntityFormComponent`) | The form itself (sections, editors, validation, change set) |
22
+ | `EntityFormService` | Loads forms + CK metadata (cached per tenant) and resolves the form for a type or form key |
23
+ | `EntityFormDataService` | Reads values / secret state / associations; create, update (incl. `clearSecretAttributes`), delete |
24
+ | `parseEntityForms`, `pickEntityForm`, `resolveEntityForm`, … | Pure functions behind the service, e.g. for a forms editor |
25
+ | `entityFormCatalog(forms)`, `entityFormKey(form)` | Settings overview: one entry per target type whose effective form has a `Category`, with the URL key |
26
+ | `EntityFormsMessages`, `DEFAULT_ENTITY_FORMS_MESSAGES` | All UI strings (English defaults; pass `Partial<…>` via `messages`) |
27
+ | `ENTITY_FORM_FALLBACK_FORMS`, `provideEntityFormFallbacks`, `selectFallbackForms` | Host-provided built-in forms that apply per type where the resolution would end at `form-default` (see below) |
28
+ | `ENTITY_FORM_ACTION_CONFIRMATION` | Optional host hook asked before a delete (e.g. a production-mode check) |
29
+ | `ENTITY_FORM_DANGER_CONFIRMATION`, `entityDeleteConfirmation` | Optional host replacement of the delete danger confirmation (AB#5579) |
30
+
31
+ ## Usage
32
+
33
+ ### Routes (recommended)
34
+
35
+ ```ts
36
+ import { entityFormRoutes } from '@meshmakers/octo-ui/entity-forms';
37
+
38
+ export const routes: Routes = [
39
+ {
40
+ path: 'sftp',
41
+ children: entityFormRoutes({
42
+ formKey: 'sftp-configuration', // or ckTypeId: 'System.Communication/SftpConfiguration'
43
+ breadcrumbUrl: 'communication/sftp', // optional; adds {{entityFormTitle}} / {{entityName}} crumbs
44
+ canWrite: true, // optional; default true
45
+ }),
46
+ },
47
+ ];
48
+ ```
49
+
50
+ The host must provide what the library services expect:
51
+
52
+ - `provideOctoUi()` — includes `provideMmSharedUi()` (`ConfirmationService`,
53
+ `NotificationDisplayService`, `EntitySelectDialogService` for the reference picker) and
54
+ `CkTypeSelectorDialogService` (subtype picker). Hosts that do not use `provideOctoUi()` must
55
+ call `provideMmSharedUi()` themselves.
56
+ - A `<div kendoDialogContainer></div>` (record row dialog, confirmations) and
57
+ `<div kendoWindowContainer></div>` (CK type selector) in the app shell.
58
+ - Apollo for the tenant; optionally `TENANT_ID_PROVIDER` (cache scope per tenant) and
59
+ `BreadCrumbService` (breadcrumb labels; skipped when absent).
60
+
61
+ ### Route-parameter contract (stable; used by the Refinery Studio, AB#5523)
62
+
63
+ | URL (relative to the mount point) | Page state |
64
+ |-----------------------------------|------------|
65
+ | `''` | List. A **singleton** form skips the list and opens its entity (see below). |
66
+ | `new` | Create form. Route data `rtId: 'new'`. |
67
+ | `new?type=<rtCkTypeId>` | Create form for a concrete subtype (required for abstract types; set by the list's subtype picker). |
68
+ | `:rtId` | Edit form; read-only view when `canWrite` is false or the form has `CanEdit: false`. |
69
+ | `:rtId?type=<rtCkTypeId>` | Edit form for an entity of a derived type (set when the list opens such a row). Without it the page detects the type after loading and re-resolves. |
70
+
71
+ Optional `entityFormRoutes` options for the page heading and list: `title` (route data
72
+ `entityListTitle`) replaces the resolved form title as the list heading and breadcrumb label
73
+ (e.g. "All configurations" over `System/Configuration`); `showTypeColumn` (route data
74
+ `entityListTypeColumn`) adds a "Type" column with the display name of each row's CK type: the
75
+ `Name` of the type's entity form (`entityFormTypeTitles` / `ckTypeDisplayName`), else
76
+ `humanizeCkTypeName` — sentence case with known acronyms and brands (`EMailReceiverConfiguration`
77
+ → "E-mail receiver configuration", `FinApiConfiguration` → "finAPI configuration",
78
+ `SftpConfiguration` → "SFTP configuration"; AB#5524). A
79
+ **singleton** form is titled with its form name (e.g. "Tenant mode"), never "Edit <entity name>".
80
+
81
+ Page inputs (bound by `withComponentInputBinding()` from route params / data, otherwise read from
82
+ `ActivatedRoute` — params, route data of the route and its ancestors, query params):
83
+
84
+ | Input | Source fallback | Meaning |
85
+ |-------|-----------------|---------|
86
+ | `formKey` | `data.formKey`, then `params.formKey` | `form-sftp-configuration` (rtWellKnownName) or the kebab type key `sftp-configuration`. Wins over `ckTypeId`. |
87
+ | `ckTypeId` | `data.ckTypeId`, then `params.ckTypeId` | Runtime CK type id of the target type. |
88
+ | `rtId` | `params.rtId`, `data.rtId` | Absent = list; `'new'` = create; otherwise edit. |
89
+ | `canWrite` | `data.canWrite`, then `true` | Write permission; the host maps roles to it (the library knows no role names). |
90
+ | `messages` | `data.messages` | `Partial<EntityFormsMessages>`. |
91
+ | `routerNavigation` | — | Default `true`. Set `false` and handle the `navigate` output to drive navigation yourself. |
92
+
93
+ The query parameter is deliberately named `type`, not `ckTypeId`, so input binding never
94
+ overwrites the page's `ckTypeId` (the form's target type).
95
+
96
+ Navigation is always **relative** (`..`, `new`, `:rtId`, with `replaceUrl` after a create), so
97
+ the routes work under any mount point. The `navigate` output (`{ kind: 'list'|'create'|'edit', rtId?, ckTypeId? }`)
98
+ is emitted for every transition.
99
+
100
+ Two more outputs let a host that embeds the page (e.g. the Studio's Data Explorer peek) keep its
101
+ own lists current:
102
+
103
+ | Output | Payload | When |
104
+ |--------|---------|------|
105
+ | `saved` | `{ kind: 'create' \| 'update', rtId, ckTypeId }` | After a successful create or update (not for an empty change set) |
106
+ | `deleted` | `{ rtId, ckTypeId }` | After the open entity was deleted from the form, before the navigation to the list |
107
+
108
+ Breadcrumb labels set via `BreadCrumbService.updateBreadcrumbLabels`: `entityFormTitle` (the
109
+ resolved form title) and `entityName` (entity `name`, else `rtWellKnownName`, else `rtId`; the
110
+ create title on `new`).
111
+
112
+ ### Singleton forms
113
+
114
+ `Singleton: true` skips the list:
115
+
116
+ 1. With `SingletonWellKnownName` the entity is loaded by that well-known name.
117
+ 2. Without it, the first entity of the type is used.
118
+ 3. If none exists, the create form opens (with write permission) and the well-known name is
119
+ written on first save; afterwards the page stays on the same URL in edit mode.
120
+
121
+ ### Saving
122
+
123
+ `saveChanges(): Promise<boolean>` (also called by `UnsavedChangesGuard` on "Yes"):
124
+
125
+ - invalid form → all fields touched, warning, `false`;
126
+ - create → `EntityFormDataService.create`, then navigation to `:rtId` (replacing `new`);
127
+ - edit → only the changed attributes are sent (empty change set = "no changes"), then the
128
+ values are read again.
129
+ - a host `beforeSave` hook (see below) runs after validation and before the server call; a
130
+ veto shows a warning (the hook's message or `saveVetoed`), an error shows `saveError`, and
131
+ nothing is saved in either case.
132
+
133
+ ### Before-save hook (`beforeSave`, AB#5623)
134
+
135
+ Normalise or derive values, or stop the save, right before it happens. Sync or async
136
+ (`Promise` / `Observable`, first value counts):
137
+
138
+ ```ts
139
+ const normaliseContact: EntityFormBeforeSaveHook = (changeSet) => {
140
+ for (const a of changeSet.attributes) {
141
+ if (typeof a.value === 'string') a.value = a.value.trim();
142
+ if (a.attributeName === 'iban' && typeof a.value === 'string') a.value = a.value.replace(/\s+/g, '').toUpperCase();
143
+ }
144
+ const email = changeSet.attributes.find((a) => a.attributeName === 'email');
145
+ if (email) {
146
+ if (typeof email.value === 'string' && email.value && !email.value.includes('@')) {
147
+ throw new EntityFormSaveVeto(translate('CONTACT.INVALID_EMAIL'));
148
+ }
149
+ changeSet.attributes.push({ attributeName: 'normalizedEmail', value: String(email.value ?? '').toLowerCase() });
150
+ }
151
+ // returning nothing saves the (mutated) change set; return a new change set to replace it,
152
+ // or null / false to veto silently
153
+ };
154
+ ```
155
+
156
+ - Where: `mm-entity-page [beforeSave]` > route option `beforeSave` (data `entityFormBeforeSave`)
157
+ > `ENTITY_FORM_BEFORE_SAVE`. `mm-entity-form [beforeSave]` (or the token) applies through
158
+ `getChangeSetForSave({ rtId })` for hosts that save themselves; the form never notifies.
159
+ - The hook gets a copy of the change set plus `{ mode, ckTypeId, rtId?, form }`. Attribute names
160
+ are camelCase; edit mode holds only the changed attributes, so derive from a source attribute
161
+ only when it is in the change set. An edit without changes skips the hook.
162
+ - **Secrets:** nothing beyond the change set reaches the hook. A SECRET attribute appears only as
163
+ the write value the user just typed (or in `clearSecretAttributes`), never as a stored value —
164
+ the form never reads stored secrets. Do not log the change set.
165
+
166
+ ### Embedding without routes
167
+
168
+ ```html
169
+ <mm-entity-list [model]="model" [canWrite]="canWrite" (openRequested)="open($event)" (createRequested)="create($event)" />
170
+ <mm-entity-form [model]="model" mode="edit" [state]="state" (dirtyChange)="dirty = $event" />
171
+ ```
172
+
173
+ Resolve `model` with `EntityFormService.resolve(rtCkTypeId)` / `resolveByFormKey(key)` and the
174
+ state with `EntityFormDataService.load(model, { rtId })`.
175
+
176
+ ## Settings overview (catalog)
177
+
178
+ `entityFormCatalog(await formService.getForms())` returns one entry per target type whose
179
+ **effective** form (tenant form before seeded, then higher `Priority`) has a `Category`:
180
+ `key`, `category` (lower case), `title` (form `Name`, else the humanized type name),
181
+ `description`, `icon`, `targetCkTypeId`, `includeDerivedTypes`, `singleton`.
182
+ `form-default` has no category and never appears. A tenant form replaces the delivered entry
183
+ (and can move it to another category, or hide it by leaving `Category` empty).
184
+
185
+ `entityFormKey(form)` is the URL key: the well-known name without `form-`
186
+ (`form-email-sender-configuration` → `email-sender-configuration`), otherwise the kebab type
187
+ name. The catalog prefers a delivered `form-*` name of the type, so URLs survive a tenant
188
+ override. `resolveByFormKey` accepts both forms.
189
+
190
+ `EntityFormDataService.count(ckTypeId, includeDerivedTypes?)` counts entities without reading
191
+ attributes (`attributeNames: []`); without `includeDerivedTypes` only the exact type is counted.
192
+
193
+ ## Fallback forms (`ENTITY_FORM_FALLBACK_FORMS` / `provideEntityFormFallbacks`)
194
+
195
+ A host can ship built-in copies of delivered forms, for example while a new version of the
196
+ seeding blueprint has not been rolled out yet (AB#5524). Two ways to provide them:
197
+
198
+ ```ts
199
+ // root (pulls the entry point into the initial bundle):
200
+ providers: [{ provide: ENTITY_FORM_FALLBACK_FORMS, useValue: MY_FALLBACK_FORMS }]
201
+ // or on every lazy route that renders entity forms (registers them in the root service, one cache):
202
+ { path: 'settings', providers: [provideEntityFormFallbacks(MY_FALLBACK_FORMS)], loadChildren: … }
203
+ ```
204
+
205
+ - `EntityFormService.getForms()` appends a fallback **only where the normal resolution would end
206
+ at the chain end** — no form at all, or `form-default` (a form on `System/Entity`). A tenant or
207
+ seeded form for the exact type, or for an ancestor with `IncludeDerivedTypes`, always wins
208
+ (`selectFallbackForms`). Fallbacks for types the tenant does not have (no CK metadata) are
209
+ dropped. The first fallback per type wins. The result feeds `resolve`, `resolveByFormKey` and
210
+ `entityFormCatalog` unchanged.
211
+ - Fallbacks are also used when the forms cannot be loaded (no System.UI ≥ 2.8.0, query error).
212
+ - `registerFallbackForms(forms)` (behind `provideEntityFormFallbacks`) is idempotent per array and
213
+ drops the cached forms and resolutions once.
214
+ - They count as delivered forms (`isTenantForm` is forced to `false`, `source: 'seeded'`). Use the
215
+ delivered `rtWellKnownName` (`form-<kebab-type>`) so URL keys do not change when the seeded form
216
+ arrives, and leave `rtId` empty.
217
+ - A tenant form for the type without `Category` hides the entry, exactly like it hides a seeded one.
218
+
219
+ This is the per-type counterpart of the built-in `form-default` safety net; the host is
220
+ responsible for keeping its copies in step with the seed.
221
+
222
+ ## Action confirmation (`ENTITY_FORM_ACTION_CONFIRMATION`)
223
+
224
+ Optional `(request: { action: 'delete', ckTypeId, count, description }) => Promise<boolean>`,
225
+ asked by `mm-entity-list` and `mm-entity-page` **before** their own danger confirmation; `false` (or a
226
+ throwing hook) cancels. The Refinery Studio maps it to its production-mode confirmation.
227
+
228
+ ## Delete confirmation (`ENTITY_FORM_DANGER_CONFIRMATION`, AB#5579)
229
+
230
+ Deletes ask `ConfirmationService.showDangerConfirm` (shared-ui action guideline): the title names the
231
+ entity ("Delete Primary?") or the count ("Delete 3 entities?"), the confirming button says
232
+ "Delete entity" / "Delete entities", Cancel keeps the initial focus. Texts: the optional
233
+ `confirmDelete*` keys of `EntityFormsMessages` (the former `confirmDeleteTitle` / `confirmDeleteMessage` /
234
+ `confirmDeleteManyMessage` are no longer shown). A host that adds environment knowledge provides
235
+ `ENTITY_FORM_DANGER_CONFIRMATION: (options: DangerConfirmationOptions, request) => Promise<boolean>`
236
+ instead of the built-in dialog — e.g. the Studio's `DangerConfirmService.confirm` (production = type the
237
+ name); it then no longer needs the production check in `ENTITY_FORM_ACTION_CONFIRMATION`.
238
+
239
+ ## `<mm-entity-list>`
240
+
241
+ - Inputs: `model` (required), `canWrite = true`, `messages`, `listStateKey`.
242
+ - Outputs: `createRequested { ckTypeId }`, `openRequested { rtId, ckTypeId }`, `deleted { rtId, ckTypeId }[]`.
243
+ - Rows are flattened (`rtId`, `ckTypeId`, `rtWellKnownName`, `rtDisplayName`,
244
+ `rtCreationDateTime`, `rtChangedDateTime`, `<attributeName>: value`), so a column `field`
245
+ equals the GraphQL attribute path and server-side sort / filter / search work.
246
+ - Derived types are listed when the form has `IncludeDerivedTypes` or the type is abstract;
247
+ otherwise a `ckTypeId EQUALS` field filter restricts the list to the exact type
248
+ (`runtimeEntities(ckId)` returns derived types by default).
249
+ - Column display: `chip` → badge, `date` → localized date, `mono` → monospace cell, else text.
250
+ Cells are formatted by the CK value type of the attribute like the reference display (AB#5547):
251
+ ENUM → the enum value's name (the API returns the key, e.g. `0`), BOOLEAN → yes / no
252
+ (`toggleOn` / `toggleOff` messages); a `chip` column maps the keys via `badgeMapping`.
253
+ - Context menu: **Copy ID** (RtId / CkTypeId / RtCkTypeId / RtEntityId), then — only with
254
+ `canWrite && CanDelete` — Delete with a confirmation. Toolbar "New" only with
255
+ `canWrite && CanCreate`.
256
+ - **Abstract types** (`createRequiresSubtype`): "New" opens `CkTypeSelectorDialogService`
257
+ restricted to concrete subtypes (`derivedFromRtCkTypeId`, `allowAbstract: false`);
258
+ cancelling emits nothing.
259
+
260
+ ## Host extensions (AB#5623)
261
+
262
+ All optional; without them the components behave as before.
263
+
264
+ | Need | API |
265
+ |---|---|
266
+ | Save / Cancel below the form | `mm-entity-page [actionBarPosition]="'bottom'"` (route option `actionBarPosition`, data `entityPageActionBarPosition`) |
267
+ | Extra page actions (Export, Prefill, ...) | `<ng-template mmEntityPageActions let-ctx>` inside `mm-entity-page` — rendered in the list header and in the form's action bar; `ctx` = `EntityPageActionsContext` (`view`, `mode`, `rtId`, `ckTypeId`, `model`, `saving`, `page`) |
268
+ | Extra list actions | `mm-entity-list` `toolbarActions` / `rowActions` (icon buttons) / `rowMenuActions` (context menu); on the page `listToolbarActions` / `listRowActions` / `listRowMenuActions`. Plain `CommandItem`s; `onClick` gets the row as `e.data` |
269
+ | Default list order | `listDefaultSort` in a host fallback form, or `defaultSort` on list / page (route option `defaultSort`). A user sort (also a remembered one) wins; the header shows no marker for the default order |
270
+ | Translated labels / enum texts | `ENTITY_FORM_LABEL_RESOLVER` or the `labelResolver` input: `(request) => string \| null`; `request.kind` = `field`, `help`, `placeholder`, `section`, `sectionDescription`, `formTitle`, `formDescription`, `listColumn`, `recordColumn`, `enumOption`. Read a signal (language) inside to re-render on change; controls are not rebuilt |
271
+ | Fixed texts incl. Copy ID | `messages` (`copyId`, `copyIdTooltip`, `copiedId`, `copyFailed`, ...); `mm-entity-id-info` has `buttonText` / `tooltip` / `copiedMessage` / `copyFailedMessage` |
272
+ | Prefill a create form | `mm-entity-form [initialValues]` / `mm-entity-page [initialValues]` (object or `({ ckTypeId }) => values`; route option `initialValues`), or `entityFormPrefillState(values)` as `state` |
273
+ | Normalise / veto before saving | `beforeSave` on `mm-entity-page` / `mm-entity-form`, route option `beforeSave`, or `ENTITY_FORM_BEFORE_SAVE` — see [Before-save hook](#before-save-hook-beforesave-ab5623) |
274
+ | Set values from a host action | `EntityFormComponent.patchValues(values)` / `EntityPageComponent.patchFormValues(values)` — values count as edits (dirty, saved); secrets and read-only fields are skipped |
275
+ | Placeholder values of legacy STRING secrets | `ENTITY_FORM_SECRET_PLACEHOLDER_VALUES` (exact values that read "Not set"). SECRET-typed attributes (AB#5528) need nothing: their state comes from the server |
276
+ | Create / edit in a dialog over the list | `mm-entity-page [editMode]="'dialog'"` (route option `editMode`, data `entityPageEditMode`) — see [Dialog mode](#dialog-mode-editmode-dialog-ab5623) |
277
+ | CSS classes per list row (e.g. disabled rows) | `mm-entity-list [rowClass]` / `mm-entity-page [listRowClass]` (route option `rowClass`, data `entityListRowClass`) or `ENTITY_LIST_ROW_CLASS`: `(row) => string \| string[] \| Record<string, boolean> \| null`. Classes land on the grid's `<tr>`: style them globally (or `::ng-deep`) |
278
+ | Human row label (row action names, dialog / page title, delete confirmations) | `mm-entity-list [rowLabelField]` / `mm-entity-page [listRowLabelField]` (route option `rowLabelField`, data `entityListRowLabelField`); dotted paths allowed. Absent = `name`, well-known name, display name, rtId (AB#5623) |
279
+ | Boolean columns as icons | `mm-entity-list [booleanDisplay]="'icon'"` / `mm-entity-page [listBooleanDisplay]` (route option `booleanDisplay`, data `entityListBooleanDisplay`) for every plain BOOLEAN column, or `display: icon` on one list column of the form — check / x icon named "<column>: Yes\|No" (`messages.toggleOn` / `toggleOff`), boolean row filter (AB#5623) |
280
+ | Placeholder values of NON-secret config fields | `ENTITY_FORM_UNSET_PLACEHOLDER_VALUES` — see [Unset placeholders](#unset-placeholders-entity_form_unset_placeholder_values-ab5623) |
281
+
282
+ ### Dialog mode (`editMode: 'dialog'`, AB#5623)
283
+
284
+ "New", row click and Edit / View open the form in a Kendo dialog over the list; the URL does not
285
+ change and no `navigate` events are emitted.
286
+
287
+ - Same form behaviour as the page: `beforeSave`, `initialValues`, `labelResolver`, secrets,
288
+ `patchFormValues`, the record row dialog. Host page actions (`mmEntityPageActions`) render in the
289
+ dialog with `ctx.view === 'form'`.
290
+ - Bottom bar (`kendo-dialog-actions`, right-aligned like the record row dialog): page actions,
291
+ Cancel (Close when read-only), Delete (edit, with permission), Save (primary, right-most).
292
+ - Cancel, the title-bar close button and Escape ask before discarding unsaved changes
293
+ (`unsavedChangesTitle` / `unsavedChangesMessage` / `discardChanges` / `keepEditing`). A route
294
+ change while the dialog has changes goes through `UnsavedChangesGuard` (save / discard / stay).
295
+ - After a successful save (also "no changes") or delete the dialog closes; after a write the list
296
+ reloads (`saved` / `deleted` are emitted as on the page). Focus returns to the element that opened
297
+ the dialog (the list when that element is gone).
298
+ - A load error or a missing entity shows an error notification; the list stays.
299
+ - The `new` / `:rtId` routes keep working as pages (deep links); singleton forms always use the page.
300
+
301
+ ### Unset placeholders (`ENTITY_FORM_UNSET_PLACEHOLDER_VALUES`, AB#5623)
302
+
303
+ Seeded placeholder values of non-secret configuration attributes (e.g. `TODO_SET_AZURE_TENANT_ID`)
304
+ read as "not configured":
305
+
306
+ ```ts
307
+ providers: [{
308
+ provide: ENTITY_FORM_UNSET_PLACEHOLDER_VALUES,
309
+ // a plain list applies to every attribute; or per attribute (any casing) and/or global:
310
+ useValue: { attributes: { azureTenantId: ['TODO_SET_AZURE_TENANT_ID'], clientId: ['TODO_SET_CLIENT_ID'] } },
311
+ }]
312
+ ```
313
+
314
+ - Exact, case-sensitive match on string values.
315
+ - List: the cell shows `messages.notConfigured` ("Not configured"; text, mono and chip columns).
316
+ - Form: the field starts empty with `notConfigured` as placeholder hint; the value counts as unset
317
+ (a required field is invalid, `VisibleWhen` sees no value).
318
+ - Save: the form never writes a placeholder back. Untouched = not in the change set (the stored
319
+ placeholder stays and keeps reading "not configured"); a typed value replaces it; emptying the
320
+ field again is "no change". To remove the placeholder from the store, write `null` yourself
321
+ (e.g. in `beforeSave`). In create mode a placeholder default / prefill is left out of the create.
322
+ - Never applies to secrets (SECRET decision AB#5528: secret placeholders are dropped; secrets use
323
+ `secretIsSet`): fields with `secret` (value type `SECRET`, CK metadata, credential-name rule, form
324
+ decision) and attributes in `secretFields` are skipped even when listed. Legacy STRING secrets
325
+ keep using `ENTITY_FORM_SECRET_PLACEHOLDER_VALUES`.
326
+
327
+ ## Secrets (write-only)
328
+
329
+ Secret values never reach the browser (AB#5522 D5, AB#5542, AB#5544 item 4, decisions 2026-10-06).
330
+
331
+ **Which fields are secret**
332
+
333
+ - The **SECRET value type** (AB#5528) is always secret and maps automatically to a write-only
334
+ field — no form definition needed; `Secret: false` is ignored with a warning. Compatible
335
+ editors: `password` (default) and `multiline` (PEM keys, `EntityFormField.Editor: multiline`);
336
+ any other editor falls back to `password` with a warning.
337
+ - Fallback for attributes that are not SECRET yet (older models): the form says `Secret: true`,
338
+ **or its editor is `password`** (always write-only), or the CK attribute carries the metadata
339
+ `secret=true`, or a textual attribute has a credential-like name (shared rule
340
+ `isSecretAttributeCandidate` of `@meshmakers/octo-services`).
341
+
342
+ **Reading**
343
+
344
+ - Value reads, the list and the reference picker use documents whose `$attributeNames` is
345
+ declared `[String]!` and always pass explicit names. Never add a document that selects
346
+ `attributes` without that argument — **omitting it makes the server return every attribute,
347
+ secrets included.**
348
+ - SECRET fields (`ResolvedEntityForm.secretStateFields`) are part of that list: the server
349
+ returns `value: null` plus `secretIsSet`, giving `EntityFormValueState.secretStates`
350
+ (`isSet`, `keyMissing`, `setAt`) from `secretIsSet` / `secretKeyMissing` / `secretSetAt`
351
+ (selected since the SECRET round-2 schema).
352
+ - Fallback secrets are never listed; whether they are set is read with an `IS_NOT_NULL` field
353
+ filter — plus `NOT_EQUALS ""` for STRING secrets — and `totalCount`. (Never use that probe on a
354
+ SECRET: the server refuses every filter but `IS_NULL` / `IS_NOT_NULL` there.)
355
+
356
+ **UI** (field shell badge + `mm-entity-form-secret-editor`)
357
+
358
+ - Badge next to the label, visible to read-only users too (Q15): **Set · set at …** (or **Set**
359
+ for legacy values without a timestamp), **Not set**, **Key missing — re-enter** (a value is
360
+ stored but its key is not in this environment's key ring; it reads as not set for consumers but
361
+ counts as present for "required"), **Will be cleared** (staged clear). Create mode shows
362
+ "Secret".
363
+ - The input is never prefilled; placeholder "Leave empty to keep" when a value is stored.
364
+ Read-only users get no input, no "Show" and no "Clear".
365
+ - **Show** reveals only the value typed in this session, never a stored one; it is disabled while
366
+ the input is empty (Q10).
367
+ - **Multiline** (PEM keys): the text area renders its text transparent until **Show** (caret,
368
+ selection and placeholder stay visible) and a status line reports only the number of lines
369
+ entered. This works in every browser — Firefox has no reliable `-webkit-text-security`, and a
370
+ password input would drop the PEM line breaks.
371
+ - **Clear** (optional SECRET fields with a stored value, edit mode, write access) is staged and
372
+ sent on Save as `clearSecretAttributes` (Q8). Clear and a new value are mutually exclusive:
373
+ Clear is disabled while a value is typed; a staged clear replaces the input by a note with
374
+ **Undo**. Required secrets cannot be cleared; fallback secrets cannot be cleared either (the
375
+ server accepts `clearSecretAttributes` only for SECRET attributes).
376
+ - **No key ring** (Q17): when the host provides `ENTITY_FORM_SECRET_KEY_RING_CONFIGURED`
377
+ (a `Signal<boolean | null | undefined>`) and it is `false`, secret inputs are disabled with a
378
+ hint. Only **SECRET-typed** fields (and SECRET record members) are gated — fallback secrets
379
+ (name rule / metadata) are plain values server-side and stay writable. Without a provider (or
380
+ while the signal is `null` / `undefined`) secrets are writable.
381
+ The signal may change after the form was built (status loaded asynchronously): the controls
382
+ follow it. In **create** mode a visible, required secret that cannot be entered blocks the form:
383
+ the field stays required (marker + hint), `isValid()` is `false` and the public signal
384
+ `saveBlockedReason()` names the fields — `mm-entity-page` disables Save with that text as
385
+ tooltip and the form shows it as a notice; other hosts should do the same. Edit mode is not
386
+ blocked (the server enforces required secrets only on create). The Studio provides the token
387
+ app-wide from the bot status endpoint `GET {tenantId}/v1/secrets/status`
388
+ (`SecretEnvironmentStatusService`, fail-open).
389
+
390
+ **Writing**
391
+
392
+ - An empty secret is left out of the change set (unchanged). A required secret is required
393
+ only on create or while it is not present (set or key missing).
394
+ - Secret list columns are dropped. The update mutation selects no attributes (no echo).
395
+ - Record members of value type SECRET: the generic read returns `value: null` + `secretIsSet` per
396
+ member; the form keeps that state (never a value). The records grid and the row editor show the
397
+ shared status badge (**Set** / **Not set** / **Key missing — re-enter**); the row editor offers
398
+ an empty password input (read-only users: badge only). A typed value replaces the member
399
+ (grid: "New value (unsaved)"); an empty input keeps it — on save the member is **omitted** and
400
+ the server carries the stored value over from the element with the same record key (handover
401
+ §2). Changing an element's record key therefore drops its stored secret. A state object is
402
+ never sent back. Without a key ring the member input is disabled with the hint (badge stays).
403
+ - The CK description of `EntityFormField.Secret` says "masked … revealed on demand"; the concept
404
+ (§5.8, write-only) wins.
405
+
406
+ ## Editors (MVP)
407
+
408
+ | Editor | Implementation |
409
+ |--------|----------------|
410
+ | text, multiline, email, url, password, number, toggle, enum, datetime | Kendo inputs; email / url validators by editor; `Min`/`Max` on numbers, `Pattern` on text |
411
+ | chips | Array editor for `STRING_ARRAY` / `INT_ARRAY` |
412
+ | cron | shared-ui `mm-cron-builder` |
413
+ | **json / yaml** | Monospace `kendo-textarea` (no Monaco / YAML library in the workspace). `json` validates with `JSON.parse`, `yaml` is not validated. Both are stored as STRING. The Studio can swap in Monaco later via `CustomComponent`. |
414
+ | **reference** | shared-ui `mm-entity-select-input` (typeahead plus its **grid dialog**, multi-select for `N` roles) on a secret-safe data source that selects only the target's non-secret `name` attribute (literal `attributeNames: ["name"]`). Labels (picker and current value): a real `rtDisplayName` > `name` > `rtWellKnownName` > the synthetic `<type>@<rtId>` (so a pool shows "Default Cloud", not its well-known name `CommunicationPool`). Deviates from concept §5.4, which names `mm-entity-selector-dialog` — that one is a perspective tree picker without type filter or multi-select, and configuration types are not in a tree. A form field's `referenceDisplayAttributes` (CK `EntityFormField.ReferenceDisplayAttributes`, System.UI ≥ 2.9.0, read through the generic EntityForm query — no typed schema field, so older models simply lack it; host fallback forms may set it too; e.g. `['repositoryUrl', 'channel']`): the picker then reads exactly those non-secret target attributes with `entityFormGetReferenceOptionsWithAttributes` (explicit `[String]!` `attributeNames`) and shows `name · value · value` — in the picker rows and for the current value (looked up by rtId; the field value keeps its plain name). Values are formatted by the target's CK value type (`EntityFormService.resolve` attaches `reference.displayAttributeInfo`): ENUM key → enum name (e.g. channel `0` → "Release"), BOOLEAN → the `toggleOn`/`toggleOff` labels, DATE_TIME → localized date and time, arrays comma-separated, records skipped (AB#5547; never list a secret attribute — attributes marked secret on the target are dropped, and a SECRET value is never returned anyway). |
415
+ | records | Table with add / remove / move / edit; rows are edited in a dialog generated from the record's CK attributes. Nested records are read-only. |
416
+ | unsupported (BINARY, GEOSPATIAL_POINT, TIME_SPAN, …) | Read-only display |
417
+
418
+ **Runtime state is never generated as an editable field.** Generated fields (`form-default`,
419
+ "Further attributes") whose attribute is in `RUNTIME_STATE_ATTRIBUTES` (`core/attribute-path.ts`:
420
+ deployment / communication / configuration state, last errors and their timestamps, status
421
+ message, last synced sequence number, lifecycle state, last activity, on-demand flags) are
422
+ read-only, like the engine-stamped `rtBlueprint*` attributes. It is a documented list because the
423
+ CK `ownership: RuntimeState` marker is not in the schema the library is generated from; a field a
424
+ form defines explicitly keeps its own `ReadOnly`.
425
+
426
+ ## Form resolution (summary)
427
+
428
+ 1. Forms targeting the exact type win; otherwise the nearest ancestor with forms that set
429
+ `IncludeDerivedTypes` supplies the candidates (`form-default` targets `System/Entity`).
430
+ 2. Tenant forms beat seeded forms (empty `RtBlueprintSource`), then higher `Priority`, then
431
+ `rtWellKnownName` / `rtId` ascending (deterministic tie-break).
432
+ 3. No match → built-in default form with a warning. Forms are never merged.
433
+ 4. Attribute paths match case-insensitively (forms write `Host`, CK names are `host`); unknown
434
+ paths are skipped, dotted paths are skipped with a warning; unmentioned attributes go to a
435
+ generated "Further attributes" section unless `GenerateRemainingFields: false`.
436
+
437
+ ## Known backend limits
438
+
439
+ - **CK record attributes always report `isOptional: false`.** Record sub-fields are therefore
440
+ treated as optional in the UI; the server still enforces mandatory sub-attributes and answers
441
+ `ASSET1004`.
442
+ - **The `attributeNames` filter is applied inside records too.** Reading a record attribute
443
+ returns its rows with empty `attributes` unless the record's sub-attribute names are listed as
444
+ well, so `readAttributeNames` contains them. When a sub-attribute name equals a fallback
445
+ (non-SECRET) secret top-level attribute name it is dropped and the record field becomes
446
+ read-only (warning), so a save cannot erase that sub-value. A top-level SECRET name does not block
447
+ only when every record member of that name is a SECRET too (the server redacts both). If a
448
+ NON-SECRET member shares the name, listing it would return that member in clear text: the name is
449
+ not listed, the record field becomes read-only and the SECRET falls back to the presence probe.
450
+ - The edit flow relies on a partial `RtEntityUpdate` keeping the attributes that are not sent
451
+ (unchanged values, secrets). That is what makes the write-only secret handling safe.
452
+
453
+ ## Tests
454
+
455
+ Specs live next to the sources and run with the octo-ui test target
456
+ (`../**/*.spec.ts`). `@meshmakers/octo-ui` resolves to `dist`, so build first:
457
+
458
+ ```bash
459
+ npm run build:octo-ui && npm run test:octo-ui
460
+ ```
461
+
462
+ Demo: `demo-app` → `demos/entity-forms` (SFTP configuration form).