diff --git a/.changeset/25801-flow-screen-file-field.md b/.changeset/25801-flow-screen-file-field.md new file mode 100644 index 00000000000..f71ba8e17bb --- /dev/null +++ b/.changeset/25801-flow-screen-file-field.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +'@object-ui/app-shell': minor +--- + +feat(spec,automation,app-shell): a flow screen field typed `file` collects an upload, and the run resumes with the uploaded file's id + +Clause-②: yes (narrowing) +Metadata-protocol: semantic + + + +A wizard can now ask for a file before the record that will own it exists, and write everything after its last screen. + +- **`@objectstack/spec`**: `ScreenFieldConfigSchema.type` (and the `ScreenFieldSpec` contract) documents `'file'` as the second value domain beside `'number'`. A `type: 'file'` field renders the platform upload widget. The upload goes through the file service and lands as a `sys_file` that no record owns yet. The resume carries that file's id, which is ADR-0104 D3's stored form, and nothing else. A later `create_record` or `update_record` node writes the id into an object's `file` field, and that write claims the file for the record. Under the default `runAs: 'user'`, the claim first makes the download door's read check as the run's user; a `runAs: 'system'` run's write is not checked, as no system write is. +- **`@objectstack/service-automation`**: the resume re-checks a `type: 'file'` field as a value domain. A value that is not a file id, judged by the spec's `isFileIdToken` (the same predicate the claim path reads), is refused with `400 INVALID_SCREEN_INPUT` and the field code `invalid_type` ("must be the id of an uploaded file"). That covers a URL, the expanded `{ id, name, url, … }` read form, a list and a number. The value is never reduced to an id. The refusal writes nothing and keeps the pause, so a corrected resume still lands. An empty optional field and a hidden field are not judged. `image`, `avatar`, `video` and `audio` stay open widget hints. +- **`@object-ui/app-shell`**: the flow screen dialog renders the object form's `FileField` for a `type: 'file'` field and uploads through the console's `UploadProvider`. While an upload is in flight, Submit is disabled and reads "Uploading…". The resume posts each file field as its bare id, and a prefilled expanded value is reduced to its id first. + +**BREAKING** (a resume-acceptance narrowing) for a flow whose `screen` node already declares a field `type: 'file'`. None was found in this repository's examples or packages. + +- **Before.** `type: 'file'` was an open widget hint the console did not know, so the field rendered a plain text input, and `POST /automation/:name/runs/:runId/resume` accepted any value for it. +- **Now.** The field renders the upload widget, and the resume refuses any value that is not a file id with `400 INVALID_SCREEN_INPUT` (`invalid_type`). The refusal keeps the pause. +- **Fix.** A caller that resumes the run itself, without the console, uploads the file through the file service first and posts the minted `sys_file` id for the field: FROM `{ "contract_file": "https://…/nda.pdf" }` TO `{ "contract_file": "FILE_ID" }`. A field that was never meant to collect an upload takes another `type`, such as `text`. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 11f38e7d018..ad03ff2059c 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -785,6 +785,39 @@ and `placeholder`, and the console's flow runner overlays it in the active locale — see the flows row in [Translations](/docs/ui/translations#what-you-can-translate). +#### A file field + +`type: 'file'` asks for a file before the record that will own it exists, so a +wizard can collect everything first and write once, after its last screen: + +```typescript +{ id: 'ask', type: 'screen', config: { fields: [ + { name: 'contract_name', label: 'Name', type: 'text', required: true }, + { name: 'contract_file', label: 'Signed copy', type: 'file', required: true }, +] } }, +{ id: 'write', type: 'create_record', config: { objectName: 'contract', + fields: { name: { dialect: 'cel', source: 'contract_name' }, doc: { dialect: 'cel', source: 'contract_file' } } } }, +``` + +- **The console uploads, the run receives the file's id.** The flow dialog + renders the platform upload widget and uploads through the file service. The + file lands as a `sys_file` that no record owns yet, and Submit stays disabled + until the upload settles. The resume carries the file's **id**, the stored + form of a `file` field. +- **The write after the screen claims the file.** Writing the id into an + object's `file` field makes that record the file's one owner. Under the + default `runAs: 'user'` the write first checks that the run's user may read + the file, as the download door does. A `runAs: 'system'` run's write is not + checked, as no system write is. +- **Anything but an id is refused on resume.** Like `type: 'number'`, `file` is a + shape `validateScreenInputs` checks. A URL, the expanded + `{ id, name, url, … }` read form, a list or a number is refused with + `invalid_type` inside the run's `INVALID_SCREEN_INPUT`, never reduced to an id. + The run stays parked, and the corrected submission still lands. +- **One file per field.** A screen field has no `multiple`, `accept` or `maxSize`. + The target `file` field's own `accept` / `maxSize` still apply at the write + that claims the file. + **Screen (object form):** Set `config.objectName` diff --git a/content/docs/references/automation/builtin-node-config.mdx b/content/docs/references/automation/builtin-node-config.mdx index 64f1b558148..19336ae7825 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 | +| **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 | | **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 | @@ -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 | +| **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 | | **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 | diff --git a/packages/qa/dogfood/test/fixtures/flow-screen-file-fixture.ts b/packages/qa/dogfood/test/fixtures/flow-screen-file-fixture.ts new file mode 100644 index 00000000000..a5ad9d5550d --- /dev/null +++ b/packages/qa/dogfood/test/fixtures/flow-screen-file-fixture.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Flow screen file-intake fixture (#25801). +// +// The shape the card asks for: a wizard that collects everything first — +// including a file — and writes at the end. The record that will own the file +// does not exist while the screen is open, so the upload lands as a `sys_file` +// no record owns yet (ADR-0104 D3), the run resumes with that file's id, and +// the `create_record` after the screen writes the id into the record's `file` +// field — the write that claims the file for the record. +// +// The flow runs as the user (`runAs: 'user'`), so the claiming write is the +// caller's own and the claim is read-judged as #25692 asks; a member, not the +// seeded admin, drives it, so that judgement is a real one. + +import { defineStack } from '@objectstack/spec'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; +import type { Flow } from '@objectstack/spec/automation'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; + +/** The record the end of the wizard writes, owning the file the screen collected. */ +export const SfiContract = ObjectSchema.create({ + name: 'sfi_contract', + // [ADR-0090 D1] grandfather stamp: the gate under test is the screen's file + // intake and the claim, not owner-sharing. + sharingModel: 'public_read_write', + label: 'Contract', + pluralLabel: 'Contracts', + fields: { + name: Field.text({ label: 'Name', required: true }), + doc: Field.file({ label: 'Signed copy' }), + }, +}); + +/** + * `sfi_contract_intake` — start → screen (name, file) → create_record → end. + * + * Nothing is written before the screen is answered: the record and the claim + * both come from the one write after it. + */ +export const flowScreenFileIntake: Flow = { + name: 'sfi_contract_intake', + label: 'Contract intake', + type: 'screen', + status: 'active', + runAs: 'user', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'ask', + type: 'screen', + label: 'New contract', + config: { + title: 'New contract', + fields: [ + { name: 'contract_name', label: 'Name', type: 'text', required: true }, + { name: 'contract_file', label: 'Signed copy', type: 'file', required: true }, + ], + }, + }, + { + id: 'write', + type: 'create_record', + label: 'Create contract', + config: { + objectName: 'sfi_contract', + fields: { + name: { dialect: 'cel', source: 'contract_name' }, + doc: { dialect: 'cel', source: 'contract_file' }, + }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'ask' }, + { id: 'e2', source: 'ask', target: 'write' }, + { id: 'e3', source: 'write', target: 'end' }, + ], +}; + +/** What a fresh, grant-less member resolves to: create and read on the contract. */ +export const sfiMemberSet: PermissionSet = PermissionSetSchema.parse({ + name: 'sfi_fixture_member', + label: 'Screen file intake fixture member', + objects: { + sfi_contract: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + }, +}); + +/** The platform defaults (the seeded admin keeps full access) plus the member fallback above. */ +export function screenFileFixtureSecurity(): SecurityPlugin { + return new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, sfiMemberSet], + fallbackPermissionSet: sfiMemberSet.name, + }); +} + +export const screenFileStack = defineStack({ + manifest: { + id: 'com.dogfood.flow-screen-file', + namespace: 'sfi', + version: '0.0.0', + type: 'app', + name: 'Flow Screen File Intake Fixture', + description: 'A screen flow that collects a file before the record that owns it exists, then writes and claims it.', + }, + objects: [SfiContract], + flows: [flowScreenFileIntake], +}); diff --git a/packages/qa/dogfood/test/flow-screen-file-intake.dogfood.test.ts b/packages/qa/dogfood/test/flow-screen-file-intake.dogfood.test.ts new file mode 100644 index 00000000000..60e2070c53a --- /dev/null +++ b/packages/qa/dogfood/test/flow-screen-file-intake.dogfood.test.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// FLOW SCREEN FILE INTAKE (#25801) — a flat screen collects a file before the +// record that will own it exists, and the write after the screen claims it. +// Driven end to end through a REAL boot: real storage adapter, real bytes on +// disk, the real trigger and resume doors, as a plain member. +// +// The chain, each link asserted where it lands rather than as "no error": +// +// • the paused screen tells a client the field is a FILE (`type: 'file'` on +// the wire), so the console knows to render the upload widget; +// • the upload lands as a `sys_file` no record owns yet — the one-file, +// one-owner rule met by unattached-then-claimed (ADR-0104 D3); +// • the resume door admits the file's id and nothing else: a URL is refused +// 400 `INVALID_SCREEN_INPUT` before anything is written, and the pause +// survives it; +// • the resume with the id runs the `create_record` after the screen, which +// stores the id in the record's `file` field and CLAIMS the file for that +// record — `ref_object` / `ref_id` / `ref_field` on the `sys_file` row. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { StorageServicePlugin } from '@objectstack/service-storage'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { screenFileStack, screenFileFixtureSecurity } from './fixtures/flow-screen-file-fixture.js'; + +const SYS = { isSystem: true } as const; +const FLOW = 'sfi_contract_intake'; + +describe('objectstack verify FLOW SCREEN FILE INTAKE (#25801): a screen collects a file, the write after it claims the file', () => { + let stack: VerifyStack; + let ql: any; + let memberTok: string; + + /** Upload real bytes as `token` and return the `sys_file` id (scope `user`, the field lineage). */ + async function uploadFile(token: string, name: string): Promise { + const auth = { Authorization: `Bearer ${token}` }; + const presign = await stack.api('/storage/upload/presigned', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...auth }, + body: JSON.stringify({ filename: name, mimeType: 'application/pdf', size: 5, scope: 'user' }), + }); + expect(presign.status, `presign: ${await presign.clone().text()}`).toBe(200); + const { data } = (await presign.json()) as any; + expect(data.fileId, 'presign carried no fileId').toBeTruthy(); + const put = await stack.raw(String(data.uploadUrl).replace(/^https?:\/\/[^/]+/, ''), { + method: 'PUT', + headers: data.headers ?? { 'content-type': 'application/pdf' }, + body: '%PDF-', + }); + expect(put.status, 'raw PUT').toBeLessThan(300); + const complete = await stack.api('/storage/upload/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...auth }, + body: JSON.stringify({ fileId: data.fileId }), + }); + expect(complete.status, `complete: ${await complete.clone().text()}`).toBe(200); + return String(data.fileId); + } + + const file = (id: string) => ql.findOne('sys_file', { where: { id }, context: SYS }); + const contractsNamed = (name: string) => ql.find('sfi_contract', { where: { name }, context: SYS }); + + /** Trigger the intake flow as `token`; returns the paused result. */ + async function start(token: string): 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 = (token: string, runId: string, inputs: Record) => + stack.apiAs(token, 'POST', `/automation/${FLOW}/runs/${runId}/resume`, { inputs }); + + beforeAll(async () => { + const rootDir = mkdtempSync(join(tmpdir(), 'sfi-dogfood-')); + stack = await bootStack(screenFileStack as never, { + automation: true, + security: screenFileFixtureSecurity(), + extraPlugins: [ + // The `sys_migration` flag ledger the file-reference lifecycle reads (#4243). + new PlatformObjectsPlugin(), + new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }), + ], + }); + // The seeded admin first, so the sign-up below is a plain member who + // resolves to the fixture's fallback grant. + await stack.signIn(); + memberTok = await stack.signUp('sfi-member@verify.test'); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('the paused screen carries the field as `type: \'file\'`, and nothing is written yet', async () => { + const paused = await start(memberTok); + const field = (paused.screen?.fields ?? []).find((f: any) => f.name === 'contract_file'); + expect(field, `no contract_file on the paused screen: ${JSON.stringify(paused.screen)}`).toBeTruthy(); + expect(field.type).toBe('file'); + expect(field.required).toBe(true); + expect(await contractsNamed('never written')).toEqual([]); + }); + + it('an upload lands as a file no record owns yet', async () => { + const fileId = await uploadFile(memberTok, 'unowned.pdf'); + const row = await file(fileId); + expect(row, 'the upload left no sys_file row').toBeTruthy(); + expect(row.ref_object ?? null, 'a fresh upload is owned by no record').toBeNull(); + expect(row.ref_id ?? null).toBeNull(); + }); + + it('refuses a resume that carries a URL, writes nothing, and keeps the pause for the id', async () => { + const fileId = await uploadFile(memberTok, 'nda.pdf'); + const { runId } = await start(memberTok); + + for (const wrong of [`/api/v1/storage/files/${fileId}`, { id: fileId, name: 'nda.pdf', url: '/x' }]) { + const refused = await resume(memberTok, runId, { contract_name: 'NDA refused', contract_file: wrong }); + expect(refused.status, JSON.stringify(wrong)).toBe(400); + const text = await refused.text(); + expect(text).toContain('INVALID_SCREEN_INPUT'); + expect(text).toContain('contract_file'); + } + expect(await contractsNamed('NDA refused'), 'a refused resume wrote a record').toEqual([]); + expect((await file(fileId)).ref_id ?? null, 'a refused resume claimed the file').toBeNull(); + + // Refused, not consumed: the corrected submission lands on the same run. + const resumed = await resume(memberTok, runId, { contract_name: 'NDA refused', contract_file: fileId }); + expect(resumed.status, await resumed.clone().text()).toBeLessThan(300); + expect(await contractsNamed('NDA refused')).toHaveLength(1); + }); + + it('a resume with the file id writes the record and claims the file for it', async () => { + const fileId = await uploadFile(memberTok, 'msa.pdf'); + const { runId } = await start(memberTok); + + const resumed = await resume(memberTok, runId, { contract_name: 'MSA 2026', contract_file: fileId }); + expect(resumed.status, await resumed.clone().text()).toBeLessThan(300); + + // The record the end of the wizard wrote holds the file — the stored id, + // which the read path expands into the `{ id, name, url, … }` read form. + const rows = await contractsNamed('MSA 2026'); + expect(rows).toHaveLength(1); + const recordId = String(rows[0].id); + expect(rows[0].doc).toMatchObject({ id: fileId, name: 'msa.pdf' }); + + // …and that write claimed the file for the record: one file, one owner. + const claimed = await file(fileId); + expect(claimed.status).toBe('committed'); + expect(claimed.ref_object).toBe('sfi_contract'); + expect(String(claimed.ref_id)).toBe(recordId); + expect(claimed.ref_field).toBe('doc'); + + // The member reads their record back with the file in it, through the data door. + const read = await stack.apiAs(memberTok, 'GET', `/data/sfi_contract/${recordId}`); + expect(read.status, await read.clone().text()).toBe(200); + const body = (await read.json()) as any; + expect((body.record ?? body).doc).toMatchObject({ id: fileId, name: 'msa.pdf' }); + }, 60_000); +}); diff --git a/packages/services/service-automation/src/builtin/screen-file-field-resume.test.ts b/packages/services/service-automation/src/builtin/screen-file-field-resume.test.ts new file mode 100644 index 00000000000..08ba1c6ac3d --- /dev/null +++ b/packages/services/service-automation/src/builtin/screen-file-field-resume.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A flat screen collects a file, and the node after it writes the file's id + * into a record (#25801). + * + * The shape the card asks for: a wizard that asks everything first and writes + * at the end. The screen declares a `type: 'file'` field, the console uploads + * through the file service (a `sys_file` no record owns yet) and resumes with + * the file's id, and a `create_record` after the screen writes that id into + * the record's `file` field — the write that claims the file (ADR-0104 D3; the + * claim itself is `service-storage`'s and is proven end to end in the dogfood + * suite's `flow-screen-file-intake` file). + * + * Pinned here, at the engine: + * - the paused screen carries `type: 'file'` on the wire, so a client knows to + * render the upload widget; + * - a resume that is not a file id is refused with `INVALID_SCREEN_INPUT` + * before anything is written, and the pause survives for a corrected resume; + * - a resume with the id reaches the write with the id, unchanged. + */ + +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 records every insert's object and fields. */ +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}_1` }; + }, + }; + return { data, inserts }; +} + +const CEL = (source: string) => ({ dialect: 'cel', source }); +const FILE_ID = 'V1StGXR8_Z5jdHi6B-myT'; + +/** start → screen (title, file) → create_record (writes both) → end. */ +function intakeFlow() { + return { + name: 'contract_intake', + label: 'Contract intake', + type: 'screen', + status: 'active', + runAs: 'system', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'ask', type: 'screen', label: 'First version', + config: { + fields: [ + { name: 'title', label: 'Title', type: 'text', required: true }, + { name: 'contract_file', label: 'Contract', type: 'file', required: true }, + ], + }, + }, + { + id: 'write', type: 'create_record', label: 'Create version', + config: { objectName: 'contract_version', fields: { title: CEL('title'), file: CEL('contract_file') } }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'ask' }, + { id: 'e2', source: 'ask', target: 'write' }, + { id: 'e3', source: 'write', 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('contract_intake', intakeFlow() as any); + return { engine, inserts }; +} + +describe('a flat screen collects a file before the record that owns it exists (#25801)', () => { + it('pauses with the field typed `file` on the wire', async () => { + const { engine, inserts } = makeEngine(); + const started = await engine.execute('contract_intake', {} as any); + expect(started.status).toBe('paused'); + const field = started.screen?.fields.find((f) => f.name === 'contract_file'); + expect(field).toMatchObject({ name: 'contract_file', type: 'file', required: true }); + // Nothing is written before the last screen is answered. + expect(inserts).toEqual([]); + }); + + it('refuses a resume that is not a file id, writes nothing, and keeps the pause', async () => { + const { engine, inserts } = makeEngine(); + const { runId } = await engine.execute('contract_intake', {} as any); + + const refused = await engine.resume(runId!, { + variables: { title: 'NDA', contract_file: 'https://cdn.example.com/nda.pdf' }, + }); + expect(refused.success).toBe(false); + expect(refused.code).toBe('INVALID_SCREEN_INPUT'); + expect(refused.error).toContain('Screen field "contract_file" must be the id of an uploaded file'); + expect(inserts).toEqual([]); + + // The refusal consumed nothing: the corrected submission still lands. + const resumed = await engine.resume(runId!, { variables: { title: 'NDA', contract_file: FILE_ID } }); + expect(resumed.success, resumed.error).toBe(true); + expect(inserts).toEqual([{ obj: 'contract_version', fields: { title: 'NDA', file: FILE_ID } }]); + }); + + it('hands the next node the file id unchanged', async () => { + const { engine, inserts } = makeEngine(); + const { runId } = await engine.execute('contract_intake', {} as any); + const resumed = await engine.resume(runId!, { variables: { title: 'MSA', contract_file: FILE_ID } }); + expect(resumed.success, resumed.error).toBe(true); + expect(inserts).toHaveLength(1); + expect(inserts[0]!.fields.file).toBe(FILE_ID); + }); +}); diff --git a/packages/services/service-automation/src/screen-input-contract.test.ts b/packages/services/service-automation/src/screen-input-contract.test.ts index 8d5bb4be211..bd4ef523f61 100644 --- a/packages/services/service-automation/src/screen-input-contract.test.ts +++ b/packages/services/service-automation/src/screen-input-contract.test.ts @@ -88,10 +88,11 @@ describe('validateScreenInputs — the declared bound pair (#17306)', () => { } }); - it('reads only `type: \'number\'` as a value domain — every other widget hint stays open', () => { - // The closure is one member of an open vocabulary, not a new rule that - // every `type` constrains its value. A bound on a non-numeric field still - // constrains nothing, exactly as `FieldSchema.min`/`.max` do. + it('reads only the closed value-domain members as value domains — every other widget hint stays open', () => { + // The closure is a closed set of members of an open vocabulary (`number`, + // and since #25801 `file` — `screen-input-file-field.test.ts`), not a new + // rule that every `type` constrains its value. A bound on a non-numeric + // field still constrains nothing, exactly as `FieldSchema.min`/`.max` do. const texty: ScreenFieldSpec = { name: 'discount', type: 'text', min: 0, max: 20 }; expect(validateScreenInputs([texty], { discount: 'twenty' }, ALWAYS_VISIBLE)).toEqual([]); const untyped: ScreenFieldSpec = { name: 'discount', min: 0, max: 20 }; diff --git a/packages/services/service-automation/src/screen-input-contract.ts b/packages/services/service-automation/src/screen-input-contract.ts index ba76f1f7367..753e36a8734 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 { isFileIdToken } from '@objectstack/spec/data'; /** One violation of a screen's declared field contract. */ export interface ScreenInputIssue { @@ -57,6 +58,21 @@ function isPresent(v: unknown): boolean { return v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === ''); } +/** + * The closed set of screen-field `type` members that are VALUE domains, each + * with the predicate a present value must meet and the sentence a value that + * fails it is refused with. Every other member is a widget hint and constrains + * nothing — see {@link validateScreenInputs} for why the set is closed. + */ +const SCREEN_FIELD_VALUE_DOMAINS: ReadonlyMap boolean; expected: string }> = + new Map([ + ['number', { admits: (value) => typeof value === 'number' && Number.isFinite(value), expected: 'a number' }], + // [#25801] The uploaded `sys_file` id — ADR-0104 D3's stored form — judged + // by the spec's one arbiter, the predicate the record write's claim path + // reads too, so an id this door admits is one a later write can claim. + ['file', { admits: isFileIdToken, expected: 'the id of an uploaded file' }], + ]); + /** * Whether a screen surfaces a field contract worth enforcing. * @@ -85,7 +101,8 @@ export function screenDeclaresInputContract(screen: ScreenSpec | undefined): boo * * Enforced, and nothing beyond it: * - `required` presence for every field the caller was actually asked for; - * - the VALUE SHAPE of a `type: 'number'` field (#17306); + * - the VALUE SHAPE of a `type: 'number'` field (#17306) and of a + * `type: 'file'` field (#25801); * - the declared numeric bound pair `min` / `max` (#17306); * - undeclared keys. * @@ -120,9 +137,27 @@ export function screenDeclaresInputContract(screen: ScreenSpec | undefined): boo * ⚠️ Narrow on purpose in two directions. It keys off `type: 'number'`, not * off the presence of a bound: a bound declared on a non-numeric field still * constrains nothing (`FieldSchema.min`/`.max` are unconditioned on `type` - * too), and no other widget hint is read as a value domain. And it is a - * PRESENCE-conditioned check, like the bound: an absent value is `required`'s - * question, and a field the caller was never shown is nobody's. + * too). And it is a PRESENCE-conditioned check, like the bound: an absent + * value is `required`'s question, and a field the caller was never shown is + * nobody's. + * + * `'file'` is the second closed member (#25801), and for the same reason: + * its meaning is a value domain, not a rendering preference. The client + * renders the platform upload widget, the upload lands as a `sys_file` no + * record owns yet, and the resume carries that file's id — ADR-0104 D3's + * stored form, so a later node can write it into an object's `file` field, + * whose write claims the file. A present value that is not a file id (a URL, + * the expanded `{ id, name, url }` read form, a number) is refused with the + * same `invalid_type`, ⛔ never reduced to an id here: the console reduces its + * own value before it posts, and a caller that skips the console has sent the + * wrong shape. The id is judged by the spec's `isFileIdToken`, the one + * predicate the claim path reads, so the two cannot disagree about what a + * file reference is. Whether the CALLER may read that file is the claiming + * write's question, asked there under the write's own caller (#25692) — not + * this door's, which holds no storage service. + * + * No other widget hint is read as a value domain: the set is + * {@link SCREEN_FIELD_VALUE_DOMAINS}, and a member joins it by ruling. */ export function validateScreenInputs( fields: readonly ScreenFieldSpec[], @@ -149,13 +184,15 @@ export function validateScreenInputs( }); } - // Value shape for a `type: 'number'` field (#17306, ruling A′). Its own pass, - // BEFORE the bound: a value that is not a number cannot be compared against - // one, so the shape is the first thing true or false about it. A caller that - // posts `"25"` under a `max` of 20 gets one issue naming the shape, not a - // silent pass and not a bound verdict computed on a string. + // Value shape for a value-domain `type` — `'number'` (#17306, ruling A′) and + // `'file'` (#25801). Its own pass, BEFORE the bound: a value that is not a + // number cannot be compared against one, so the shape is the first thing + // true or false about it. A caller that posts `"25"` under a `max` of 20 gets + // one issue naming the shape, not a silent pass and not a bound verdict + // computed on a string. for (const field of declared.values()) { - if (field.type !== 'number') continue; + const domain = typeof field.type === 'string' ? SCREEN_FIELD_VALUE_DOMAINS.get(field.type) : undefined; + if (!domain) continue; const value = bag[field.name]; // Absent is `required`'s question (see the bound pass below for the same // split), and a field the user was never shown is neither's. @@ -163,11 +200,11 @@ export function validateScreenInputs( if (field.visibleWhen != null && String(field.visibleWhen).trim() !== '') { if (visibility(field) !== true) continue; } - if (typeof value === 'number' && Number.isFinite(value)) continue; + if (domain.admits(value)) continue; issues.push({ field: field.name, code: 'invalid_type', - message: `Screen field "${field.name}" must be a number`, + message: `Screen field "${field.name}" must be ${domain.expected}`, }); } diff --git a/packages/services/service-automation/src/screen-input-file-field.test.ts b/packages/services/service-automation/src/screen-input-file-field.test.ts new file mode 100644 index 00000000000..a468e0b30a5 --- /dev/null +++ b/packages/services/service-automation/src/screen-input-file-field.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A `type: 'file'` screen field is a VALUE domain on resume (#25801). + * + * The console renders the platform upload widget for it, the upload lands as a + * `sys_file` no record owns yet, and the resume carries that file's id — + * ADR-0104 D3's stored form — so a later node can write it into an object's + * `file` field and that write claims the file. A caller that skips the console + * and posts anything else (a URL, the expanded read form, a number) is refused + * with `invalid_type`, ⛔ never reduced to an id: the reduction is the + * console's, done before it posts. + * + * The id is judged by the spec's `isFileIdToken`, the predicate the claim path + * reads, so the pairing below asserts the door and the claimer agree. + */ + +import { describe, expect, it } from 'vitest'; + +import type { ScreenFieldSpec } from '@objectstack/spec/contracts'; +import { isFileIdToken } from '@objectstack/spec/data'; + +import { validateScreenInputs } from './screen-input-contract.js'; + +const ALWAYS_VISIBLE = () => true; + +const contract: ScreenFieldSpec = { name: 'contract_file', label: 'Contract', type: 'file', required: true }; + +/** Ids shaped as the storage service mints them: a uuid and a nanoid. */ +const MINTED = ['3f1c2a9e-7b4d-4e0a-9c3f-2d8b6a1e5f70', 'V1StGXR8_Z5jdHi6B-myT']; + +describe('validateScreenInputs — a `type: \'file\'` field takes the uploaded file\'s id (#25801)', () => { + it('accepts a minted `sys_file` id', () => { + for (const id of MINTED) { + expect(validateScreenInputs([contract], { contract_file: id }, ALWAYS_VISIBLE), id).toEqual([]); + } + }); + + it('refuses every other shape with `invalid_type`, naming what it expected', () => { + const shapes: Array<[string, unknown]> = [ + ['the download URL', '/api/v1/storage/files/V1StGXR8_Z5jdHi6B-myT'], + ['an absolute URL', 'https://cdn.example.com/nda.pdf'], + ['the expanded read form', { id: MINTED[1], name: 'nda.pdf', size: 12, mimeType: 'application/pdf', url: '/x' }], + ['a legacy inline blob', { file_id: MINTED[1], name: 'nda.pdf', url: 'blob:abc' }], + ['a list of ids', [MINTED[0]]], + ['a number', 42], + ]; + for (const [label, value] of shapes) { + const issues = validateScreenInputs([contract], { contract_file: value }, ALWAYS_VISIBLE); + expect(issues.map((i) => i.code), label).toEqual(['invalid_type']); + expect(issues[0]!.field, label).toBe('contract_file'); + expect(issues[0]!.message, label).toBe('Screen field "contract_file" must be the id of an uploaded file'); + } + }); + + it('agrees with the claim path\'s predicate on every shape above', () => { + // The door admits exactly what `isFileIdToken` calls a reference — the + // predicate the record write's claim path reads — so a value admitted here + // is one a later write can claim, and nothing it refuses could be. + const values: unknown[] = [...MINTED, '/api/v1/storage/files/abc', 'https://x.test/a.pdf', 'has space', 42, {}]; + for (const value of values) { + const admitted = validateScreenInputs([contract], { contract_file: value }, ALWAYS_VISIBLE).length === 0; + expect(admitted, JSON.stringify(value)).toBe(isFileIdToken(value)); + } + }); + + it('leaves absence to `required`, and an optional empty field alone', () => { + expect(validateScreenInputs([contract], {}, ALWAYS_VISIBLE).map((i) => i.code)).toEqual(['required']); + expect(validateScreenInputs([contract], { contract_file: null }, ALWAYS_VISIBLE).map((i) => i.code)) + .toEqual(['required']); + const optional: ScreenFieldSpec = { ...contract, required: false }; + expect(validateScreenInputs([optional], {}, ALWAYS_VISIBLE)).toEqual([]); + expect(validateScreenInputs([optional], { contract_file: '' }, ALWAYS_VISIBLE)).toEqual([]); + }); + + it('does not judge a field the user was never shown', () => { + const conditional: ScreenFieldSpec = { ...contract, required: false, visibleWhen: 'has_template == false' }; + expect(validateScreenInputs([conditional], { contract_file: 'https://x.test/a.pdf' }, () => false)).toEqual([]); + expect(validateScreenInputs([conditional], { contract_file: 'https://x.test/a.pdf' }, () => undefined)) + .toEqual([]); + // …and the control, or the two readings above prove nothing. + expect(validateScreenInputs([conditional], { contract_file: 'https://x.test/a.pdf' }, () => true)) + .toHaveLength(1); + }); + + it('reads `file` alone — the other media hints stay open widget hints', () => { + // The set is closed and grows by ruling: `image`, `avatar`, `video` and + // `audio` share the stored form but are not screen value domains. + for (const type of ['image', 'avatar', 'video', 'audio']) { + const field: ScreenFieldSpec = { name: 'media', type }; + expect(validateScreenInputs([field], { media: 'https://x.test/a.png' }, ALWAYS_VISIBLE), type).toEqual([]); + } + }); +}); diff --git a/packages/spec/src/automation/builtin-node-config.zod.ts b/packages/spec/src/automation/builtin-node-config.zod.ts index a900d3e77c5..b6e955ac98f 100644 --- a/packages/spec/src/automation/builtin-node-config.zod.ts +++ b/packages/spec/src/automation/builtin-node-config.zod.ts @@ -893,8 +893,27 @@ export const ScreenFieldConfigSchema = lazySchema(() => strictObject({ .describe('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 }}`'), /** Display label. */ label: z.string().optional().describe('Display label'), - /** Input type (text, number, select, …). */ - type: z.string().optional().describe('Input type'), + /** + * Input type — an open widget hint (`text`, `number`, `select`, …) the client + * maps to a control. + * + * Two members are also VALUE domains, which the resume enforces + * (`validateScreenInputs`): `'number'` (a finite JSON number, ruling A′) and + * `'file'` (#25801). A `'file'` field renders the platform upload widget; the + * upload goes through the file service and lands as a `sys_file` no record + * owns yet, and the resume carries that file's id — ADR-0104 D3's stored + * form, and nothing else. A later node writes the id into an object's `file` + * field (`create_record` / `update_record` `fields`), and that write claims + * the file for the record it writes. Under the default `runAs: 'user'` the + * claim first asks, as the run's user, the read verdict the download door + * asks (#25692); a `runAs: 'system'` run's write is not judged, as no system + * write is. So a wizard collects its file before the record that will own it + * exists, and writes everything after its last screen. + */ + type: z.string().optional().describe( + '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", + ), /** Whether the runner requires a value before resume. */ required: z.boolean().optional().describe('Whether a value is required to submit'), /** diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 70223cd0d69..7ac943fce57 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -270,7 +270,16 @@ export interface AutomationContext { export interface ScreenFieldSpec { name: string; label?: string; - /** Widget hint (text/select/boolean/number/date/…); the client maps it to a field widget. */ + /** + * Widget hint (text/select/boolean/number/date/file/…); the client maps it + * to a field widget. + * + * Two members are value domains the server re-checks on resume + * (`validateScreenInputs`): `'number'` takes a finite JSON number, and + * `'file'` (#25801) takes the id of an uploaded `sys_file` — the client + * renders the platform upload widget and answers with the id the upload + * minted, never the expanded `{ id, name, url, … }` read form. + */ type?: string; required?: boolean; /** Closed-enum options for select-style fields. */ diff --git a/ui/packages/app-shell/src/views/FlowRunner.tsx b/ui/packages/app-shell/src/views/FlowRunner.tsx index d0b2f4b2104..e98d7c63338 100644 --- a/ui/packages/app-shell/src/views/FlowRunner.tsx +++ b/ui/packages/app-shell/src/views/FlowRunner.tsx @@ -205,6 +205,19 @@ * single column it always had. The count is the DECLARED one, so a field a * `visibleWhen` reveals mid-edit never makes the dialog jump width. * `ActionParamDialog` takes the same bound for a long `params` list. + * + * ## A file field holds Submit until its upload settles (objectstack#25801) + * + * A `type: 'file'` screen field is the platform upload widget ({@link ScreenView}), + * and its value is the uploaded file's id only once the upload settles. So the + * screen body renders inside an uploading scope (`useUploadingScope`, the + * aggregation the record form gates its Save on), and while any upload in it + * is in flight Submit is disabled and reads the widget's own "Uploading…" — + * the same answer `ActionParamDialog` gives its Confirm. The scope also holds + * past an unmount, so an upload still running cannot be outrun by a Submit. + * The values then go out through `screenResumeValues`, which reduces each file + * field to its bare id: the engine reads `'file'` as a value domain and refuses + * anything else. */ import { Suspense, useEffect, useState } from 'react'; import { @@ -221,6 +234,7 @@ import { cn, } from '@object-ui/components'; import { notifyDataChanged, useObjectTranslation } from '@object-ui/react'; +import { UploadingScopeProvider, useUploadingScope } from '@object-ui/fields'; import type { ActionCopyTemplate } from '@object-ui/core'; import { FLOW_SCREEN_FIELD_COPY_KEYS, @@ -236,6 +250,7 @@ import { initialScreenValues, screenFieldBoundViolations, screenFields, + screenResumeValues, visibleScreenFields, type ScreenFieldSpec, type ScreenSpec, @@ -423,6 +438,9 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat const [submitting, setSubmitting] = useState(false); const [resumeError, setResumeError] = useState(null); const [refused, setRefused] = useState(null); + // Every upload widget in the screen body reports into this scope, so Submit + // can wait for a file field's id (objectstack#25801 — see the header). + const uploads = useUploadingScope(); useEffect(() => { if (state) { @@ -532,6 +550,9 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat }; const submit = async () => { + // A file field's value is its id only once the upload settles; Submit is + // disabled meanwhile, and this covers a submit that reaches here anyway. + if (uploads.anyUploading) return; // Enforce `required` over the fields ACTUALLY ON SCREEN, not the whole // declared list. A `visibleWhen` field that is required *when shown* is not // required while hidden — the user was never asked for it, and the flow is @@ -573,7 +594,8 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat } setSubmitting(true); try { - await resumeWith(values); + // File fields go out as their bare `sys_file` id (objectstack#25801). + await resumeWith(screenResumeValues(screen, values)); } catch (err) { // The request never produced a response (network / abort), so the // suspension was not consumed — this one IS retryable. @@ -671,29 +693,31 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat fallback and destroys the host's state, taking this dialog (and the run it is driving) with it. */} {t('common.loading', { defaultValue: 'Loading…' })}}> - + + + )} @@ -707,10 +731,12 @@ export function FlowRunner({ state, authFetch, baseUrl, onClose, onComplete, dat ) : ( <> - )} diff --git a/ui/packages/app-shell/src/views/ScreenView.tsx b/ui/packages/app-shell/src/views/ScreenView.tsx index 7d4f84c1802..8358d587d30 100644 --- a/ui/packages/app-shell/src/views/ScreenView.tsx +++ b/ui/packages/app-shell/src/views/ScreenView.tsx @@ -37,6 +37,19 @@ * no target to resolve and keeps the plain input it always had. * * A field declaring none of them renders exactly as it did before. + * + * ## A `type: 'file'` field is the platform upload widget (objectstack#25801) + * + * The object form's `FileField`, reached through `getLazyFieldWidget('file')` + * as `ActionParamDialog`'s file params reach it, uploading through the host's + * ambient `UploadProvider`. The upload lands as a `sys_file` no record owns yet, + * and the widget's value is that file's bare id. The spec makes `'file'` a value + * domain on resume: the engine refuses anything but a file id, so the runtime + * posts {@link screenResumeValues}, which reduces a file field's value to its + * id (a prefilled `defaultValue` may arrive in the expanded read form). A later + * node writes the id into an object's `file` field, and that write claims the + * file. While an upload is in flight the value is not an id yet, so the runtime + * holds Submit until it settles (`FlowRunner`). */ import { Suspense } from 'react'; import { @@ -53,7 +66,7 @@ import { } from '@object-ui/components'; import { ObjectForm } from '@object-ui/plugin-form'; import { evalFieldPredicate } from '@object-ui/core'; -import { getLazyFieldWidget } from '@object-ui/fields'; +import { fileIdOf, getLazyFieldWidget } from '@object-ui/fields'; import { useObjectTranslation } from '@object-ui/i18n'; import { flowScreenFieldOptionKey } from '@objectstack/spec/automation'; @@ -65,6 +78,14 @@ import { flowScreenFieldOptionKey } from '@objectstack/spec/automation'; */ const LookupFieldWidget = getLazyFieldWidget('lookup'); +/** The object form's upload widget (`FileField`), resolved the same way. */ +const FileFieldWidget = getLazyFieldWidget('file'); + +/** Whether a screen field is the upload widget — the widget hint, read as the renderer reads it. */ +function isFileScreenField(field: ScreenFieldSpec): boolean { + return (field.type || '').toLowerCase() === 'file'; +} + /** * The screen-pause contract is OWNED by `@objectstack/spec/contracts` * (objectstack#4115) — the server emits it, this dialog renders it. @@ -214,6 +235,32 @@ export function screenFieldBoundViolations( return out; } +/** + * The values a flat screen resumes with: the collected `values`, with each + * `type: 'file'` field's value reduced to its `sys_file` id (objectstack#25801). + * + * The engine reads a file field as a value domain and admits a bare file id + * alone. The upload widget already hands back a bare id, but a prefilled + * `defaultValue` can be served in the expanded read form (`{ id, name, url, … }`), + * so the reduction is `fileIdOf`, the extractor `ActionParamDialog`'s + * `serializeParamValues` applies to file params — one rule for both surfaces. + * A value carrying no id is sent as it is, so the engine's refusal names it + * rather than the dialog posting `undefined`. Every other value passes through. + */ +export function screenResumeValues(screen: ScreenSpec, values: Record): Record { + let out: Record | undefined; + for (const field of screenFields(screen)) { + if (!isFileScreenField(field)) continue; + const value = values[field.name]; + if (value == null) continue; + const id = fileIdOf(value); + if (id === undefined || id === value) continue; + out ??= { ...values }; + out[field.name] = id; + } + return out ?? values; +} + /** The id of a screen field's help-text element, which its control names in `aria-describedby`. */ function screenFieldHelpId(name: string): string { return `ff-${name}-help`; @@ -385,6 +432,26 @@ export function ScreenFieldInput({ ); } + // The platform upload widget (objectstack#25801) — the object form's own + // `FileField`, through the same lazy registry and behind its own Suspense for + // the reason the lookup's is. It uploads through the host's ambient + // `UploadProvider` and hands back the minted file's bare id; its in-flight + // state reaches the runner through the uploading scope it reports into. + if (kind === 'file') { + return ( + + ); + } + if (Array.isArray(field.options) && field.options.length > 0) { // No declared placeholder ⇒ the locale's `common.select`, the word the // object form's lookup picker shows in the same spot (objectui#11220). diff --git a/ui/packages/app-shell/src/views/__tests__/FlowRunner.fileField-25801.test.tsx b/ui/packages/app-shell/src/views/__tests__/FlowRunner.fileField-25801.test.tsx new file mode 100644 index 00000000000..edfb39223d7 --- /dev/null +++ b/ui/packages/app-shell/src/views/__tests__/FlowRunner.fileField-25801.test.tsx @@ -0,0 +1,197 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#25801 — a flat screen collects a file. + * + * A `type: 'file'` screen field renders the platform upload widget, the upload + * goes through the host's `UploadProvider` (the file service), and the run + * resumes with the uploaded file's id — the one shape the engine admits for a + * file field, and the one a later node writes into an object's `file` field. + * + * The widget is the REAL `FileField`, reached through the dialog's own lazy + * registry and driven through its real file input; only the upload adapter is + * the test's, and it holds its promise so the mid-upload window is + * deterministic. Each refusal leg asserts "no resume left the dialog", with the + * pass leg beside it asserting the request that did — the defect this guards + * is a Submit that resumes WITHOUT the file, which "no error" would pass on. + */ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +// Module scope, per AGENTS.md 测试纪律: the file leg reaches `FileField` +// through a `React.lazy` factory inside `@object-ui/fields`. +import '@object-ui/fields'; +import { UploadProvider, type UploadResult } from '@object-ui/providers'; +import { FlowRunner, type ScreenFlowState } from '../FlowRunner'; +import { screenResumeValues } from '../ScreenView'; + +const toasts = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() })); +vi.mock('sonner', () => ({ toast: toasts })); + +/** uuid/nanoid-shaped, so the platform's `isFileIdToken` accepts it. */ +const MINTED_ID = 'V1StGXR8_Z5jdHi6B-myT'; + +const CONTRACT_FIELD = { name: 'contract_file', label: 'Contract', type: 'file', required: true }; + +const INTAKE: ScreenFlowState = { + flowName: 'contract_intake', + runId: 'run-1', + screen: { nodeId: 'ask', title: 'First version', fields: [CONTRACT_FIELD] }, +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +const okResume = () => + vi.fn(async (_url: string, _init?: RequestInit) => jsonResponse({ success: true, data: { success: true } })); + +/** An upload adapter whose every upload stays in flight until its release is called. */ +function heldUploads() { + const pending: Array<() => void> = []; + const upload = vi.fn(async (f: File | Blob): Promise => { + await new Promise((release) => { + pending.push(release); + }); + return { + url: `/api/v1/storage/files/${MINTED_ID}`, + name: (f as File).name, + size: f.size, + mimeType: f.type, + meta: { fileId: MINTED_ID }, + }; + }); + return { adapter: { name: 'held', upload }, upload, pending }; +} + +function setup(state: ScreenFlowState, authFetch: ReturnType, adapter = heldUploads().adapter) { + render( + + + , + ); +} + +/** The inputs the one resume request carried. */ +function resumedInputs(authFetch: ReturnType): unknown { + expect(authFetch).toHaveBeenCalledTimes(1); + return JSON.parse(String(authFetch.mock.calls[0][1]?.body)).inputs; +} + +/** The real widget's hidden picker, once its lazy chunk is in (the dialog renders in a portal). */ +async function filePicker(): Promise { + await waitFor(() => expect(document.querySelector('input[type="file"]')).not.toBeNull()); + return document.querySelector('input[type="file"]') as HTMLInputElement; +} + +beforeEach(() => { + toasts.error.mockReset(); + toasts.success.mockReset(); +}); +afterEach(() => { + cleanup(); +}); + +describe('objectstack#25801 — a `type: \'file\'` screen field is the platform upload widget', () => { + it('renders the upload widget, not a text input', async () => { + setup(INTAKE, okResume()); + await filePicker(); + // The plain text input every unknown hint used to fall back to is gone. + expect(document.getElementById('ff-contract_file')?.tagName).not.toBe('INPUT'); + expect(screen.queryByRole('textbox')).toBeNull(); + }); + + it('holds Submit while the upload is in flight, then resumes with the uploaded file\'s id', async () => { + const authFetch = okResume(); + const { adapter, upload, pending } = heldUploads(); + setup(INTAKE, authFetch, adapter); + + fireEvent.change(await filePicker(), { + target: { files: [new File(['%PDF'], 'nda.pdf', { type: 'application/pdf' })] }, + }); + // The upload IS in flight, through the host's provider: the adapter holds it. + await waitFor(() => expect(pending).toHaveLength(1)); + expect(upload).toHaveBeenCalledTimes(1); + + // Submit is held and says why; a click on it resumes nothing. + const held = await screen.findByRole('button', { name: 'Uploading…' }); + expect(held).toBeDisabled(); + fireEvent.click(held); + expect(authFetch).not.toHaveBeenCalled(); + + pending[0]!(); + + const submit = await screen.findByRole('button', { name: 'Submit' }); + await waitFor(() => expect(submit).not.toBeDisabled()); + fireEvent.click(submit); + + await waitFor(() => expect(authFetch).toHaveBeenCalledTimes(1)); + expect(String(authFetch.mock.calls[0][0])).toBe('/api/v1/automation/contract_intake/runs/run-1/resume'); + expect(resumedInputs(authFetch)).toEqual({ contract_file: MINTED_ID }); + }); + + it('refuses a required file field left empty, and resumes nothing', async () => { + const authFetch = okResume(); + setup(INTAKE, authFetch); + await filePicker(); + + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => expect(toasts.error).toHaveBeenCalledTimes(1)); + expect(String(toasts.error.mock.calls[0][0])).toContain('Contract'); + expect(authFetch).not.toHaveBeenCalled(); + }); + + it('posts a prefilled expanded file value as its bare id', async () => { + const authFetch = okResume(); + const prefilled: ScreenFlowState = { + ...INTAKE, + screen: { + ...INTAKE.screen, + fields: [{ + ...CONTRACT_FIELD, + defaultValue: { id: MINTED_ID, name: 'nda.pdf', size: 4, mimeType: 'application/pdf', url: '/x' }, + }], + }, + }; + setup(prefilled, authFetch); + await filePicker(); + + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => expect(authFetch).toHaveBeenCalledTimes(1)); + expect(resumedInputs(authFetch)).toEqual({ contract_file: MINTED_ID }); + }); +}); + +describe('screenResumeValues — the values a flat screen resumes with (objectstack#25801)', () => { + const screenSpec = { + nodeId: 'ask', + fields: [ + { name: 'title', type: 'text' }, + { name: 'contract_file', type: 'file' }, + { name: 'cover', type: 'image' }, + ], + }; + + it('reduces a file field\'s expanded value to its id, and leaves every other field alone', () => { + const expanded = { id: MINTED_ID, name: 'nda.pdf', url: '/api/v1/storage/files/x' }; + const image = { id: 'img_1', url: '/i.png' }; + expect(screenResumeValues(screenSpec, { title: 'NDA', contract_file: expanded, cover: image })) + .toEqual({ title: 'NDA', contract_file: MINTED_ID, cover: image }); + // The legacy `file_id` spelling is read the way action params read it. + expect(screenResumeValues(screenSpec, { contract_file: { file_id: MINTED_ID, name: 'nda.pdf' } })) + .toEqual({ contract_file: MINTED_ID }); + }); + + it('hands back the same object when nothing needs reducing', () => { + const values = { title: 'NDA', contract_file: MINTED_ID }; + expect(screenResumeValues(screenSpec, values)).toBe(values); + const empty = { title: 'NDA', contract_file: null }; + expect(screenResumeValues(screenSpec, empty)).toBe(empty); + }); + + it('sends a value carrying no id as it is, so the engine\'s refusal names it', () => { + const values = { contract_file: 'https://cdn.example.com/nda.pdf' }; + expect(screenResumeValues(screenSpec, values)).toBe(values); + }); +});