diff --git a/content/docs/ai/skills.mdx b/content/docs/ai/skills.mdx index cee600755a4..5289decb47f 100644 --- a/content/docs/ai/skills.mdx +++ b/content/docs/ai/skills.mdx @@ -131,6 +131,15 @@ ObjectStack provides **nine domain-specific skills**, one per protocol domain The authoritative list (names, domains, and "use when / do not use" boundaries) is generated from each skill's `SKILL.md` frontmatter. See the **[AI Skills Reference](/docs/ai/skills-reference)** for the full catalog with per-skill detail. +The same package also carries **`objectui`**, the skill for the `@object-ui/*` React +packages rather than for metadata: rendering JSON schemas with `SchemaRenderer`, writing +renderer plugins, data-source integration, and testing, i18n and mobile layout in a React host. +Its source is [`skills/objectui/`](https://github.com/objectstack-ai/objectstack/tree/main/skills/objectui): +a `SKILL.md` entry point, `rules/` the agent always applies, `guides/` it loads per task, and an +`evals/` prompt per guide that catches regressions in agent behaviour. Its frontmatter defers +data modelling, APIs, automation, formulas and agents to the domain skills above, so the two +coexist in one agent. It installs and updates with the rest of `@objectstack/skills`. + Lifecycle hooks, plugin development, and project bootstrap are **not** separate skills — they live in **Data** (record hooks) and **Platform** (plugins, kernel events, `defineStack`). diff --git a/content/docs/data-modeling/field-types.mdx b/content/docs/data-modeling/field-types.mdx index 31c9d697f18..18640a450ee 100644 --- a/content/docs/data-modeling/field-types.mdx +++ b/content/docs/data-modeling/field-types.mdx @@ -11,6 +11,11 @@ ObjectStack provides a **comprehensive set of field types** covering every data **Import:** `import { FieldType, FieldSchema } from '@objectstack/spec/data'` +Each type below also says what the Console does with it: the form widget, the read-only +display and the grid cell. Behaviour shared by many types, such as the empty-value +affordance and the keys a widget receives at runtime, is described once under +[How the Console renders a field](#how-the-console-renders-a-field). + --- ## Text Types @@ -29,6 +34,13 @@ Single-line plain text input. { name: 'first_name', label: 'First Name', type: 'text', maxLength: 100 } ``` +**In the Console:** a single-line input. `placeholder` is shown inside the empty input and +`inlineHelpText` beside it. A regex check is not a field key (`FieldSchema` is strict and +refuses `pattern`); declare a [`format` validation rule](/docs/data-modeling/validation#format-validation) +on the object instead. The form does not pre-check that rule in the browser: a value that +breaks it is refused when the record is saved, with the rule's `message` as the reason. In a +grid the value is truncated to the column width. + ### `textarea` Multi-line text input for longer content. @@ -41,6 +53,10 @@ Multi-line text input for longer content. { name: 'description', label: 'Description', type: 'textarea', maxLength: 5000 } ``` +**In the Console:** a multi-line editor. `rows` sets the inline editor's height in text rows +(fullscreen and dialog editors size themselves). `maxLength` is also the editor's native stop +and drives a `{n}/{max}` character counter. In a grid long text is truncated. + ### `email` Email address with built-in format validation. @@ -52,6 +68,10 @@ Email address with built-in format validation. { name: 'email', label: 'Email', type: 'email', required: true, unique: 'organization' } ``` +**In the Console:** an `email` input. Read-only, and in a grid, the value is a `mailto:` link. +The address shape is checked on the server's write path, so a malformed address is refused on +save whatever the client sent. + ### `url` URL with format validation. @@ -63,6 +83,10 @@ URL with format validation. { name: 'website', label: 'Website', type: 'url' } ``` +**In the Console:** a URL input. Read-only, and in a grid, an `http://` or `https://` value is a +link that opens in a new tab with `rel="noopener noreferrer"`. Any other scheme (`javascript:`, +`data:`) is shown as plain text, never as a link. + ### `phone` Phone number field. @@ -74,6 +98,10 @@ Phone number field. { name: 'phone', label: 'Phone', type: 'phone' } ``` +**In the Console:** a `tel` input, which opens the dial pad on a phone. Read-only, and in a +grid, the value is a `tel:` link. The widget applies no input mask: the value is stored as +typed. + ### `password` Masked password input. On a generic object the value is **plaintext at rest** but **masked to `••••••••` on read** (ADR-0100) — it is *not* one-way hashed. One-way hashing is owned by the auth subsystem and applies only to its identity tables, never to an authored `password` field. For reversible encrypted-at-rest secrets (API keys, tokens, DB passwords), use the `secret` type instead; for login credentials, model them on the auth user object. @@ -81,6 +109,11 @@ Masked password input. On a generic object the value is **plaintext at rest** bu { name: 'password', label: 'Password', type: 'password', required: true } ``` +**In the Console:** a masked input with a show/hide toggle. Read-only, and in a grid, a stored +`password` or `secret` value is drawn as a fixed mask (`••••••`), never as the value. An empty +value draws the shared "No value" affordance instead, so a credential that was never set is +distinguishable from one that is. + ### `secret` Reversible, encrypted-at-rest value (DB password, API key, token). Unlike `password` (masked on read but plaintext at rest, or one-way hashed inside the auth subsystem), a `secret` is encrypted on write via the registered crypto provider, stored as an opaque ref on the row, and masked on read. Fail-closed: with no provider configured, writes throw rather than persist cleartext. @@ -125,6 +158,20 @@ WYSIWYG rich text editor content. { name: 'content', label: 'Content', type: 'richtext' } ``` +**In the Console:** `markdown`, `html` and `richtext` share one editor, and `type` changes only +how the stored text is displayed. `richtext` stores HTML and is displayed through the same +pipeline as `html`. The editor is a plain textarea today: there is no formatting toolbar, +preview pane or WYSIWYG mode, so `toolbar`, `preview`, `minHeight` and `maxHeight` are not +field keys. `rows` sets the inline editor's height, and `maxLength` adds a native stop and a +character counter, inline and in the fullscreen editor a form opens when it sets +`mobile.fullscreenLongText`. + +Grids, record pages and read-only forms render the stored markup formatted. HTML is sanitized +against a GitHub-style allow-list: links keep `http`, `https`, `mailto` and relative URLs, +images keep `http`, `https` and relative URLs, and inline `style`, event-handler attributes, +frames, objects and SVG or MathML content are not rendered. An element outside the allow-list +shows its text without its tag. The markdown pipeline uses the same allow-list. + --- ## Number Types @@ -143,6 +190,21 @@ Numeric value with optional precision. { name: 'quantity', label: 'Quantity', type: 'number', min: 0, max: 99999 } ``` +**In the Console:** a numeric input with a stepper (`step` sets the increment). The read-only +display and the grid cell use tabular figures and show the value to the field's `scale`: a +stored `1234.5` reads `1,234.50` at `scale: 2` and `1,234.5` with no `scale`. Thousands +separators follow `useGrouping`: + +| `useGrouping` | `scale` | Separators | +|:---|:---|:---| +| `false` | any | none: `12345.5` | +| `true` | any | always, even where the locale leaves four-digit numbers alone: `2,026` | +| not set | `0` | none, since the value reads as a year or another ordinal: `2026` | +| not set | anything else, or not set | the locale's own convention: `12,345.5` | + +The input is a native number box, so the browser filters what is typed. See +[What a number input silently rewrites](#what-a-number-input-silently-rewrites). + ### `currency` Monetary value. The stored value is a bare number in both modes; the currency code lives in `currencyConfig`, never in the value. @@ -168,6 +230,13 @@ or runtime ever read it), each with a prescription to delete the key. The field-level `precision` is the amount's total digit count — a DECIMAL(18,2) amount declares `precision: 18` — and is never compared with the currency. +**In the Console:** a numeric input with the currency's symbol. The read-only display, the +grid cell, the input's `step` and the rounding applied when the input loses focus all use the +currency's minor unit, so a JPY amount shows no decimals and a KWD amount shows three. With no +currency resolved at all, an amount shows two decimals. A field-level `currency` key is +refused: the currency is `currencyConfig`. The input is a native number box; see +[What a number input silently rewrites](#what-a-number-input-silently-rewrites). + ### `percent` Percentage value (0–100 or decimal). @@ -182,6 +251,21 @@ Percentage value (0–100 or decimal). { name: 'discount', label: 'Discount', type: 'percent', min: 0, max: 100 } ``` +**How a percent is stored.** A `percent` field stores a **fraction** unless it declares a `max` +above 1, in which case it stores **whole percentage points** (`percentScaleOf` in +`@objectstack/spec/data`). With no `max`, or `max: 1`, a stored `0.75` is displayed as `75%` and +a typed `90` is stored as `0.9`. With `max: 100` (any `max` above 1), a stored `75` is `75%` and a +stored `1` is `1%`. Every surface reads the same declaration (the input, the read-only text, +the grid cell and footer, the record header, gantt tooltips, gallery cards and dashboard +tiles), so one stored value shows one percentage everywhere; none of them guesses the storage +from the value's size. + +**In the Console:** `scale` is the number of decimals the percent shows, in the input's step, +the read-only display and the grid cell: a stored `0.855` shows `85.50%` at `scale: 2` and `86%` +with no `scale`. `precision` is the stored total digit count and never sets the displayed +decimals. The input is a native number box; see +[What a number input silently rewrites](#what-a-number-input-silently-rewrites). + --- ## Date & Time Types @@ -193,6 +277,12 @@ Date value (no time component). { name: 'birth_date', label: 'Birth Date', type: 'date' } ``` +**In the Console:** a date picker. A date is a calendar day and shows the same day in every +time zone. The grid cell shows a relative face ("3 days ago") for recent dates and an absolute +date otherwise. There is no date-range key: a field's `min` / `max` are numbers, so bound a +date with a validation rule. A date can be declared a deadline; see +[Deadlines: `dueLike` and `settledWhen`](#deadlines-duelike-and-settledwhen). + ### `datetime` Date and time value with timezone support. @@ -200,6 +290,11 @@ Date and time value with timezone support. { name: 'created_at', label: 'Created At', type: 'datetime', readonly: true } ``` +**In the Console:** a combined date and time picker. A datetime is an instant and renders in +the workspace's time zone, the `timezone` that `GET /api/v1/auth/me/localization` answers; +without one it renders in the viewer's own zone. A datetime can be declared a deadline; see +[Deadlines: `dueLike` and `settledWhen`](#deadlines-duelike-and-settledwhen). + ### `time` Time-only value (no date component). @@ -207,6 +302,41 @@ Time-only value (no date component). { name: 'start_time', label: 'Start Time', type: 'time' } ``` +**In the Console:** a 24-hour `HH:mm` time input (`14:30` is 2:30 PM). + +### Deadlines: `dueLike` and `settledWhen` + +`dueLike: true` declares a `date` or `datetime` field a deadline. Once it has passed, the +Console shows relative overdue wording ("Overdue 3d") instead of the neutral "3 days ago", and +colours the value red. Without the key nothing is inferred from the field's name: a past +`due_date` reads like any past date. Both keys are refused on any other type, and both are +display only: nothing on the write path reads them. + +`settledWhen` says when the deadline is settled for one record: a CEL predicate over the +record's own columns, read as `record`, written as a string or as the expression envelope. It +is accepted only beside `dueLike: true`. While it holds, no overdue wording or colour applies to +that record. + +```typescript +{ name: 'due_date', label: 'Due Date', type: 'date', dueLike: true, settledWhen: "record.status == 'done'" } +``` + +Every view that draws the date beside its record (the grid, the record page, related lists, +kanban and gallery cards, the compact list and the tree) evaluates `settledWhen` against that +record, so a done task's past due date reads neutral while an open one's reads overdue. A +predicate that fails to evaluate leaves the deadline unsettled. + +- **The predicate reads the record as the view fetched it.** A grid, a list view and a related + list add the fields `settledWhen` reads to their own query, so those fields need not be + columns. A field the viewer may not read is never fetched, so for that viewer a deadline + whose predicate reads it stays unsettled. An approval's record summary judges the deadline + against the snapshot it shows. +- **The affordance is day-granular.** It compares calendar days, so a datetime a few hours past + its deadline still reads "Today" and is not red. The red starts the day after the deadline; + the "Overdue Nd" wording starts a day later (the shortest is "Overdue 2d") and stops once the + relative face falls back to an absolute date. The red does not stop with it: a long-overdue + deadline reads as a plain past date, in red. + --- ## Boolean Types @@ -218,6 +348,17 @@ Standard true/false field. { name: 'is_active', label: 'Active', type: 'boolean', defaultValue: true } ``` +**In the Console:** a switch in forms. Read-only, a `true` reads "Yes" and a `false` reads "No"; +in a grid the value is a disabled checkbox named by the field's label. A field named +`completed`, `is_completed`, `done` or `is_done` draws a green "Completed" or an outlined "Not +completed" indicator instead; every other name, `active` and `enabled` included, draws the +checkbox. + +Only a real boolean is a value of a boolean field: the wire value of `boolean` and `toggle` is a +JSON boolean. `null`, `0`, `1`, `''`, `'true'` and `'false'` all draw the "No value" affordance, +because the record holds no boolean there. Repairing SQL `0`/`1` or the strings `'true'` / +`'false'` is the producer's job (driver read-coercion, CSV import), never the widget's. + ### `toggle` Toggle switch (functionally identical to boolean, different UI). @@ -256,6 +397,38 @@ Single-choice dropdown. } ``` +**In the Console:** a dropdown, drawn in grids as a coloured badge per value. An option +authored with an empty or whitespace-only `label` (the field designer writes one when only the +value box is filled) is displayed by its `value` in every option widget (`select`, +`multiselect`, `radio`, `checkboxes`), editable or read-only. That is a display fallback, not a +repair: the stored option keeps its empty label, so give every option a real one. + +**Cascading and gated options.** An option's `visibleWhen` predicate offers the option only +while it is true. It reads the live `record` and `current_user`, so one mechanism covers both +dependent lists (country → province → city) and options offered only to some users. Declare +the controlling fields with `dependsOn` (an array of field names). While any of them is empty +the control is gated with a hint ("Select country first") instead of an unfiltered list; when a +controlling value changes, the list is re-evaluated and a selection that is no longer offered +is cleared. The clear is saved like any other edit: a single select or radio is written as +`null`, and a multiple select keeps only the values still offered. + +```typescript +{ + name: 'province', label: 'Province', type: 'select', dependsOn: ['country'], + options: [ + { label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" }, + { label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" }, + { label: 'Public (external)', value: 'public', visibleWhen: "'admin' in current_user.positions" }, + ], +} +``` + +Option `visibleWhen` is the one visibility predicate the server also enforces: a write of a +value whose predicate is false, or cannot be evaluated, is refused. A third level (`city`, +`dependsOn: ['province']`) chains the same way. Option lists suit small, static +dictionaries; when the data is large, changes over time or is shared across forms, model each +level as a `lookup` with `dependsOn`, whose candidate query is filtered on the server and paged. + ### `multiselect` Multi-choice dropdown. Same `options` property as `select`. @@ -315,6 +488,41 @@ Reference to a record in another object (foreign key). { name: 'company', label: 'Company', type: 'lookup', reference: 'account' } ``` +**In the Console:** a two-level picker. + +- **Typeahead.** The trigger opens a popover that fetches candidates from the referenced + object, searches as the user types (debounced), and shows "Showing X of Y" when there are + more. `displayField` names each candidate's label (default: the referenced object's name or + title field) and `descriptionField` a secondary line under it. +- **Record Picker.** A "Browse all" button beside the trigger, and a "Show all results" link in + the popover, open a dialog with a multi-column table, server-side search, column sorting, + paging and keyboard navigation (arrow keys, Enter or Space to pick). `lookupColumns` sets its + columns (a field name or `{ field, label, width }`), `lookupPageSize` its page size + (default 10), and `lookupFilters` restricts the candidates. Without `lookupColumns` the + columns are inferred from the display field. +- **Quick create.** `allowCreate: true` offers "Create new" when nothing matches, creating a + record from the typed text. It suits objects whose only required field is the display field. +- **Dependent lookups.** `dependsOn` scopes the candidates by sibling fields: an array of field + names, or `{ field, param }` entries when the filter parameter differs from the local field + name. While a controlling value is empty the trigger is gated ("Select account first"); once + it is set, every candidate query (typeahead, Record Picker and people picker) carries it as a + filter no user input can override. + +```typescript +{ + name: 'contact', label: 'Contact', type: 'lookup', reference: 'contact', + displayField: 'name', descriptionField: 'email', + lookupColumns: ['name', { field: 'email', label: 'Email', width: '220px' }], + lookupPageSize: 15, + dependsOn: [{ field: 'account', param: 'account_id' }], +} +``` + +In a grid, a lookup shows the referenced record's label, or one chip per record when +`multiple` is set. Which record field a pick stores (`id` unless told otherwise) is picker +configuration, not a field key: `FieldSchema` refuses `idField`, and a form entry, an action +parameter or an inline grid column sets it instead. + ### `user` Person picker — a lookup specialized to the built-in `sys_user` object. Stored identically to `lookup` (foreign key to `sys_user.id`; `multiple: true` stores an array) and resolved through the same `$expand` machinery. Supports `defaultValue: 'current_user'` to stamp the acting user's id on insert. @@ -324,6 +532,14 @@ Field.user({ label: 'Watchers', multiple: true }) Field.user({ label: 'Owner', defaultValue: 'current_user' }) ``` +**In the Console:** a search-first people picker that delegates to the lookup picker with the +reference fixed to `sys_user`, so it searches users by name and email as you type, and shows +each candidate's business unit and email beside the name. Read-only and in grids the value +is an avatar (initials when there is no image) with the name, and several users show as +overlapping avatars with a count. There is no separate owner type: a record owner is a `user` +field named `owner`, usually `readonly` with `defaultValue: 'current_user'`, and `type: 'owner'` +is refused. + ### `master_detail` Parent-child relationship (cascading delete by default). @@ -347,6 +563,32 @@ Parent-child relationship (cascading delete by default). } ``` +**The inline grid in the Console.** With `inlineEdit: 'grid'` (or `true` resolving to the grid) +the parent's form edits the children as rows of a table: + +- **Columns.** Without `inlineColumns` the columns are derived from the child object. An + explicit column (`InlineGridColumn`) names the child field in `name` and may set `label`, + `width` (pixels), `required` and `type`. A column's `type` is one of nine cell controls: + `text`, `number`, `currency`, `date`, `datetime`, `time`, `select`, `lookup` and `file`. + Declaring `type` opts the column out of derivation, so supply what the control needs + (`options` for a select, `reference` and `displayField` for a lookup). +- **File cells.** A `file` column is an upload control in the cell, with removable chips and + thumbnails for images; `accept` and `multiple` restrict it. Derived columns map `file`, + `image` and `avatar` child fields to `file` cells, an image field accepting `image/*`. Shown as + text, a file cell reads the file's name, or a count when it holds several. +- **Booleans.** A derived `boolean` or `toggle` child field becomes a `select` cell storing + `'true'` / `'false'`, labelled in the session language. +- **Currency.** A `currency` cell shows the resolved currency's symbol, and decimals of that + currency's minor unit; `prefix` replaces the symbol only. +- **Order.** When the columns are derived and the child object has a field named `position`, `sort_order`, `sequence`, + `line_no`, `line_number` or `sort`, the grid stamps each row's index into it whenever a row is + added, removed, duplicated or dragged, so the order is saved with the rows. +- **Total.** `inlineAmountField` names the numeric child field summed into the footer. + +`inlineEdit: true` picks the form factor itself: a child with a single file, image or avatar +field keeps the grid, while a form-only field (textarea, rich text, JSON, location) or several +rich fields tips it to a per-row form. An explicit `'grid'` or `'form'` always wins. + #### A relationship never targets a stored-metadata table A `lookup` or `master_detail` whose `reference` is `sys_metadata` or `sys_metadata_history` is refused by `os validate`, `os build` and the object save door alike. Those rows carry serialized metadata bodies, which are served only through the metadata API (`GET /api/v1/meta/:type/:name`), where the body is projected; the engine never hydrates a reference into them, so `$expand` would keep the stored id and never nest the row, and a master-detail child's `parent` root would stay unbound, refusing every write a `parent` rule judges. To point a record at a metadata item, store its type and name in `text` fields and read the item through the metadata API. @@ -368,6 +610,16 @@ Self-referential hierarchy (e.g., categories, org chart). Media fields store a reference to an uploaded file. They take the universal field properties (`multiple` is honored for `file` and `image`). Two more field properties apply to the media family (`file`, `image`, `avatar`, `video` and `audio`) and constrain the referenced file: `accept`, the permitted upload types as MIME types or extensions (e.g. `["image/*", ".pdf"]`), offered to the file picker and enforced on write; and `maxSize`, the maximum permitted file size in bytes, enforced on write against the stored file size, not just checked in the browser. A create or update whose referenced file violates either is refused with `400 ERR_FILE_CONSTRAINT`, and the error message names the field and the constraint. Storage itself is configured at the storage-provider layer, not on the field. +**In the Console:** the stored value is the bare `sys_file` id the upload minted (an array of +ids when `multiple` is set). The widget uploads through the Console's upload provider, and an +upload that comes back with no id is refused with "did not complete", leaving the field +unchanged. A pick larger than `maxSize` is refused before the upload starts. A file the reader +may not open (its `sys_file` row is not readable) arrives as `{ id, metadataRefused: true }` +and every file, image and avatar renderer draws a lock labelled "Restricted file" instead of a +name or thumbnail; the download link still points at the id, and the download endpoint decides +access. Records written before files were stored as references may still hold an inline +`{ name, size, mime_type, url }` object, which is still rendered and never written. + ### `image` Image file with optional validation. @@ -375,6 +627,9 @@ Image file with optional validation. { name: 'photo', label: 'Photo', type: 'image' } ``` +Images show as a thumbnail grid in the form, with remove on hover, and as up to three +thumbnails plus a count in a grid cell. `accept` defaults to `image/*`. + ### `file` Generic file attachment. @@ -382,6 +637,9 @@ Generic file attachment. { name: 'attachment', label: 'Attachment', type: 'file', multiple: true } ``` +Files show as a list with names and sizes in the form, and as the file's name (or a count) in a +grid cell. + ### `avatar` Profile image (typically square, small). @@ -419,6 +677,13 @@ Calculated field using a CEL expression (see [Expressions](/docs/data-modeling/f { name: 'total', label: 'Total', type: 'formula', expression: 'record.quantity * record.unit_price', returnType: 'number' } ``` +**In the Console:** the value is computed on the server and shown read-only; the widget never +evaluates the expression. The form and the grid cell draw it by `returnType`: `number` is +formatted as a number field formats it (to the field's `scale`), `boolean` as Yes / No in the +UI language (a non-boolean reads empty), `date` as the date field's absolute face (not the +grid's relative one), and `text` in a monospace font. With no `returnType`, a number is drawn +as a number and anything else as text; nothing is inferred from the expression. + #### `expression` on a stored field `expression` is not only a `formula` key. On a field of any other type, the engine computes it from the row each write stores and **stores** the result in the field's column, so a `summary` can roll the column up. It is computed on every insert and every update by id (the stored row merged with the patch). On a predicate update it is computed only when the patch moves an input: one value is stored for every matched row, and the update is refused (`VALIDATION_FAILED`, an `invalid_value` error on the field) when the rows would compute different values. Update those rows by id instead. A value a client sends for the field never stands: it is overwritten by the computed one. When the value sent differs from the computed one, the write still succeeds and reports the field as dropped, under `reason: 'stored_expression'` (the data API's `droppedFields`); sending the computed value, or no value, reports nothing. An in-process caller that passes the engine write option `strictReadonlyWrites: true` is refused instead, with `ERR_READONLY_FIELD_REJECTED`, and nothing is written; a request body cannot set that option (see [`WriteObservabilityOptions`](/docs/kernel/contracts/data-engine#writeobservabilityoptions)). The full contract is the `expression` row of [`FieldSchema`](/docs/references/data/field). @@ -479,6 +744,11 @@ out of the filter (a status change) keeps the parent current: } ``` +**In the Console:** the roll-up is shown read-only, drawn by the same rule in the form and the +grid cell: a number is formatted as a number field formats it, keeping its own precision unless +the field sets a `scale` (`15750.5` reads `15,750.5`; at `scale: 2`, `15,750.50`), and anything +else is shown as text. + ### `autonumber` Auto-incrementing number with format template. @@ -505,6 +775,26 @@ guarantee as a separate requirement to satisfy explicitly, not something `autonumber` already gives you. +**Format tokens.** `autonumberFormat` is literal text with `{...}` tokens, read by +`parseAutonumberFormat` (`@objectstack/spec/data`); the query engine and the SQL drivers share +one renderer, so these are the whole grammar: + +| Token | Renders | +|:---|:---| +| `{0}`, `{00}`, `{0000}`, … | The counter, zero-padded to at least that many digits (one per format) | +| `{YYYY}`, `{YY}`, `{MM}`, `{DD}`, `{YYYYMMDD}` | The generation date in the business time zone | +| `{field_name}` | The value of another field on the same record | + +Date tokens are exact and case-sensitive, and there is no quarter, week or time token: any other +name in braces is a field token, so `{Q}` reads a field called `Q`. A referenced field must exist +on the object and be set before the record is created. The counter is scoped to whatever renders +before it, so `'ORD-{YYYY}-{0000}'` restarts each year and `'ORD-{0000}'` keeps one count. Omitted, +the format is `{0000}`. The older `format` key is the same pattern and is still accepted, but +`autonumberFormat` wins when both are set. + +**In the Console:** the number is assigned on insert, so a create form shows the field empty +and the value appears once the saved record comes back. + --- ## Embedded Structured Types @@ -568,6 +858,12 @@ Geographic coordinates. Stored as `{ lat, lng, altitude?, accuracy? }` (`lat` { name: 'headquarters', label: 'Location', type: 'location' } ``` +**In the Console:** the user types a latitude-then-longitude pair (`37.7749, -122.4194`) and the +value is displayed the same way; only the stored keys are `lat` / `lng`. There is no zoom key on +the field. To plot the records of an object on a map, use an `object-map` view or page node whose +`map` input names the field in `locationField` (with `titleField`, `zoom` and a fallback +`center` used when no record matches). + ### `address` Structured postal address. Stored as `{ street, city, state, postalCode, country, countryCode, formatted }` (all parts optional). No per-type config properties. The schema is strict (#13802): an undeclared key such as `postal_code` or `zipCode` is refused with a rename to `postalCode`, not silently dropped. @@ -593,6 +889,23 @@ Raw JSON data. { name: 'metadata', label: 'Metadata', type: 'json' } ``` +**In the Console:** `json`, `composite` and `record` share a JSON text editor. A `json` value need +not be an object: an array, number, string, boolean or `null` is shown as its JSON text and parsed +back as itself. Text that does not parse is refused: it stays in the box marked "Invalid JSON", +the value is not updated, and the form will not submit until it parses. An empty box sets `null`. +The editor checks syntax only; a value's shape is declared with a +[`json_schema` validation rule](/docs/data-modeling/validation#json-schema-validation) on the +object, since the field has no `schema` key. + +In a grid a `json` value is single-line JSON truncated to the column, with the full text in the +tooltip. A `composite` or `record` value is one line of labelled sub-values instead (labels are +the humanized keys), on the record page as well: + +```text +composite { width: 10, height: 20 } → Width 10 · Height 20 +record { primary: { name: "A", score: 9 }, backup: { … } } → Primary (Name A · Score 9) · Backup (…) +``` + ### `color` Color picker. Stores the color value as a string. No per-type config properties. @@ -673,6 +986,10 @@ An earlier nested `vectorConfig` object was removed — the field builder no lon // embedding: Field.vector(1536, { label: 'Embedding' }) ``` +**In the Console:** a vector is read-only. The form shows a preview of its first three values to +four decimals plus the dimensionality (`[0.1234, -0.5678, 0.9012...] (768D)`); a grid cell shows +`[Vector]`. + --- ## Universal Field Properties @@ -705,6 +1022,42 @@ These properties are available on **all** field types: --- +## How the Console renders a field + +### Metadata keys and widget props + +The keys on this page are **metadata**: authored on the object, validated when it is +published. A field widget also receives **props** at runtime, and those are never metadata: +the live value and its `onChange`, the host's `className` and `disabled`, a data source for +widgets that query records, the values of sibling fields for cascades, and the DOM and ARIA +attributes the form control minted. Writing one of those on a field definition publishes a key +the strict `FieldSchema` refuses. A custom widget receives the authored field under one carrier +(`field`) beside the props; its type, `FieldWidgetComponentProps` in `@object-ui/fields`, is +closed, so a misspelled prop is a compile error. Use `toDomProps(props)` to pass only what may +reach a DOM element, never a bare `{...props}` spread. A field's `widget` key names a +registered component to render it instead of the type's default. + +### Empty values + +An absent value (`null`, `undefined`, `''` or `[]`) draws one shared "No value" affordance, an +em-dash with that accessible name, in every widget and grid cell. A value the wire contract +does not allow for the type, such as `'true'` on a boolean, draws the same affordance: the +renderer keeps no second reading of off-contract data. + +### What a number input silently rewrites + +`number`, `currency` and `percent` render a native `type="number"` input, so the browser decides +what the box accepts, and only one of the two outcomes is announced: + +- **Announced.** Text the browser cannot read at all (`1e`, a lone `-`, a lone `.`) stays visible + while the input's value is empty. The field is marked `aria-invalid` and reads "Not saved: the + text in this box is not a number." +- **Not announced.** Text the browser truncates as it arrives: pasting `1.2.3` stores `1.23`, and + `0x10` stores `10` (on a fractional percent field, `0.0123` and `0.1`). The extra characters + never reach the form, so no warning is possible. + +"No warning" therefore does not mean "the value is right". + ## Choosing a Field Type Not sure which type fits your data? Follow the [Field Type Decision Tree](/docs/data-modeling/field-type-decision-tree) — a flowchart, quick-reference tables, and a use-case mapping for every type in this gallery. diff --git a/content/docs/ui/actions.mdx b/content/docs/ui/actions.mdx index 0340889279c..21e5b1d6ff8 100644 --- a/content/docs/ui/actions.mdx +++ b/content/docs/ui/actions.mdx @@ -371,9 +371,26 @@ strict params gate admits either bag without a word. ADR-0059), so any `FieldType` works — a `file` param shows a real upload control (`multiple` / `accept` / `maxSize` honored), `lookup` a record picker, `date` a date picker, `richtext` the rich-text editor, and so on. + A field-backed param resolves the same way when the action is launched from a + list's selection bar. Values reach the action as the widget emits them + (`number` a number, `date` `YYYY-MM-DD`, a lookup the record id(s), a `file` + the uploaded `sys_file` id(s), arrays when `multiple`), and the dialog's + **Confirm** stays disabled while an upload is in flight. A param's `visible` + is a CEL predicate over `features`, `current_user` and `data` that omits the + param when false. - **`confirmText`** — confirmation dialog before running. - **`successMessage` / `errorMessage` / `refreshAfter`** — post-run feedback and an automatic data refresh. + `successMessage` is a template whose holes are `{{ }}` over `result` (the + handler's answer) and `record` (the record the action ran on): + `'Invitation sent to {{ result.email }}'`; a `${…}` token in it is refused. A + `type: 'api'` or `'script'` action whose handler can succeed in more than one + way adds `outcomeMessages`, one entry per snake_case `outcome` the handler + returns, each of which may read `${result.*}`; the toast shows the entry the + answer names, else `successMessage`, else a generic "Action completed + successfully". A `message` in the server's answer is shown only when the copy + asks for it (`{{ result.message }}`). Post-success navigation is + `onSuccess: { navigate, openIn }`. - **`undoable`** — on a single-record update, the success toast offers an **Undo** that restores the record's prior field values (`Ctrl+Z` works too). The runtime only captures the prior values when this flag is set, so an @@ -437,6 +454,24 @@ is a spec proposal for a properly named key, not a values map under this one. [formulas guide](/docs/data-modeling/formulas) for CEL syntax. - **`requiresFeature`** ties visibility to a feature flag (compiled into a `visible` predicate). +- **Relation fields bind as their id.** A `lookup`, `master_detail`, `user` or + `tree` field reads in a predicate as the id the server stores, never as the + record it points at, on every surface (a record page that expanded the + relation for display collapses it again before evaluating). So "only the + record's manager sees this" is `current_user.id == record.manager`; + `record.manager.id` faults, because `record.manager` is a string. An empty + relation is `null`, a clean `false`. +- **`current_user.can(object, verb)`** asks whether the signed-in user holds an + object permission, from the effective permissions the Console already holds + for its built-in Edit and Delete, so a custom action that replaces one of + them can be gated the same way: `visible: "current_user.can('account', 'delete')"`. + The verb comes from the closed `OBJECT_PERMISSION_VERBS` table in + `@objectstack/spec/security`; a verb outside it is a fault, not `false`. + `current_user.hasCapability('')` asks the capability question from the + same payload. Until that payload has loaded both fault, so a `visible` gate + stays closed and a `disabled` gate renders the button disabled until the + answer arrives. In a UI gate they decide only what is shown: the server + still enforces the request the action sends, as `requiredPermissions` does. ### Authorization inside an action diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index 0c1b71b7644..a97b2fb38b1 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -192,6 +192,13 @@ nested `actionDef` block — `actionName` is **not** a top-level key on the item design** — the action owns its own parameter contract, so the app schema does not validate what goes inside it. +This is how an app-level action ("Quick Create", "Log a Call") is placed: the item +names a declared action, it does not carry the action's definition. The Console +renders it in the sidebar, and on click looks the name up among the declared +actions and runs it through the same runner as a toolbar button, so confirmations, +parameter dialogs and permission checks apply. An item that names no declared action +shows an error when clicked rather than doing nothing. + ### Component Navigation Renders a registered component. `componentRef` is a component-registry key, not @@ -277,6 +284,54 @@ rejected — a divider has nothing to label, gate, or badge.) | `requiresObject` | `string` | Hide/disable unless the named object is registered | | `requiresService` | `string` | Hide/disable unless the named kernel service is registered | +`label` is optional. An entry without one shows its target's **current** label, +resolved when the navigation renders: the view's label for an entry that names a +labelled view, else the object's or dashboard's label, else the target's machine +name. A renamed object, view or dashboard therefore shows its new name on the next +load with no navigation edit. Other entries fall back to their target's name +(`pageName`, `reportName`, the `url`) and a group to its `id`, so give those a +`label`. A `label` you write renders verbatim; never write an empty one, omit the +key instead. + +## Designing the navigation + +The same requirement can often be met three ways: a plain object entry, an object +entry anchored to a named view, or a page that embeds views. They are not +interchangeable. For an app with a `project` object: + +| You write | The user lands on | What comes with it | +| :--- | :--- | :--- | +| `{ type: 'object', objectName: 'project' }` | `/apps/my_app/project`, the object's default view | The whole object shell: view switcher, object actions, a create button, record routing, search and recents | +| `… viewName: 'project.by_status'` | `/apps/my_app/project/view/project.by_status` | The same shell, opened on a named view; the user can still switch | +| `… filters: { status: 'open' }` | `/apps/my_app/project/data?filter[status]=open`, the bare data surface | The conditions as removable chips over everything permissions allow, bound to no saved view; **Save as view** turns the slice into a named view | +| `{ type: 'page', pageName: 'project_overview' }` | `/apps/my_app/page/project_overview` | Only the page. View switching, actions and record links are assembled by hand | + +A page can imitate the other two, but it loses the object shell, and every later +improvement to that shell (new actions, view switching, permission trimming) skips +it. Use the least powerful construct that expresses the requirement: + +1. **One plain `object` entry per core business object**, with no `viewName`: the + default view is the view metadata's business, so navigation stays decoupled from + presentation. +2. **`viewName` when the entry is a named perspective** ("By Status", "Due This + Week"): declare a named view and anchor the entry to it. It sets the entry point; + it does not lock the user in. +3. **`filters` for one-off or parameterized slices** (a drill-through, "assigned to + me"): put the condition in the URL instead of authoring a view, and promote it to + a named view only once it is curated and reused. +4. **A page only for composition one view cannot express**: several objects side by + side, KPI cards beside lists, onboarding content. A page that wraps one object's + one view is an anti-pattern. +5. **A `dashboard` for metrics and charts, a `report` for tabular analysis**, rather + than pages of chart blocks. +6. **One entry per target.** Don't offer an object through both a plain entry and a + page wrapping its default view. If an object needs several entries, make each of + them a named-view entry; mixing styles breaks the sidebar's active-state + highlighting. + +In a typical business app most entries are `object` entries; pages are for the home +screen, onboarding and cross-object workbenches. + ## Areas An **area** partitions one app's navigation by business domain — Sales, Service, @@ -683,9 +738,17 @@ branding: { | Property | Type | Description | | :--- | :--- | :--- | | `primaryColor` | `string` | Primary brand color (hex) | -| `logo` | `string` | Logo image URL | +| `accentColor` | `string` | Accent color (hex), for highlights and active states | +| `logo` | `string` | Logo image URL: one square app icon, SVG preferred, at least 64 px. Each surface scales it; there is no second size or wordmark slot | | `favicon` | `string` | Favicon URL | +`branding` is the whole of an app's theming in metadata: the Console maps +`primaryColor` and `accentColor` onto its `--primary` and `--accent` CSS variables. +There is no theme document to author (the standalone `themes` collection was retired +in 17.1). Both the app and `branding` are strict: a top-level `logo` or `theme` is +refused with a pointer to `branding`, and a misspelled key inside `branding` is +refused rather than dropped. + ## Complete Example {/* os:check */} diff --git a/content/docs/ui/console.mdx b/content/docs/ui/console.mdx new file mode 100644 index 00000000000..6d29e63126d --- /dev/null +++ b/content/docs/ui/console.mdx @@ -0,0 +1,164 @@ +--- +title: The Console — how it renders your metadata +navTitle: Console +description: What the Console does with the apps, views and pages you declare — its configuration, the record surfaces, what each user's own state keeps, and how a broken predicate is reported +--- + +The **Console** (`/_console/`) is the workspace end users live in, and the renderer for +everything in this module: it declares no apps, objects or views of its own, and draws +whatever the server it is connected to publishes. [Build without code](/docs/build-without-code) +tours it from a user's side; this page is the reference for how it behaves, so an author +can predict what a declaration will look like. + +## Configuration + +The Console has no configuration file. It has two inputs: + +- **Which server.** A build-time variable, `VITE_SERVER_URL`, names the backend. Empty + means same origin, which is how the Console ships inside a server; an absolute origin is + for a Console deployed apart from its backend, and opts into CORS. In development the + Vite server proxies `/api/*` to `DEV_PROXY_TARGET`, or `http://localhost:3000`. +- **What the server pushes.** Before it mounts, the Console reads + `/api/v1/runtime/config` from that server: product branding, feature flags + (marketplace, AI Studio, SSO, custom domain) and the cloud URL. An operator changes + them on the server, with no Console rebuild. + +Apps, objects and views are metadata, fetched over HTTP when the Console connects and +loaded on demand. To change what it shows, change the metadata: in code, or in Studio and +publish. + +## Navigating + +- **App switcher.** Every app the server serves the user, with an **Edit app in Studio** + row at the end for workspace admins. An **Edit app** button in the top bar (the pencil + alone on narrow screens) opens the app's owning package in Studio: on a dashboard, page + or report it deep-links to that surface's design page in the Interfaces pillar + (`/studio/:packageId/interfaces?surface=page:NAME`); elsewhere it opens the package's + Data tab. Interfaces are designed in Studio; there is no in-page edit panel. +- **Navigation.** The sidebar renders the app's `navigation` tree; see + [Apps](/docs/ui/apps#navigation-items). A `visible` predicate reads `current_user` and + `features`, e.g. `visible: "'admin' in current_user.positions"`. +- **Command palette.** `⌘K` opens a searchable command bar over the open app. On the + Studio landing (`/studio`) it searches the Studio instead: packages, their objects and + their flows, by label or machine name. + +## Record surfaces + +### The record page and its overlay + +Clicking a row, card, node, bar or event opens the record in an overlay whose mode is the +view's `navigation.mode`: `drawer`, `modal`, `split` or `popover`. It means the same on +every list-type view, and every one of them (grid, tree, kanban, calendar, gantt, list, +gallery, map and timeline) opens the same record panel, so a record reads with its +declared labels and typed values, without its `hidden` fields, whichever view it came +from. In `split` the view moves into the left panel; in `popover` the panel is anchored to +what was clicked. + +The record opened from a list is the root of the drawer, and it is addressable: +`?recordId=` on the list's URL, so a reload, a shared link or Back reopens it. A record +opened inside the drawer (a related row, a lookup value) opens one level down in the same +drawer. Those levels are in-memory: a reload shows the root record. `⤢` on any level opens +that record's full page, with the levels below it as its `?from=` trail, and the page's +**Collapse to drawer** returns to the drawer with the stack it left. + +A record page's layout is described in [Pages](/docs/ui/pages). + +### Approvals on a record + +A record with approval requests grows an **Approvals** tab beside Details and Related, with +a request-count badge: the current step, decision progress, the approvers it is waiting on, +the merged decision timeline and a remind button for the submitter. Every viewer who can +read the record sees it, not only approvers. A request opens on its record page through +the approvals routes rather than the data API, so an approver who is not an administrator +sees the whole page, decision actions and timeline included, although the data API stays +closed to them on the approval objects. + +### Dashboards and list views a user may not open + +The server lists only the dashboards and list views a user may open, so one gated by +`requiredPermissions` is not in their lists. Opened by its URL, the Console asks the +server about it by name; a `403 PERMISSION_DENIED` shows "You don't have access" with the +server's sentence naming the item, not "not found", and the list page does not switch to +the default view. A name that does not exist still reads "not found" (the list page opens +the default view). + +### Notifications + +The header bell reads the server-side inbox (`sys_inbox_message`, with read state in +`sys_notification_receipt`) through one shared feed: the bell's Notifications tab, its +unread badge and any bell a page places with a `global:notifications` node read the same +feed on one cadence, so they cannot disagree about read state. + +## What each user's own state keeps + +Some state belongs to the user, not to the metadata. It follows them across browsers and +devices, and never changes anything for anyone else. It is stored in +`sys_user_preference`, one row per user and key, and cached in the browser, so the first +paint never waits for it; with no server row the Console runs from the browser copy. + +| State | Key | Limit | +|:---|:---|:---| +| Favorites (starred records, apps) and sidebar pins | `ui.favorites` | 20 favorites and 20 pins, kept apart | +| Recently visited items | `ui.recent` | 8 | +| Flow designer's recently added node types | per user | 5 | +| A list's view-tab order, and per view its sort, filters, column order and widths, density and hidden fields | `ui.list.` | — | + +- **Pins.** The sidebar's **Pinned** section is the only part of the sidebar a user orders: + drag a pinned row, or focus it and use Space, the arrow keys and Space again (Escape + cancels). The app's own menu keeps the order its author gave it. +- **Recents store identity, not labels.** An entry keeps its type and name and is labelled + when it renders, so a rename or a language switch shows at once. +- **List state never writes the view.** A header sort, a column drag or resize, a filter + change, a density change, Hide Fields and a Manage Views drag write the user's row, never + the view metadata, so they work even where the tenant's metadata is read-only. The view's + authored `sort`, filter, columns, `rowHeight`, `hiddenFields` and tab order stay the + default for every user with no state of their own, and the view editor in Studio is where + an author changes them for everyone. A link carrying list state (`uf_*` parameters) wins + over the stored state. A user's hidden fields only subtract from the columns the view + shows, never add one. **Reset view** in the page header's ⋯ menu drops the active view's + stored state, and an administrator can delete the row under Setup → User Preferences. +- **Overlay width.** The record overlay remembers the width a user dragged it to, per + object and across view types, in the browser only. Widths under 360 px are not restored, + a drag stops at 95% of the viewport, and double-clicking the handle returns to the + authored width. + +## When a predicate cannot be evaluated + +A predicate that fails to evaluate (a misspelled root, a type error, a missing function) +never fails silently. The Console warns once per distinct predicate source, in production +builds as well, naming the node, the key, the predicate and the engine's reason: + +```text +[ObjectUI] A visibility predicate could not be evaluated - node "record:alert" (id: "a1") + visibleWhen: "nosuchroot.status == 'draft'" + Reason: ... nosuchroot is not defined +``` + +What the failure does depends on what the predicate guards: + +| Predicate | On failure | +|:---|:---| +| A field's `visibleWhen`, `readonlyWhen`, `requiredWhen`; a view's `visibleOn`; an option's `visibleWhen`; list conditional formatting | **Fail open**: the rule's safe default applies, so a broken rule never hides a field. A rule that never fires while its field stays visible is the symptom, usually a bare field name where `record.` was meant | +| An object field group's `visibleWhen` | **Fail closed**: the whole group, header and members, is hidden on the form and the record page | +| A page node's `visibleWhen` / `visible` / `hidden`, and a `page:tabs` item's `visibleWhen` | **Fail closed**: the node or tab is withheld. A withheld related list is not counted in its tab badge, and if the active tab is withheld the strip falls back to the first visible one | +| A navigation item's or area's `visible` | **Fail open**: the item renders for everyone, which is why the warning is reported under `app-shell:visible` | + +At **submit** a broken field rule is refused, not passed. A field `visibleWhen` that +cannot be evaluated refuses the submit on the client (on the record form, the internal and +public form pages, and a wizard's last step), with a message naming the field and the rule, +because no server evaluates `visibleWhen`. A broken `requiredWhen` or `readonlyWhen` is +refused by the server, and the form shows that refusal beside the input. A `visibleWhen` +that reads `previous` cannot be evaluated on a create form, so such a form is always +refused. + +A **blank** rule (`''`, whitespace, or an envelope whose `source` is blank) is not "no +rule". A blank field rule is refused when it is authored, and one already stored is refused +at submit like any rule that cannot be evaluated. A blank gate (`visible`, `hidden`, +`disabled`, a page node's `visibleWhen`) is read as "no gate" at runtime and reported once +with the `[blank]` reason. + +## Related + +- [Studio](/docs/ui/studio) — designing objects and flows, and the metadata diagnostics. +- [Apps](/docs/ui/apps) · [Pages](/docs/ui/pages) · [Views](/docs/ui/views) · [Forms](/docs/ui/forms) +- [Build without code](/docs/build-without-code) — the Console, Studio and Setup from a user's side. diff --git a/content/docs/ui/create-vs-edit-form.mdx b/content/docs/ui/create-vs-edit-form.mdx index f2f51d26872..b96a84e1e1d 100644 --- a/content/docs/ui/create-vs-edit-form.mdx +++ b/content/docs/ui/create-vs-edit-form.mdx @@ -116,6 +116,51 @@ Rule of thumb: **"different field subset" → derive. "different layout or flow" - Object: [`examples/app-showcase/src/data/objects/contact.object.ts`](https://github.com/objectstack-ai/objectstack/blob/main/examples/app-showcase/src/data/objects/contact.object.ts) — flat, intent-tagged field set, with the four `fieldGroups` its `group` tags point at. - Views: [`examples/app-showcase/src/ui/views/contact.view.ts`](https://github.com/objectstack-ai/objectstack/blob/main/examples/app-showcase/src/ui/views/contact.view.ts) — full edit form + sparse `formViews.create` + the list's Add button (`addRecord: { enabled: true }`), which opens it by convention. +## Modal or page: `editMode` + +Where the form opens is a separate choice from what it holds, made on the object: + +{/* os:check */} +```typescript +import { ObjectSchema } from '@objectstack/spec/data'; + +export const Account = ObjectSchema.create({ + name: 'account', + label: 'Account', + sharingModel: 'private', + editMode: 'page', // 'modal' (the Console's default) | 'page' + fields: { name: { type: 'text', label: 'Name', required: true } }, +}); +``` + +- **`modal`** opens Create and Edit in a dialog over the current view: short forms and quick + edits. +- **`page`** gives the form its own route under the active app, + `/apps/:appName/:objectName/new` and `/apps/:appName/:objectName/record/:recordId/edit`: a + URL that can be bookmarked and shared, survives a refresh (the form re-reads the record from + the URL) and closes with Back. It suits long, tabbed or wizard forms. + +Both render the same form, so sections, validation and visibility rules behave identically. +The change is additive: the existing Create and Edit entry points, the list's per-row Edit +included, follow `editMode` with no other edit. To open the page routes from a page element, +an `action:button` names the Console's `navigate_create` or `navigate_edit` handler in +`properties.actionType`, with its arguments in `properties.params` (a node-level `params` is +the action's input list and is ignored here). Strings in `properties.params` are templates, +so a button on a record page names its record with `${record.id}`; inside an object view the +active object is already known and `params` can be omitted. + +```json +{ + "type": "action:button", + "properties": { + "label": "Edit", + "icon": "pencil", + "actionType": "navigate_edit", + "params": { "objectName": "account", "recordId": "${record.id}" } + } +} +``` + ## Anti-patterns - **Two full hand-authored field lists** (`contact_create_form` + `contact_edit_form`). Guaranteed drift; the create form silently misses new required fields. diff --git a/content/docs/ui/dashboards.mdx b/content/docs/ui/dashboards.mdx index 9c246dee426..0d9be0ce56d 100644 --- a/content/docs/ui/dashboards.mdx +++ b/content/docs/ui/dashboards.mdx @@ -415,6 +415,45 @@ to `field`; the name `dateRange` is reserved for the built-in date range. The filter above names itself `region` for exactly that reason — left to default it would be keyed `account.region`, dots and all, in every expression and binding. +### Filter controls and options + +Each entry renders one control in the filter bar above the widgets, and its `type` decides +the condition it adds to every bound widget's query, `AND`-combined with the widget's own +filter: + +| `type` | Control | Condition | +| :--- | :--- | :--- | +| `text` | Text input | `{ field: { $contains: value } }` | +| `number` | Numeric input | `{ field: value }` | +| `select`, `lookup` | Dropdown | `{ field: value }`, or `$in` for several values | +| `date` | Preset / custom range | `{ field: { $gte: from, $lte: to } }` | + +Static `options` are `{ value, label }` pairs, where `label` is a string or an inline locale +map (`{ en: 'AMER', 'zh-CN': '美洲' }`). The bare-string shorthand (`options: ['EMEA', +'APAC']`) is refused at publish, and a stored filter written that way renders with an empty +option list. Options can instead be read from an object: + +{/* os:check */} +```typescript +globalFilters: [ + { + name: 'industry', + field: 'industry', + label: 'Industry', + type: 'select', + // object, valueField and labelField are all required; with no separate label + // column, name the value field again. `filter` is a condition object, not an array. + optionsFrom: { object: 'account', valueField: 'industry', labelField: 'industry', filter: { status: 'active' } }, + }, +] +``` + +The Console resolves `optionsFrom` as distinct values on the server, so the list is complete +whatever the row count. A filter that declares `object` (the object its `field` lives on, not +the `optionsFrom` source) takes its label and option labels from the app's +[translation bundle](/docs/ui/translations) (`objects..fields..label` and +`.options.`), as lists and forms do; the authored `label` is then the fallback. + ### Where a Filter's `field` Resolves A global filter's `field` resolves against the **object behind each bound @@ -507,6 +546,27 @@ Binding precedence: an explicit `filterBindings` entry (string override or `false` opt-out) → the filter's legacy `targetWidgets` allow-list → the filter's own `field`. +### In the Console + +- **The filter bar.** The date range and each global filter render as controls above the + widgets; a **Reset** button appears once any of them differs from its default. Changing one + re-reads every bound widget in place. +- **Presets stay symbolic.** A preset compiles to date-macro tokens (`{30_days_ago}`, + `{current_month_start}`) that each widget resolves at query time, so a dashboard saved + today still means "last 30 days" tomorrow. +- **Values in expressions.** Filter values are dashboard variables, read as `page.`. + The date range is an object under `page.dateRange`: `{ preset: 'last_30_days' }` for a + preset, `{ from: '2026-01-01', to: '2026-03-31' }` for a custom range (either bound may be + absent). A dashboard embedded in a page that declares its own `variables` merges the two + scopes: a filter shadows only an outer variable of the same name, and a write goes to the + scope that defines the variable. +- **Bindings in Studio.** The dashboard widget inspector has a **Dashboard filter bindings** + section, one row per declared filter, with an apply toggle (the `false` opt-out) and a + field picker for the override. +- **Refresh.** The page header has a **Refresh All** button, and `refreshIntervalSeconds` + re-reads the widgets every that many seconds (`0` or unset: no automatic refresh). Widgets + re-read in place rather than remounting, single-value tiles included. + ## Complete Example First define the dataset the widgets bind to: diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index 8fb4b4fb24f..5eea4c9e7f3 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -280,7 +280,7 @@ async function submit(slug: string, payload: Record) { } ``` -The ObjectUI console (a separate package/repo) ships a unified `FormPage` renderer at `/_console/f/:slug` (public) and `/_console/forms/:name` (internal) that does exactly this. +The Console ships a unified `FormPage` renderer at `/_console/f/:slug` (public) and `/_console/forms/:name` (internal) that does exactly this. ## 6. Internal forms (`/_console/forms/:name`) @@ -320,6 +320,14 @@ Operators hit `/_console/forms/quick_create`. The renderer: Permissions are enforced server-side just like every other authed write — no `guest_portal` profile required. +Inside an app the same form opens at `/_console/apps/:appName/forms/:name`, with that app's +sidebar and branding: a `type: 'form'` action fired from an app route (a record page, an +object list or a page) opens there, and the app-less `/forms/:name` serves every other +link, cold deep links included. The form is headed by the view's label or the form's +`title`, else by its object's label (the object's API name only when it has no label). An +internal form also has **Cancel**, which returns to the page the form was opened from, or, +on a cold link, to the record being edited, else to the app's home. + ## 7. URL prefill Both modes accept `?prefill_=` query params. Use this to seed forms from email links, CRM segments, or campaign pages: diff --git a/content/docs/ui/index.mdx b/content/docs/ui/index.mdx index b9f48556bb7..f44fd7c1439 100644 --- a/content/docs/ui/index.mdx +++ b/content/docs/ui/index.mdx @@ -34,6 +34,7 @@ export const CrmApp = App.create({ - **Branding** colours an app: `app.branding.primaryColor` / `accentColor` drive the console's `--primary` / `--accent` variables. (The standalone `themes` collection was retired in spec 17.1, ADR-0049 — it was parsed but never applied.) - **Public forms** expose a scoped slice of an object to external audiences through **anonymous entry routes** for public data collection ([Forms](/docs/ui/forms), [public data collection](/docs/ui/public-data-collection)). - The **Setup App** — the platform's built-in administration UI — is itself rendered from the same protocol ([Setup App](/docs/ui/setup-app)). +- The **Console** renders all of it for end users, and **Studio** is where it is authored without code ([Console](/docs/ui/console), [Studio](/docs/ui/studio)). ## What's in this module @@ -50,6 +51,8 @@ export const CrmApp = App.create({ + + ### Recipes diff --git a/content/docs/ui/meta.json b/content/docs/ui/meta.json index 9bf31a8cd75..cb6c022c9f1 100644 --- a/content/docs/ui/meta.json +++ b/content/docs/ui/meta.json @@ -13,6 +13,8 @@ "forms", "doc-pages", "setup-app", + "console", + "studio", "---Recipes---", "create-vs-edit-form", "field-grouping-and-order", diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index acec9c7cb09..52ab7529a03 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -64,6 +64,7 @@ const homePage = { | `template` | `enum` | optional | Layout template: `'default'` (the default), `'header-sidebar-main'`, `'three-column'` or `'dashboard'` — see [Templates](#templates). Any other name is refused | | `kind` | `enum` | optional | Page override mode (default `'full'`). `full` \| `slotted` = structured authoring; `html` = author-written constrained JSX/HTML compiled (parsed, never executed) to the tree (ADR-0080; ADR-0081 named it `html`); `react` = real-React source executed at render by the runtime (ADR-0081) — it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via `OS_PAGE_REACT=off`. See [Authoring modes](#authoring-modes) | | `regions` | `PageRegion[]` | optional | Layout regions with components (default `[]` — `list` pages render via `interfaceConfig`, and an empty `record`/`home`/`app` page falls back to the synthesized default layout) | +| `slots` | `object` | optional | Slot overrides for a `kind: 'slotted'` record page — see [Slotted pages](#slotted-pages) | | `source` | `string` | optional | Page source text — required (and authoritative over `regions`) when `kind` is `'html'` or `'react'`. For `kind: 'html'` it is constrained JSX/HTML compiled to the tree by `@objectstack/sdui-parser` at save time (parse, never execute). For `kind: 'react'` it is real React/JSX executed at render by `@object-ui/react-runtime` (trusted tier). See [React Pages](/docs/ui/react-pages) | | `variables` | `PageVariable[]` | optional | Local state variables | | `isDefault` | `boolean` | optional | The default record page of its `object` — the page that object's record view opens. At most one `isDefault: true` record page per object per deployment: a second is refused by `defineStack` / `os validate`, at package registration and at metadata save, naming both pages. Saving a page under the existing default's own name is that page, not a second default. **Refused on any other page** (a `home`, `app`, `utility` or `list` page, or a record page with no `object`): nothing reads it there. A page does not choose where an app opens — see [Page Types](#page-types) | @@ -174,6 +175,126 @@ and is refused the same way. `full-width` is no longer a template name: it was a second name for `default`, and is refused with that replacement. +## Record pages + +### The synthesized default + +A record page needs no authoring. With no page assigned, the Console synthesizes one from +the object: a header, the highlights strip, a tab strip and the discussion panel. The strip +is **Details**, then one tab per relationship whose child field declares +`relatedList: 'primary'`, then a shared **Related** tab for the other related lists, then +**History**; an object with `enable: { files: true }` also gets an **Attachments** tab with a +count badge. Promoting a child table to its own tab is therefore a one-word change on the +relationship field, not a page-authoring task, and `relatedList: false` leaves a child off +the page. + +The Console lays the page out by the width of the surface it occupies (the drawer, the +split pane or the full page), never by the viewport. From 1280 px the tabs become three +columns: details, a timeline (the discussion composer, then Activity and History) and the +related lists as cards with Approvals, Attachments and the created/updated line. From +1024 px the related column folds into a rail behind a **Related (N)** toggle. Below that, and +always in a drawer or split pane, the tab strip is drawn. `?tab=` selects a tab in the strip. + +### Slotted pages + +`kind: 'slotted'` overrides only the parts of the synthesized page you name, and the +synthesizer fills in the rest. A slotted page authors `slots`, not `regions`: + +| Slot | Replaces | +| :--- | :--- | +| `header` | The `page:header` node | +| `actions` | The action bar (`record:quick_actions`) | +| `alerts` | The alert banners above the body | +| `highlights` | The highlights strip | +| `details` | The body of the Details tab; the other tabs stay synthesized | +| `tabs` | The whole tab strip, to add, remove or reorder tabs | +| `discussion` | The discussion panel | + +Each slot takes one component or an array of them, and replaces its part whole: there is no +deep merge. `details` and `tabs` are refused together, because the Details tab lives inside +the strip `tabs` replaces; to replace both, put `record:details` in the first `tabs` item +and author no `details` slot. A slotted `tabs` strip stays a strip at every width. + +{/* os:check */} +```typescript +const accountDetailPage = { + name: 'account_detail_page', + label: 'Account Detail', + type: 'record', + object: 'account', + kind: 'slotted', + regions: [], + slots: { + header: { + type: 'page:header', + properties: { title: '{name}', subtitle: '{industry} · {type}' }, + }, + }, +}; +``` + +Use a slotted page to change one or two parts of an otherwise standard record page, and a +`full` page for a record page that looks nothing like the default. + +### The discussion panel is composed, never appended + +A record page shows a discussion panel if and only if the page composes a +`record:discussion` node (or `record:chatter`, the same block under another name). +Synthesized and slotted pages compose it for you; a `full` page places it like any other +block, and the node's own properties are honoured, so +`{ type: 'record:discussion', properties: { feed: { showCommentInput: false } } }` draws the +conversation without a composer. The object outranks the page: `enable: { feeds: false }` +suppresses the panel whether or not the page composes it, and the server refuses comments on +such an object (`403 FEEDS_DISABLED`). + +### Related lists + +- **Permission-gated.** A relationship whose child object the user cannot read produces no + tab and no section, and a `record:related_list` on an authored page hides itself the same + way. +- **Paged.** A related list shows `limit` rows per page (5 unless set), fetched a page at a + time with the count badge showing the whole collection; `sort` pins the initial order, and + a user's column sort is sent to the server. A list that declares `columns` fetches only the + declared columns the user can read. +- **Actions are ids.** By default a related list draws the child object's own actions: those + placed at `list_toolbar` as header buttons, and those at `list_item` or `record_related` in + each row's menu (`record_related` shows only inside a parent record). Declaring + `actions: ['export_contacts', 'send_welcome']` in the block's `properties` replaces that + set, in that order; each action is still placed by its own `locations`, and `[]` shows + none. New, Edit, Delete and opening a row are not actions: they follow the object's + `userActions` and the user's permissions. An id that names no action of the child, or one + with no list location, shows a notice naming it rather than being dropped. + +### The page header + +`page:header` is the title bar. Its props go in `properties`, and the shape is closed, so an +unknown or retired key (`description`, `icon`, `breadcrumb`, `showStar`) is refused by name. + +| Property | Default | Description | +| :--- | :--- | :--- | +| `title` | Derived from the record on a record page | Heading; `{field}` tokens interpolate against the record, and a token that resolves to nothing renders empty. Set it on a non-record page | +| `subtitle` | — | The line under the title, interpolated the same way | +| `actions` | — | Ids of actions declared on the page's object. An authored list is the whole set: an action that declares `record_header` but is not named is not drawn, and omitting the key draws none of the object's actions. Only the synthesized page draws every `record_header` / `record_more` action | +| `recordChrome` | `true` | Draw the title as the record chip (object label, copy-id button, the record's picture when the object names an `imageField`); `false` keeps a bare heading | +| `showCopyId` | `true` | The copy-record-id button in the chip | +| `maxVisible` / `mobileMaxVisible` | 3 / 1 | How many actions render inline before the rest fold into the `⋯` menu | +| `aria` | — | ARIA attributes (`ariaLabel`, `ariaDescribedBy`, …) for the `
` element | + +A named action renders only if its `locations` include `record_header` (inline) or +`record_more` (straight into `⋯`); an id that names no action renders nothing and is reported +once in the browser console. Which actions take the inline slots is metadata too: actions are +sorted by `order` (lower first; the host's Edit / Share / Delete use 100 and above, so +authored actions outrank them), `variant: 'primary'` breaks ties, `variant: 'danger'` draws a +red button or a red row at the foot of the `⋯` menu, and `component: 'action:menu'` pins an +action inside `⋯`. Past the `⋯` menu a record page shows a **⟳ refresh** button: it is +chrome, not an action, so it has no metadata key, is never folded into `⋯`, and re-reads the +record, its related lists and tab badges in place without losing the open tab or an inline +edit. + +A page has one `

