Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/26304-screen-field-record-link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/spec': minor
'@objectstack/service-automation': minor
'@object-ui/app-shell': minor
---

feat(spec,automation,app-shell): a read-only `record_link` screen field shows a record the run created, by its display name, as a link to its record page

Clause-②: yes (widening)
Metadata-protocol: added

A screen flow can now end on a result screen that links the records it created, instead of a toast that points at nothing.

```ts
{ id: 'done', type: 'screen', config: { title: 'Lead converted', fields: [
{ name: 'account_link', label: 'Account', type: 'record_link',
reference: 'crm_account', defaultValue: { dialect: 'cel', source: 'account.id' } },
] } },
```

- **`@objectstack/spec`**: `ScreenFieldConfigSchema` gains the `type` member `'record_link'` (`SCREEN_FIELD_RECORD_LINK_TYPE`). On it, `reference` (the record's object) and `defaultValue` (a value slot that evaluates to the record's id) are required, and `required`, `options`, `placeholder`, `min` and `max` are refused, each at its own key with the exported refusal text (`SCREEN_FIELD_RECORD_LINK_REFERENCE_REQUIRED`, `SCREEN_FIELD_RECORD_LINK_DEFAULT_VALUE_REQUIRED`, `screenFieldRecordLinkRefusedKey`). The name is `record_link`, not `record`, because `record` is already an object field type (a name-keyed map of sub-objects) and a screen field's `type` uses the same vocabulary. Every other `type` value parses as before.
- **`@objectstack/service-automation`**: the resume refuses any value sent under a `record_link` field's name, `null` included and visible or not, with `400 INVALID_SCREEN_INPUT` and the field code `invalid_value` ("shows a record and takes no value"). The value is never bound to a flow variable, the refusal keeps the pause, and a resume that leaves the field out still lands. A caller's param that shares the field's name never counts as answering the screen, so a result screen always pauses.
- **`@object-ui/app-shell`**: the flow screen dialog shows a `record_link` field as the record's display name (the object's `nameField`, through the lookup widget's read-only branch) inside a link to `/apps/APP/OBJECT/record/ID`. An empty id shows "—" with no link, and an object the console cannot route to shows the name without a link. `visibleWhen` works as on any field. The dialog never seeds or posts a value for the field.
41 changes: 39 additions & 2 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,40 @@ wizard can collect everything first and write once, after its last screen:
The target `file` field's own `accept` / `maxSize` still apply at the write
that claims the file.

#### A read-only record link

`type: 'record_link'` shows a record the run already holds the id of, so a
screen flow can end on a result screen that links the records it created:

```typescript
{ id: 'create_account', type: 'create_record', config: { objectName: 'crm_account',
fields: { name: { dialect: 'cel', source: 'company' } }, outputVariable: 'account' } },
{ id: 'create_contact', type: 'create_record', config: { objectName: 'crm_contact',
fields: { last_name: { dialect: 'cel', source: 'last_name' } }, outputVariable: 'contact' } },
{ id: 'done', type: 'screen', config: { title: 'Lead converted', fields: [
{ name: 'account_link', label: 'Account', type: 'record_link',
reference: 'crm_account', defaultValue: { dialect: 'cel', source: 'account.id' } },
{ name: 'contact_link', label: 'Contact', type: 'record_link',
reference: 'crm_contact', defaultValue: { dialect: 'cel', source: 'contact.id' } },
] } },
```

