diff --git a/.changeset/26304-screen-field-record-link.md b/.changeset/26304-screen-field-record-link.md new file mode 100644 index 00000000000..1098c71d92e --- /dev/null +++ b/.changeset/26304-screen-field-record-link.md @@ -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. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index ad03ff2059c..e25eaafb082 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -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` @@ -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 diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index 19336ae7825..09afddca6b3 100644 --- a/content/docs/references/automation/builtin-node-config.mdx +++ b/content/docs/references/automation/builtin-node-config.mdx @@ -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 | @@ -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` | --- @@ -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 | @@ -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]` diff --git a/packages/qa/dogfood/test/fixtures/flow-screen-record-link-fixture.ts b/packages/qa/dogfood/test/fixtures/flow-screen-record-link-fixture.ts new file mode 100644 index 00000000000..f06c6eac950 --- /dev/null +++ b/packages/qa/dogfood/test/fixtures/flow-screen-record-link-fixture.ts @@ -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], +}); diff --git a/packages/qa/dogfood/test/flow-screen-record-link.dogfood.test.ts b/packages/qa/dogfood/test/flow-screen-record-link.dogfood.test.ts new file mode 100644 index 00000000000..3f1982cddbf --- /dev/null +++ b/packages/qa/dogfood/test/flow-screen-record-link.dogfood.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// FLOW SCREEN RECORD LINK (#25872, contract half #26304) — a screen flow that +// creates records ends on a result screen that links them. Driven end to end +// through a REAL boot: the real trigger and resume doors, real writes, and the +// record read back through the data door the console's record page reads. +// +// The chain, each link asserted where it lands rather than as "no error": +// +// • the run writes an account and a contact, then pauses on the result +// screen. Each of its two fields arrives typed `record_link`, naming its +// object (`reference`) and carrying the id of the record THIS run created +// (`defaultValue`, evaluated before the screen went on the wire), with no +// `required` to meet; +// • each id is a record of that object, readable through the data door, and +// its display name (the object's `nameField`, ADR-0079) is the name the +// author typed — what the console draws as the link text, linking to that +// object's record page; +// • a resume carrying a value under a link field's name is refused 400 +// `INVALID_SCREEN_INPUT` naming the field, and the pause survives it; +// • the resume that leaves the links out completes the run. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { screenRecordLinkStack } from './fixtures/flow-screen-record-link-fixture.js'; + +const SYS = { isSystem: true } as const; +const FLOW = 'srl_convert'; + +describe('objectstack verify FLOW SCREEN RECORD LINK (#25872): a result screen links the records the run created', () => { + let stack: VerifyStack; + let ql: any; + let token: string; + + /** Trigger the flow; returns the paused result. */ + async function start(): Promise { + const triggered = await stack.apiAs(token, 'POST', `/automation/${FLOW}/trigger`, { params: {} }); + expect(triggered.status, await triggered.clone().text()).toBeLessThan(300); + const tj = (await triggered.json()) as any; + const result = tj.result ?? tj.data ?? tj; + expect(result.status).toBe('paused'); + expect(result.runId, 'no runId on the paused result').toBeTruthy(); + return result; + } + + const resume = (runId: string, inputs: Record) => + stack.apiAs(token, 'POST', `/automation/${FLOW}/runs/${runId}/resume`, { inputs }); + + /** Answer the first screen and return the paused result screen. */ + async function convert(company: string, contactName: string): Promise<{ runId: string; screen: any }> { + const { runId } = await start(); + const answered = await resume(runId, { company, contact_name: contactName }); + expect(answered.status, await answered.clone().text()).toBeLessThan(300); + const aj = (await answered.json()) as any; + const result = aj.result ?? aj.data ?? aj; + expect(result.status, `the run did not pause on the result screen: ${JSON.stringify(result)}`).toBe('paused'); + expect(result.screen?.nodeId).toBe('result'); + return { runId: result.runId ?? runId, screen: result.screen }; + } + + /** The record behind an id, read as the console's record page reads it. */ + async function readRecord(objectName: string, id: string): Promise> { + const read = await stack.apiAs(token, 'GET', `/data/${objectName}/${id}`); + expect(read.status, await read.clone().text()).toBe(200); + const body = (await read.json()) as any; + return body.record ?? body; + } + + beforeAll(async () => { + stack = await bootStack(screenRecordLinkStack as never, { automation: true }); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('pauses on a result screen whose two fields carry the created records, each named and linkable', async () => { + const { screen } = await convert('Acme Corp', 'Ada Lovelace'); + const fields: any[] = screen.fields ?? []; + expect(fields.map((f) => [f.name, f.type, f.reference])).toEqual([ + ['account_link', 'record_link', 'srl_account'], + ['contact_link', 'record_link', 'srl_contact'], + ]); + expect(fields.every((f) => f.required !== true), 'a record link asks nothing of the user').toBe(true); + + // The ids are the records this run wrote — no other row could carry these names. + const [accounts, contacts] = await Promise.all([ + ql.find('srl_account', { where: { company_name: 'Acme Corp' }, context: SYS }), + ql.find('srl_contact', { where: { full_name: 'Ada Lovelace' }, context: SYS }), + ]); + expect(accounts).toHaveLength(1); + expect(contacts).toHaveLength(1); + const [accountLink, contactLink] = fields; + expect(String(accountLink.defaultValue)).toBe(String(accounts[0].id)); + expect(String(contactLink.defaultValue)).toBe(String(contacts[0].id)); + + // What the link shows: each record's `nameField` value, read through the + // data door the record page it links to reads. + const account = await readRecord('srl_account', String(accountLink.defaultValue)); + expect(account.company_name).toBe('Acme Corp'); + const contact = await readRecord('srl_contact', String(contactLink.defaultValue)); + expect(contact.full_name).toBe('Ada Lovelace'); + }, 60_000); + + it('refuses a resume that carries a link field\'s value, keeps the pause, and completes on the clean resume', async () => { + const { runId, screen } = await convert('Globex', 'Grace Hopper'); + const createdAccountId = String(screen.fields[0].defaultValue); + + for (const inputs of [{ account_link: createdAccountId }, { contact_link: null }]) { + const refused = await resume(runId, inputs); + expect(refused.status, JSON.stringify(inputs)).toBe(400); + const body = (await refused.json()) as any; + expect(body.error?.code).toBe('INVALID_SCREEN_INPUT'); + expect(String(body.error?.message)).toContain( + `Screen field "${Object.keys(inputs)[0]}" shows a record and takes no value`, + ); + } + + // Refused, not consumed: the resume that leaves the links out finishes the run. + const done = await resume(runId, {}); + expect(done.status, await done.clone().text()).toBeLessThan(300); + const dj = (await done.json()) as any; + const result = dj.result ?? dj.data ?? dj; + expect(result.status ?? 'completed').toBe('completed'); + expect(await ql.find('srl_account', { where: { company_name: 'Globex' }, context: SYS })).toHaveLength(1); + }, 60_000); +}); diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index 12c84f17a62..ecb18c3fd89 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -68,9 +68,9 @@ import { judgeHeadlessScreen } from '../screen-input-contract.js'; */ const LOOKUP_TARGET_COLUMN = { type: 'string', - title: 'Lookup object', + title: 'Target object', xRef: { kind: 'object' }, - description: "Object whose records a `lookup` field picks from. Required on a `lookup` field — a picker with no target object resolves nothing.", + description: "Object whose records a `lookup` field picks from, or that a `record_link` field's record belongs to. Required on both — a picker with no target object resolves nothing, and a link with none cannot name its record.", }; /** How a refusal names a value that is not a record id — `null`, a number (`42`), a map. */ diff --git a/packages/services/service-automation/src/builtin/screen-record-link-resume.test.ts b/packages/services/service-automation/src/builtin/screen-record-link-resume.test.ts new file mode 100644 index 00000000000..6b9f3587958 --- /dev/null +++ b/packages/services/service-automation/src/builtin/screen-record-link-resume.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A screen flow ends on a result screen that links the records it created + * (ruling B on the screen-flow result screen). + * + * The flow writes two records, then pauses on a screen of two + * `type: 'record_link'` fields whose `defaultValue` envelopes read the created + * records' ids. Pinned here, at the engine: + * - the paused screen carries each field typed `record_link`, with its + * `reference` and its `defaultValue` already evaluated to the id; + * - a resume carrying a value under a link field's name is refused with + * `INVALID_SCREEN_INPUT`, nothing after the screen runs, and the pause + * survives; + * - the resume that leaves the links out completes, and the flow variable the + * id came from is the record the run created — the account link is named + * after that very variable (`account`), so a bound refused value would have + * replaced the record with a string and the audit write after the screen + * would not read the created id. + */ + +import { describe, it, expect } from 'vitest'; +import { AutomationEngine } from '../engine.js'; +import { installBuiltinNodes } from './index.js'; + +function silentLogger(): any { + const l: any = { info() {}, warn() {}, error() {}, debug() {} }; + l.child = () => l; + return l; +} + +/** A data engine that mints `_` ids and records every insert. */ +function fakeData() { + const inserts: Array<{ obj: string; fields: Record }> = []; + const data: any = { + async insert(obj: string, fields: Record) { + inserts.push({ obj, fields }); + return { id: `${obj}_${inserts.length}`, ...fields }; + }, + }; + return { data, inserts }; +} + +const CEL = (source: string) => ({ dialect: 'cel', source }); + +/** start → create account → create contact → result screen (two links) → audit write → end. */ +function convertFlow() { + return { + name: 'convert_with_result', + label: 'Convert with result screen', + type: 'screen', + status: 'active', + runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'acc', type: 'create_record', label: 'Account', config: { objectName: 'crm_account', fields: { name: 'Acme' }, outputVariable: 'account' } }, + { id: 'con', type: 'create_record', label: 'Contact', config: { objectName: 'crm_contact', fields: { name: 'Ada' }, outputVariable: 'contact' } }, + { + id: 'result', type: 'screen', label: 'Created', + config: { + fields: [ + { name: 'account', label: 'Account', type: 'record_link', reference: 'crm_account', defaultValue: CEL('account.id') }, + { name: 'contact_link', label: 'Contact', type: 'record_link', reference: 'crm_contact', defaultValue: CEL('contact.id') }, + ], + }, + }, + { id: 'audit', type: 'create_record', label: 'Audit', config: { objectName: 'audit', fields: { account: CEL('account.id') } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'acc' }, + { id: 'e2', source: 'acc', target: 'con' }, + { id: 'e3', source: 'con', target: 'result' }, + { id: 'e4', source: 'result', target: 'audit' }, + { id: 'e5', source: 'audit', target: 'end' }, + ], + }; +} + +function makeEngine() { + const { data, inserts } = fakeData(); + const engine = new AutomationEngine(silentLogger()); + installBuiltinNodes(engine, { logger: silentLogger(), getService: (n: string) => (n === 'data' ? data : undefined) } as any); + engine.registerFlow('convert_with_result', convertFlow() as any); + return { engine, inserts }; +} + +describe('a screen flow ends on a screen that links the records it created', () => { + it('pauses with both fields typed `record_link`, each carrying its object and the created id', async () => { + const { engine, inserts } = makeEngine(); + const started = await engine.execute('convert_with_result', {} as any); + expect(started.status).toBe('paused'); + expect(started.screen?.fields).toEqual([ + expect.objectContaining({ name: 'account', type: 'record_link', reference: 'crm_account', defaultValue: 'crm_account_1' }), + expect.objectContaining({ name: 'contact_link', type: 'record_link', reference: 'crm_contact', defaultValue: 'crm_contact_2' }), + ]); + expect(started.screen?.fields.every((f) => f.required === false)).toBe(true); + expect(inserts.map((i) => i.obj)).toEqual(['crm_account', 'crm_contact']); + }); + + it('refuses a resume carrying a link field\'s value, runs nothing after the screen, and keeps the pause', async () => { + const { engine, inserts } = makeEngine(); + const { runId } = await engine.execute('convert_with_result', {} as any); + + const refused = await engine.resume(runId!, { variables: { account: 'crm_account_999' } }); + expect(refused.success).toBe(false); + expect(refused.code).toBe('INVALID_SCREEN_INPUT'); + expect(refused.error).toContain('Screen field "account" shows a record and takes no value'); + expect(inserts.map((i) => i.obj)).toEqual(['crm_account', 'crm_contact']); + + // The refusal consumed nothing, and bound nothing: the clean resume + // completes and the next node reads `account.id` — the record the run + // created, not the string the refused resume sent under the same name. + const resumed = await engine.resume(runId!, { variables: {} }); + expect(resumed.success, resumed.error).toBe(true); + expect(inserts[2]).toEqual({ obj: 'audit', fields: { account: 'crm_account_1' } }); + }); +}); diff --git a/packages/services/service-automation/src/screen-input-contract.ts b/packages/services/service-automation/src/screen-input-contract.ts index 753e36a8734..95952de6646 100644 --- a/packages/services/service-automation/src/screen-input-contract.ts +++ b/packages/services/service-automation/src/screen-input-contract.ts @@ -25,6 +25,7 @@ import { isDeepStrictEqual } from 'node:util'; import type { ScreenFieldSpec, ScreenSpec } from '@objectstack/spec/contracts'; import type { FieldErrorCode } from '@objectstack/spec/api'; +import { SCREEN_FIELD_RECORD_LINK_TYPE } from '@objectstack/spec/automation'; import { isFileIdToken } from '@objectstack/spec/data'; /** One violation of a screen's declared field contract. */ @@ -33,8 +34,8 @@ export interface ScreenInputIssue { field: string; /** * Which constraint the bag violated, from the field-level catalog - * (ADR-0114 D2) — `required`, `invalid_type`, `min_value`, `max_value` and - * `unknown_field`. Typed as `FieldErrorCode` rather than a local literal + * (ADR-0114 D2) — `required`, `invalid_type`, `invalid_value`, `min_value`, + * `max_value` and `unknown_field`. Typed as `FieldErrorCode` rather than a local literal * union for the reason `ActionParamIssue` gives: a screen field, an action * param and a record column must not drift into three vocabularies for the * same conditions. ⛔ Every member here is one the catalog ALREADY declares — @@ -73,6 +74,15 @@ const SCREEN_FIELD_VALUE_DOMAINS: ReadonlyMap