`. On a record page the header owns it; on another page type a +`page:header` with a literal title becomes the page heading. `page:header` draws no +children: anything that belongs beside it goes in a sibling node. + ## Components Components are the building blocks placed inside regions. @@ -258,6 +379,42 @@ The API binding's rules: - **Reads only.** `read.method` is optional and accepts `GET` alone; there is no `write` block. - **Rendered by the console.** The console reads the binding on every page element and binds the selected value as `data`: in the element's expression holes (`${data.used}`), in its `visibleWhen`, and in every element nested inside it. The console's generic `hero`, `stat`, `usage-bar`, `checklist` and `tile-grid` blocks are built to take it as their `dataSource`. While the read is in flight the element shows a loading panel, and a refused binding or a failed read shows an error panel instead. +### Expression holes in element properties + +A string in an element's `properties` may carry `${…}` holes, which the Console fills when +it renders: `"content": "Welcome back, ${current_user.name}"`. The roots are `record` (the +page's row), `current_user`, `page.` for page variables and, under an element with an +API `dataSource`, `data`. An inline locale map is filled member by member and then drawn in +the viewer's language: + +```json +{ + "type": "element:text", + "properties": { + "content": { "en": "Welcome back, ${current_user.name}", "zh-CN": "欢迎回来,${current_user.name}" } + } +} +``` + +A hole whose member is missing renders empty; a hole whose root does not exist renders as +its own source text, with one line in the browser console. Holes fill copy; they are not +gates. A component's `visibleWhen` is a CEL predicate over the same roots +(`"'sales_manager' in current_user.positions"`), not a `${…}` template. + +### List blocks in a region + +A region page sizes each block to its region, measured from the rendered width (no key sets +it). A `list-view` in a region narrower than half the page, such as an `aside` beside `main`, +renders a compact list: the title and record count, then two-line rows from the view's first +two visible columns. A region half the page or wider renders a capped table: at most the +block's `dataSource.limit` rows (5 when unset) and the view's first four visible columns. +Neither draws a toolbar, view tabs or a pager; both end with a **View all** link, to the +saved view's page when the block binds `dataSource.view` and to the object's list otherwise +(the block's own filter and sort are not carried, so bind a saved view to link to exactly +what the block shows). `html` and `react` pages have no regions, so their list blocks render +the whole list. A `page:tabs` block whose tabs hold list blocks badges each tab with its +list's record count before it is opened; a tab item's own `count` wins. + ## Variables Pages can define local variables for managing state across components. diff --git a/content/docs/ui/print-pages.mdx b/content/docs/ui/print-pages.mdx index 0f2fca47e9c..7b53d421ace 100644 --- a/content/docs/ui/print-pages.mdx +++ b/content/docs/ui/print-pages.mdx @@ -49,6 +49,41 @@ page that is not a record page of the object (`type: 'record'` with that `object`), or that declares no `print`, is refused rather than printed as some other page. +## Print in the browser + +A user can print a print page without the service. When the record page bound to a +record declares `print`, the record view shows a **Print** button that opens the browser's +print preview of that page; saving a PDF there is the browser's own "Save as PDF". A print +page that is not the object's default opens for the same record at +`/apps/APP/OBJECT/record/ID/page/PAGE`, with the same button. PAGE must be a record page +bound to OBJECT: any other name shows "Page not found", never the default page, so that URL +never puts a different document on paper. (Printing from the browser, `print` is also +accepted on a `home` or `app` page; the service prints record pages only.) + +The console mounts a print stylesheet for a page that declares `print`, and only for it. +Every rule sits inside `@media print`, so the page on screen is unchanged and is itself the +preview. Only the print page reaches paper: the console chrome and the record view's own +panels are hidden, and the page runs onto as many sheets as it needs. + +| Key | On paper | +|:---|:---| +| `paperSize` (`A4`, `A5`, `Letter`, `Legal`), `orientation` | `@page { size }`; A4 portrait when omitted | +| `margins` | The `@page` margin, one side per key, in millimetres; a side left out keeps the console default | +| `pageNumbers` | "page / pages" in the bottom-right margin box | +| `repeatHeader`, `repeatFooter` | The page's `header` / `footer` region repeated on every sheet | +| `repeatTableHeaders` | A table's column headings repeated on every sheet it runs onto; on when omitted | +| `avoidBreakInside` | `break-inside: avoid` on each block of a region | + +Blocks print whole: + +- `record:line_items` loads at most its row cap (`limit`, 500 when unset). On a print page, + when the child object has more lines than that, the block draws the cap's lines and a + note naming the cap and, when the server reports one, the total, so a printed copy never + drops lines silently. +- A collapsible `record:details` section draws open, and a section prints every declared + field, empty ones included, with no "Show N empty fields" row. A section's `hideEmpty` + still hides what it hides. + ## Compose the print service The service is composed explicitly; a deployment that does not list it never diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index eaca78960b5..b7455b0d826 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -56,6 +56,13 @@ The two tiers also differ in what they give you for layout, in opposite directio - **`react`** deliberately does **not** inject layout containers — you have real React, so you compose with ordinary `
` and inline `style={{ … }}`. +On the `html` tier the plain wrapper is ``: `
` is not on its roster, and a page +that writes one is refused at save time with an error that names `box`. Neither tier has a +raw-HTML block. The `html` node type, which wrote an authored HTML string into the page +unsanitised, is retired, so an `` tag is refused as an unknown component; rich text a +user edits belongs in an `html` or `richtext` field, which renders through the sanitised +field path. + ## The security gate Because the react tier runs author JavaScript in every viewer's session, with no sandbox, diff --git a/content/docs/ui/studio.mdx b/content/docs/ui/studio.mdx new file mode 100644 index 00000000000..f793b8e4c4b --- /dev/null +++ b/content/docs/ui/studio.mdx @@ -0,0 +1,241 @@ +--- +title: Studio — designing objects, flows and interfaces +navTitle: Studio +description: What Studio offers a metadata author — package scope, the object design panels, the Flow Designer, drafts and publishing, and how metadata diagnostics are surfaced +--- + +**Studio** (`/studio`) is where metadata is authored without code. Everything it edits is +the same metadata a package declares in TypeScript: an object's `validations[]` and +`actions[]`, a `hook`, a `flow`, an app, a page. It saves those edits as **drafts** of the +package that owns them, and nothing reaches users until the package is published. This +page describes how Studio behaves; [Build without code](/docs/build-without-code) is the +tour. + +## Access and package scope + +- **Access.** Studio needs the `studio.access` capability. A caller without it who opens a + Studio URL stays on that URL and sees one sentence saying Studio access is needed, with + a **Back to home** link to the declared landing. The builder is not loaded for them. +- **Package scope.** Studio always works inside one package. Metadata counts, diagnostics + and the metadata lists follow the selected package (`?package=` on the metadata + directory), and only project packages are offered: system and cloud packages never + appear. +- **Read-only packages.** The Studio home lists code and installed packages under + "Installed (read-only · browsable)". A card opens the package for browsing + (`/studio/:packageId/data`); its **Customize with an overlay** link opens the package's + metadata directory (`/apps/setup/metadata?package=:packageId`). Whether an item can take + an environment overlay is decided per metadata type (`allowEnvironmentOverlay`), not per + package, and the directory marks each type. +- **Flows in no package.** A flow that belongs to no package, such as a clone of a packaged + flow made from Setup › Packaged automation, is listed and edited at + `/studio/~org/automations`, reached from the Studio home ("Not in a package") or the + package switcher. +- **Search.** On the Studio landing, `⌘K` searches packages, their objects and their flows + by label or machine name. The Automations pillar's flow rail has its own search box over + label and machine name. + +## Drafts and publishing + +Every Studio edit is a draft of its package. The top bar's **Changes** count lists the +pending drafts and **Publish** promotes them; leaving a pillar that holds an unsent edit +asks first. In a package with no app yet, **Create app** saves the new app as a draft and +opens the **Interfaces** pillar, where apps, pages, dashboards and reports are designed. +The Console's **Edit app** button deep-links there +(`/studio/:packageId/interfaces?surface=page:NAME`). + +When the AI copilot saves a change to a live app as a draft, its card offers **Review +changes and publish** and **Undo**. Review opens the **Changes** drawer for the package +(its link is `/studio/:packageId/:tab?changes=review`); Undo discards the package's +pending drafts (`POST /api/v1/packages/:id/discard-drafts`) and asks first when other +drafts are pending. + +## Object design + +Selecting an object in the **Data** pillar (`/studio/:packageId/data`) opens two tabs over +it, **Records** and **Form**, and an **Advanced** menu with **Validations**, **Hooks**, +**Actions**, **API** and **Settings**. Validations, Hooks and Actions are each a list with +an editor driven by the metadata it edits, and each can add new entries: + +| Panel | Edits | Notes | +|:---|:---|:---| +| **Validations** | The object's `validations[]` ([Validation rules](/docs/data-modeling/validation-rules)) | Every rule type: `script`, `cross_field`, `state_machine`, `format`, `json_schema`, `conditional`. The **New** menu opens on common rules in plain words (*End date on or after start date*, *Number can't be negative*); a rule whose type carries a condition is saved only once it has one | +| **Hooks** | The separate `hook` metadata targeting this object | The form is generated from the running server's `hook` schema (`/api/v1/meta/types`), so it always matches the server's contract. Saved per hook | +| **Actions** | The object's `actions[]` ([Actions](/docs/ui/actions)) | A type-aware inspector, with a **More fields** form generated from the `action` schema so no property is un-editable | + +Validations and Actions are saved with the object's own **Save draft**. + +### Why a configured action can be missing from the app + +An action that is saved, published and placed can still appear on no surface. There are +two reasons, and the Actions panel states both (the running app states neither, because a +user should not be told about capabilities they do not have): + +- **No placement.** `locations` is empty, so the action surfaces nowhere. A + selection-only action is legitimate here: a list view's `bulkActions` places it. +- **The capability gate.** `requiredPermissions` is enforced with a `403` on the action + route and mirrored as a hide: a viewer who lacks any listed capability gets no button, + no greyed-out control and no message, at every location at once. + +The action inspector's **Placement** section names the gating capabilities, says the +action is hidden rather than disabled, and says so when the signed-in session itself lacks +one. A capability string registered nowhere is a different problem, caught by the advisory +capability-reference lint that runs over pending drafts at **Publish**. + +## The Flow Designer + +The Flow Designer is the visual editor for `flow` metadata, opened from a flow's +**Design** tab; the **Form** tab holds the same flow as an editable tree, and the two stay +in sync. Everything on the canvas is a node or edge of the flow, so the document runs +unchanged on the automation engine ([Flows](/docs/automation/flows)). + +### The canvas + +| Gesture | Result | +|:---|:---| +| Click a card | Select it and open its inspector | +| Drag a card | Reposition it | +| Hover a card, then its bottom **+** | Append a connected child node | +| Drag a card's connect handle onto another node | Connect the two. Refused, with the reason, for the node itself, a pair another connection already joins, or a node the flow does not have | +| Zoom controls / **Fit** | Scale and re-center the graph | + +A selected connection's **From** and **To** re-point it, under the same refusals. +Structural problems (an undeclared cycle, an unreachable node) show as a red ring and a +corner badge on the card and in a banner on the canvas; a banner row selects and pans to +its element. + +### Adding nodes + +**Add node** opens the palette: every node type the flow can use, grouped as **Data** +(create, update, get, delete record), **Logic** (decision, loop, set variables, parallel, +try / catch), **Human** (approval, screen), **Integration** (HTTP request, connector, +script) and **Flow** (subflow, wait, end). + +- **Search** filters across all groups, matching a node's label, hint and type as a + case-insensitive substring. `↑` / `↓` move the highlight, `Enter` inserts, `Esc` closes, + and the box takes focus when the palette opens. +- **Recently used** tops the list while the search is empty: the last five node types the + user inserted, kept per user (see [the Console](/docs/ui/console#what-each-users-own-state-keeps)). + Types from a plugin since uninstalled drop out. +- **Server-merged.** The engine publishes its registered actions at + `GET /api/v1/automation/actions`, and plugins contribute node types there. The palette + overlays them on the built-in list, taking the engine's labels and appending engine-only + types, so it matches what the connected server runs. If the endpoint is unreachable the + built-in list is used. + +### The node inspector + +The inspector shows a node's **ID**, **Label**, **Node Type** and **Configuration**. New +nodes start with the spec's defaults where it has one (a *Wait* node carries a timer, an +*HTTP* node defaults to `GET`), and what the author must still supply is marked with a red +`*`: an HTTP node's URL, a record node's object, a decision branch's label and expression, +a screen field's name. The marker follows the spec, so a key required only in some +configurations is marked only while it applies, and the spec's error shows on the node and +in **Problems** until the value is supplied. + +- **Server-driven forms.** For a node whose executor publishes a `configSchema`, the + inspector renders a form from that schema: scalars, enums as selects, typed references + as pickers, maps as key/value editors, and arrays of objects as repeaters (nested arrays + as nested repeaters). A plugin's node gets a real form without designer code. +- **Approval approvers.** The **Value** cell offers candidates from where the row's + **Type** says they live: a lookup over the directory object for `user`, `team` and + `department`, and a select over the registry's declared items for a registry-bound kind + such as `position`. A pencil toggle allows typing a value. +- **Decision branches.** The Branches editor sets each branch's label, CEL expression and + **target node** in one table; the target wires the branch's outgoing edge. The same + binding is editable from the edge's **Branch** picker, because routing lives on the + edges. +- **Assignments.** One variable per row, each value either text or a CEL expression, + chosen with the row's *Write as a CEL expression* toggle: + +```json +{ + "assignments": { + "label": "{record.name}", + "digest": { "dialect": "cel", "source": "joinNonEmpty(names, \", \")" } + } +} +``` + + Text is `{token}` interpolation, stored as typed; an expression is stored as the + `{ dialect: 'cel', source }` envelope and evaluated with the CEL standard library. The + toggle appears only on maps whose values the spec allows to be an expression. + +### Panels + +| Panel | Shows | +|:---|:---| +| **Variables** | The flow's declared variables | +| **Problems** | Structural and server validation issues; each one reveals its element on the canvas | +| **Debug** | A step-through simulator that walks the graph and highlights the active and visited nodes | +| **Runs** | The published flow's execution history, read from the engine | + +## Metadata diagnostics + +Every metadata item a package ships is validated against its spec schema when it is +loaded, and the result travels with the item as a `_diagnostics` envelope: + +```ts +interface MetadataDiagnostics { + valid: boolean; + errors?: Array<{ path: string; message: string; code?: string }>; + warnings?: Array<{ path: string; message: string; code?: string }>; +} +``` + +`valid: false` means at least one error, and whatever depends on the item (rendering, +queries, automation) is unsafe to use. Warnings are advisory: the item stays valid. +`path` is dot-delimited (`fields.email.type`, `columns.0.bind`). + +### The sweep endpoint + +`GET /api/v1/meta/diagnostics` validates every metadata type and item in one request. + +| Query | Default | Effect | +|:---|:---|:---| +| `severity` | `error` | `error` returns invalid items only; `warning` also returns items that have only warnings | +| `type` | — | One metadata type | +| `package` | — | One package | + +The response carries `entries` (each `{ type, name, diagnostics }`), `total`, +`scannedTypes`, `scannedItems`, and `stats`, a per-type `{ count, locked, packages }` that +lets a directory render counts and a package filter without another request. `total === 0` +is a usable CI gate; pass `severity=warning` to hold warnings to the same bar. + +### Where Studio shows them + +1. **The metadata directory** (`/apps/setup/metadata`). Each type tile carries the item + count, a red ⚠ count for items with errors, and an amber ⚠ count for warning-only items. + A **View all issues** link opens the governance page. +2. **A type's list.** Invalid rows get a red ⚠ and a tinted row, warning-only rows an amber + one; the header shows **Invalid** and **Warnings** counts, and hovering the ⚠ shows the + first three messages. +3. **An item's edit page.** A banner lists the first errors with their paths (and a + parallel amber banner the warnings), and the same errors are threaded into the form, so + the offending inputs show them before the first save. Typing in an input clears its + message until the next save validates again. On an object draft, every field rule + (`visibleWhen`, `readonlyWhen`, `requiredWhen`) is also linted as a CEL predicate with + the server's validators: an unknown field, or a field read bare instead of as + `record.`, shows under `fields..`. The field inspector's rule + editors give the same verdict as you type, with completion for the object's fields and + the `record`, `previous` and `parent` roots. A formula field's `expression` is linted the + same way and shows its inferred result type. +4. **The governance page** (`…/metadata/_diagnostics`). One sortable table of every invalid + item across every type, linked to each edit page, with a severity toggle for + warning-only items: the page for a release-readiness review. + +A predicate that gets past authoring is still reported at runtime; see +[When a predicate cannot be evaluated](/docs/ui/console#when-a-predicate-cannot-be-evaluated). + +### Authoring metadata that validates cleanly + +- Author with the `define*` helpers from `@objectstack/spec`, so TypeScript catches most + shape errors before they reach a server. +- Run `os validate` before publishing; it runs the validators the server runs on load. +- An environment overlay merges first and is validated after, and the edit banner shows + the effective item. If only the overlay is wrong, the package's own artifact is fine. + +## Related + +- [The Console](/docs/ui/console) — how the published metadata renders for users. +- [Flows](/docs/automation/flows) · [Actions](/docs/ui/actions) · [Validation rules](/docs/data-modeling/validation-rules) +- [Validating metadata](/docs/deployment/validating-metadata) — every author-time rule. diff --git a/scripts/docs-audit/handwritten-docs.json b/scripts/docs-audit/handwritten-docs.json index 20ddede3713..63761bff8f3 100644 --- a/scripts/docs-audit/handwritten-docs.json +++ b/scripts/docs-audit/handwritten-docs.json @@ -214,6 +214,7 @@ "content/docs/ui/actions.mdx", "content/docs/ui/apps.mdx", "content/docs/ui/audience-based-interfaces.mdx", + "content/docs/ui/console.mdx", "content/docs/ui/create-vs-edit-form.mdx", "content/docs/ui/dashboards.mdx", "content/docs/ui/doc-pages.mdx", @@ -226,6 +227,7 @@ "content/docs/ui/react-pages.mdx", "content/docs/ui/reports.mdx", "content/docs/ui/setup-app.mdx", + "content/docs/ui/studio.mdx", "content/docs/ui/translations.mdx", "content/docs/ui/views.mdx", "content/docs/upgrading.mdx"