- **The console shows the record's name as a link.** The flow dialog fetches the
record and shows its display name (the object's `nameField`), as a link to the
record's page. An empty id shows "—". `visibleWhen` works as on any field.
- **`reference` and `defaultValue` are required.** `reference` names the
record's object. `defaultValue` is a value slot that evaluates to the record's
id when the screen is served, usually a CEL envelope over a flow variable.
- **It takes no input.** `required`, `options`, `placeholder`, `min` and `max`
are refused on it at publish. The console posts nothing for it, and a resume
that carries any value under its name is refused with `invalid_value` inside
the run's `INVALID_SCREEN_INPUT`. The value is never bound to a flow variable.
- **The screen still ends with Submit.** A result screen is an ordinary screen:
the run completes when the user submits it. A user who follows a link first
leaves the run paused, as closing the dialog does.
- **Why not `record`?** That name is already an object field type (a name-keyed
map of sub-objects), and a screen field's `type` uses the same vocabulary.

**Screen (object form):**

Set `config.objectName`
Expand Down Expand Up @@ -1255,11 +1289,14 @@ POST /api/v1/automation/{flow}/runs/{runId}/resume
— declared fields: 'kind', 'escalation_reason'
```

Two conditions are refused, both reported at once and both with
`code: 'INVALID_SCREEN_INPUT'`:
These conditions are refused, all reported at once and all with
`code: 'INVALID_SCREEN_INPUT'` (the value shapes of `type: 'number'` and
`type: 'file'`, and the `min` / `max` bounds, are described with those fields
above):

- a **`required` field the caller was actually asked for** is missing (an empty
or blank string counts as missing);
- a value was sent under a **`type: 'record_link'` field**, which takes none;
- a key the screen **never declared** was sent.

`visibleWhen` is evaluated against the **submitted values** first, so a hidden
Expand Down
8 changes: 4 additions & 4 deletions content/docs/references/automation/builtin-node-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Field name (the flow variable the value binds to) — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
| **label** | `string` | optional | Display label |
| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field |
| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field; `record_link` shows the `reference` record whose id `defaultValue` evaluates to, by its display name, as a link to its record page, and takes no input |
| **required** | `boolean` | optional | Whether a value is required to submit |
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text |
| **defaultValue** | `any` | optional | Prefilled value: a CEL value envelope `{ dialect: 'cel', source }` evaluated when the screen is served (the client receives the value, never the envelope), or a literal written as it is — a `{…}` template token is refused |
Expand All @@ -287,7 +287,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
| **min** | `number` | optional | Minimum accepted value (numeric fields); enforced on resume |
| **max** | `number` | optional | Maximum accepted value (numeric fields); enforced on resume |
| **inlineHelpText** | `string` | optional | Help text displayed below the field |
| **reference** | `string` | optional | Target object name (snake_case) whose records a `type: 'lookup'` field picks from; REQUIRED when `type` is `lookup` |
| **reference** | `string` | optional | Target object name (snake_case) whose records a `type: 'lookup'` field picks from, or that a `type: 'record_link'` field's record belongs to; REQUIRED when `type` is `lookup` or `record_link` |


---
Expand All @@ -300,7 +300,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Field name (the flow variable the value binds to) — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
| **label** | `string` | optional | Display label |
| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field |
| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field; `record_link` shows the `reference` record whose id `defaultValue` evaluates to, by its display name, as a link to its record page, and takes no input |
| **required** | `boolean` | optional | Whether a value is required to submit |
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text |
| **defaultValue** | `any` | optional | Prefilled value: a CEL value envelope `{ dialect: 'cel', source }` evaluated when the screen is served (the client receives the value, never the envelope), or a literal written as it is — a `{…}` template token is refused |
Expand All @@ -309,7 +309,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
| **min** | `number` | optional | Minimum accepted value (numeric fields); enforced on resume |
| **max** | `number` | optional | Maximum accepted value (numeric fields); enforced on resume |
| **inlineHelpText** | `string` | optional | Help text displayed below the field |
| **reference** | `string` | optional | Target object name (snake_case) whose records a `type: 'lookup'` field picks from; REQUIRED when `type` is `lookup` |
| **reference** | `string` | optional | Target object name (snake_case) whose records a `type: 'lookup'` field picks from, or that a `type: 'record_link'` field's record belongs to; REQUIRED when `type` is `lookup` or `record_link` |

### Nested Shape: `ScreenFieldConfig.options[number]`

Expand Down
120 changes: 120 additions & 0 deletions packages/qa/dogfood/test/fixtures/flow-screen-record-link-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Flow screen record-link fixture (#25872, contract half #26304).
//
// The shape the ruling asks for: a screen flow that creates records ends on a
// result screen that links them, instead of a toast that points at nothing.
// The flow asks for a company and a contact, writes an account and a contact,
// and pauses on a last screen of two read-only `type: 'record_link'` fields
// whose `defaultValue` envelopes read the created records' ids.
//
// Both objects name their records by a field that is NOT `name`
// (`company_name`, `full_name`, ADR-0079 `nameField`), so a client that showed
// a `name` column or the raw id would show the wrong thing.

import { defineStack } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';
import type { Flow } from '@objectstack/spec/automation';

export const SrlAccount = ObjectSchema.create({
name: 'srl_account',
// [ADR-0090 D1] grandfather stamp: the gate under test is the result screen,
// not owner-sharing.
sharingModel: 'public_read_write',
label: 'Account',
pluralLabel: 'Accounts',
nameField: 'company_name',
fields: {
company_name: Field.text({ label: 'Company', required: true }),
},
});

export const SrlContact = ObjectSchema.create({
name: 'srl_contact',
sharingModel: 'public_read_write',
label: 'Contact',
pluralLabel: 'Contacts',
nameField: 'full_name',
fields: {
full_name: Field.text({ label: 'Full name', required: true }),
account: Field.lookup('srl_account', { label: 'Account' }),
},
});

const CEL = (source: string) => ({ dialect: 'cel' as const, source });

/**
* `srl_convert` — start → ask (company, contact) → create account → create
* contact → result (two record links) → end.
*/
export const flowScreenRecordLink: Flow = {
name: 'srl_convert',
label: 'Convert with a result screen',
type: 'screen',
status: 'active',
runAs: 'user',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{
id: 'ask',
type: 'screen',
label: 'Convert',
config: {
title: 'Convert',
fields: [
{ name: 'company', label: 'Company', type: 'text', required: true },
{ name: 'contact_name', label: 'Contact', type: 'text', required: true },
],
},
},
{
id: 'create_account',
type: 'create_record',
label: 'Create account',
config: { objectName: 'srl_account', fields: { company_name: CEL('company') }, outputVariable: 'account' },
},
{
id: 'create_contact',
type: 'create_record',
label: 'Create contact',
config: {
objectName: 'srl_contact',
fields: { full_name: CEL('contact_name'), account: CEL('account.id') },
outputVariable: 'contact',
},
},
{
id: 'result',
type: 'screen',
label: 'Created',
config: {
title: 'Created',
fields: [
{ name: 'account_link', label: 'Account', type: 'record_link', reference: 'srl_account', defaultValue: CEL('account.id') },
{ name: 'contact_link', label: 'Contact', type: 'record_link', reference: 'srl_contact', defaultValue: CEL('contact.id') },
],
},
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'ask' },
{ id: 'e2', source: 'ask', target: 'create_account' },
{ id: 'e3', source: 'create_account', target: 'create_contact' },
{ id: 'e4', source: 'create_contact', target: 'result' },
{ id: 'e5', source: 'result', target: 'end' },
],
};

export const screenRecordLinkStack = defineStack({
manifest: {
id: 'com.dogfood.flow-screen-record-link',
namespace: 'srl',
version: '0.0.0',
type: 'app',
name: 'Flow Screen Record Link Fixture',
description: 'A screen flow that creates two records and ends on a screen that links them.',
},
objects: [SrlAccount, SrlContact],
flows: [flowScreenRecordLink],
});
Loading
Loading