From 75e4f57994714221d514f431bfd6d59243e008c1 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 25 Aug 2026 23:46:49 +0200 Subject: [PATCH 01/11] feat(agent-client): thread per-request timezone to action form hooks --- .../src/action-fields/field-form-states.ts | 9 ++ packages/agent-client/src/domains/action.ts | 1 + .../agent-client/src/domains/collection.ts | 1 + .../action-fields/field-form-states.test.ts | 85 +++++++++++++++++++ .../test/domains/collection.test.ts | 33 +++++++ .../agent-client/test/http-requester.test.ts | 14 +++ 6 files changed, 143 insertions(+) diff --git a/packages/agent-client/src/action-fields/field-form-states.ts b/packages/agent-client/src/action-fields/field-form-states.ts index d0297ff391..40bc0ba855 100644 --- a/packages/agent-client/src/action-fields/field-form-states.ts +++ b/packages/agent-client/src/action-fields/field-form-states.ts @@ -20,6 +20,7 @@ export default class FieldFormStates { private readonly hooks?: ForestSchemaAction['hooks']; private readonly fallbackFields?: ForestSchemaAction['fields']; private readonly fallbackLayout?: ForestSchemaAction['layout']; + private readonly timezone?: string; constructor( actionName: string, @@ -30,6 +31,7 @@ export default class FieldFormStates { hooks?: ForestSchemaAction['hooks'], fallbackFields?: ForestSchemaAction['fields'], fallbackLayout?: ForestSchemaAction['layout'], + timezone?: string, ) { this.fields = []; this.actionName = actionName; @@ -41,6 +43,7 @@ export default class FieldFormStates { this.hooks = hooks; this.fallbackFields = fallbackFields; this.fallbackLayout = fallbackLayout; + this.timezone = timezone; } getFieldValues(): Record { @@ -108,6 +111,7 @@ export default class FieldFormStates { method: 'post', path: `${this.actionPath}/hooks/load`, body: requestBody, + query: this.buildTimezoneQuery(), }); this.clearFieldsAndLayout(); @@ -147,6 +151,10 @@ export default class FieldFormStates { } } + private buildTimezoneQuery(): { timezone: string } | undefined { + return this.timezone ? { timezone: this.timezone } : undefined; + } + private addFields(plainFields: PlainField[]): void { plainFields.forEach(f => this.fields.push(new FieldGetter(f))); } @@ -173,6 +181,7 @@ export default class FieldFormStates { method: 'post', path: `${this.actionPath}/hooks/change`, body: requestBody, + query: this.buildTimezoneQuery(), }); this.clearFieldsAndLayout(); diff --git a/packages/agent-client/src/domains/action.ts b/packages/agent-client/src/domains/action.ts index 43d2d5d91c..d443a1b69b 100644 --- a/packages/agent-client/src/domains/action.ts +++ b/packages/agent-client/src/domains/action.ts @@ -86,6 +86,7 @@ function toActionError(error: unknown): unknown { export type BaseActionContext = { recordId?: RecordId; recordIds?: RecordId[]; + timezone?: string; }; export type ActionExecuteOptions = { diff --git a/packages/agent-client/src/domains/collection.ts b/packages/agent-client/src/domains/collection.ts index 324e81e5b1..9130cb508e 100644 --- a/packages/agent-client/src/domains/collection.ts +++ b/packages/agent-client/src/domains/collection.ts @@ -46,6 +46,7 @@ export default class Collection extends CollectionChart { actionInfo.hooks, actionInfo.fields, actionInfo.layout, + actionContext?.timezone, ); const action = new Action( diff --git a/packages/agent-client/test/action-fields/field-form-states.test.ts b/packages/agent-client/test/action-fields/field-form-states.test.ts index 74da0ca332..5987996124 100644 --- a/packages/agent-client/test/action-fields/field-form-states.test.ts +++ b/packages/agent-client/test/action-fields/field-form-states.test.ts @@ -275,6 +275,91 @@ describe('FieldFormStates', () => { }); }); + describe('timezone', () => { + const withTimezone = (timezone?: string) => + new FieldFormStates( + 'testAction', + '/forest/actions/test-action', + 'users', + httpRequester, + ['1'], + undefined, + undefined, + undefined, + timezone, + ); + + it('should send the timezone in the load hook query when one is provided', async () => { + httpRequester.query.mockResolvedValue({ fields: [], layout: [] }); + + await withTimezone('America/New_York').loadInitialState(); + + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/forest/actions/test-action/hooks/load', + query: { timezone: 'America/New_York' }, + }), + ); + }); + + it('should send no query in the load hook when no timezone is provided', async () => { + httpRequester.query.mockResolvedValue({ fields: [], layout: [] }); + + await withTimezone().loadInitialState(); + + expect(httpRequester.query.mock.calls[0][0].query).toBeUndefined(); + }); + + it('should send the timezone in the change hook query when one is provided', async () => { + const formStates = withTimezone('Asia/Tokyo'); + httpRequester.query.mockResolvedValue({ + fields: [ + { + field: 'name', + type: 'String', + isRequired: false, + isReadOnly: false, + value: 'initial', + hook: 'changeHook', + }, + ], + layout: [], + }); + await formStates.loadInitialState(); + + await formStates.setFieldValue('name', 'updated'); + + expect(httpRequester.query).toHaveBeenLastCalledWith( + expect.objectContaining({ + path: '/forest/actions/test-action/hooks/change', + query: { timezone: 'Asia/Tokyo' }, + }), + ); + }); + + it('should send no query in the change hook when no timezone is provided', async () => { + const formStates = withTimezone(); + httpRequester.query.mockResolvedValue({ + fields: [ + { + field: 'name', + type: 'String', + isRequired: false, + isReadOnly: false, + value: 'initial', + hook: 'changeHook', + }, + ], + layout: [], + }); + await formStates.loadInitialState(); + + await formStates.setFieldValue('name', 'updated'); + + expect(httpRequester.query.mock.calls[1][0].query).toBeUndefined(); + }); + }); + describe('hooks configuration', () => { it('should not throw when hooks.load is false and server returns 404', async () => { const formStates = new FieldFormStates( diff --git a/packages/agent-client/test/domains/collection.test.ts b/packages/agent-client/test/domains/collection.test.ts index 8df256bae2..eb2944edea 100644 --- a/packages/agent-client/test/domains/collection.test.ts +++ b/packages/agent-client/test/domains/collection.test.ts @@ -1,6 +1,7 @@ import type { ActionEndpointsByCollection } from '../../src/domains/action'; import type HttpRequester from '../../src/http-requester'; +import FieldFormStates from '../../src/action-fields/field-form-states'; import Collection from '../../src/domains/collection'; jest.mock('../../src/http-requester'); @@ -404,6 +405,38 @@ describe('Collection', () => { expect(result).toBeDefined(); }); + it('should forward the action context timezone to the form states', async () => { + await collection.action('sendEmail', { recordIds: ['1'], timezone: 'America/New_York' }); + + expect(FieldFormStates).toHaveBeenCalledWith( + 'sendEmail', + '/forest/actions/send-email', + 'users', + httpRequester, + ['1'], + { load: false, change: [] }, + [], + undefined, + 'America/New_York', + ); + }); + + it('should forward no timezone when the action context omits it', async () => { + await collection.action('sendEmail', { recordIds: ['1'] }); + + expect(FieldFormStates).toHaveBeenCalledWith( + 'sendEmail', + '/forest/actions/send-email', + 'users', + httpRequester, + ['1'], + { load: false, change: [] }, + [], + undefined, + undefined, + ); + }); + it('should pipe-encode composite recordIds when executing the action', async () => { const action = await collection.action('sendEmail', { recordIds: [ diff --git a/packages/agent-client/test/http-requester.test.ts b/packages/agent-client/test/http-requester.test.ts index 8edfa577c1..a8af35e6b6 100644 --- a/packages/agent-client/test/http-requester.test.ts +++ b/packages/agent-client/test/http-requester.test.ts @@ -179,6 +179,20 @@ describe('HttpRequester', () => { }); }); + it('should let an explicit query timezone override the default', async () => { + mockRequest.then = jest.fn((onFulfilled: any) => { + return Promise.resolve(onFulfilled({ body: {} })); + }); + + await requester.query({ + method: 'post', + path: '/forest/_actions/users/0/approve/hooks/load', + query: { timezone: 'America/New_York' }, + }); + + expect(mockRequest.query).toHaveBeenCalledWith({ timezone: 'America/New_York' }); + }); + it('should normalize path without leading slash', async () => { mockRequest.then = jest.fn((onFulfilled: any) => { return Promise.resolve(onFulfilled({ body: {} })); From e19090e615cd6e1f9a4dcd26b491fd08cfac9808 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 25 Aug 2026 23:46:54 +0200 Subject: [PATCH 02/11] feat(agent-bff): forward resolved timezone to action form hooks --- .../src/action/action-routes-middleware.ts | 4 +- .../src/action/agent-action-client.ts | 15 ++++--- .../action/action-routes-middleware.test.ts | 41 ++++++++++++++++--- .../test/action/agent-action-client.test.ts | 9 ++-- 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/packages/agent-bff/src/action/action-routes-middleware.ts b/packages/agent-bff/src/action/action-routes-middleware.ts index 25c6409c7f..b118960502 100644 --- a/packages/agent-bff/src/action/action-routes-middleware.ts +++ b/packages/agent-bff/src/action/action-routes-middleware.ts @@ -90,7 +90,7 @@ async function handleForm( // callAgent so a local BFF bug surfaces as a 500, not a mislabelled agent error. Fields and // layout are read AFTER tryToSetFields because a change hook rebuilds them in place. const action = await callAgent( - () => client.loadAction(collection, actionName, recordIds), + () => client.loadAction(collection, actionName, recordIds, ctx.state.timezone as string), logger, ); const skippedFields = await callAgent(() => action.tryToSetFields(values), logger); @@ -109,7 +109,7 @@ async function handleExecute( logger: Logger, ): Promise { const action = await callAgent( - () => client.loadAction(collection, actionName, recordIds), + () => client.loadAction(collection, actionName, recordIds, ctx.state.timezone as string), logger, ); diff --git a/packages/agent-bff/src/action/agent-action-client.ts b/packages/agent-bff/src/action/agent-action-client.ts index cd597ef960..c315b46c23 100644 --- a/packages/agent-bff/src/action/agent-action-client.ts +++ b/packages/agent-bff/src/action/agent-action-client.ts @@ -30,7 +30,12 @@ export interface Action extends ActionForm { } export interface AgentActionClient { - loadAction(collection: string, action: string, recordIds: string[]): Promise; + loadAction( + collection: string, + action: string, + recordIds: string[], + timezone: string, + ): Promise; } export interface AgentActionClientOptions { @@ -42,8 +47,8 @@ export interface AgentActionClientOptions { // The raw layout must be read AFTER tryToSetFields: a change hook rebuilds fields+layout in place. // agent-client's `Action.getLayout()` only returns an `ActionLayoutRoot` wrapper whose element array -// lives in a protected field. The rollback contract forbids agent-client changes, so we read it -// through a cast rather than adding a public accessor. `extract-raw-layout.test.ts` builds a real +// lives in a protected field. PRD-673's rollback contract forbade agent-client changes, so we read +// it through a cast rather than adding a public accessor. `extract-raw-layout.test.ts` builds a real // `ActionLayoutRoot` and asserts this unwraps it, so a rename of that field fails a test. export function extractRawLayout(action: ActionForm): ForestServerActionFormLayoutElement[] { const root = action.getLayout() as { layout?: ForestServerActionFormLayoutElement[] }; @@ -70,7 +75,7 @@ export default function createAgentActionClient({ }); return { - loadAction: (collection, action, recordIds) => - client.collection(collection).action(action, { recordIds }), + loadAction: (collection, action, recordIds, timezone) => + client.collection(collection).action(action, { recordIds, timezone }), }; } diff --git a/packages/agent-bff/test/action/action-routes-middleware.test.ts b/packages/agent-bff/test/action/action-routes-middleware.test.ts index 13e0827dea..e5f424cdd8 100644 --- a/packages/agent-bff/test/action/action-routes-middleware.test.ts +++ b/packages/agent-bff/test/action/action-routes-middleware.test.ts @@ -98,14 +98,15 @@ function buildApp( { agentToken = 'agent-jwt', logger = noopLogger, - }: { agentToken?: string | null; logger?: Logger } = {}, + timezone = TIMEZONE, + }: { agentToken?: string | null; logger?: Logger; timezone?: string } = {}, ) { const app = new Koa(); app.silent = true; app.use(createErrorMiddleware({ logger: noopLogger })); app.use(bodyParser()); app.use(async (ctx, next) => { - ctx.state.timezone = TIMEZONE; + ctx.state.timezone = timezone; if (agentToken !== null) ctx.state.agentToken = agentToken; await next(); }); @@ -301,7 +302,7 @@ describe('action routes middleware', () => { .post('/agent/v1/users/actions/approve/form') .send({ recordIds: ['1|2'] }); - expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['1|2']); + expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['1|2'], TIMEZONE); }); it('coerces a numeric zero recordId to a string so it survives the downstream filter', async () => { @@ -313,7 +314,7 @@ describe('action routes middleware', () => { .post('/agent/v1/users/actions/approve/form') .send({ recordIds: [0] }); - expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['0']); + expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['0'], TIMEZONE); }); it('accepts an empty recordIds array for a global action', async () => { @@ -326,7 +327,21 @@ describe('action routes middleware', () => { .send({ recordIds: [] }); expect(response.status).toBe(200); - expect(loadAction).toHaveBeenCalledWith('users', 'approve', []); + expect(loadAction).toHaveBeenCalledWith('users', 'approve', [], TIMEZONE); + }); + + it('forwards the request-resolved timezone to the action form load', async () => { + const form = makeAction(); + const loadAction = jest.fn(async () => form); + const app = buildApp(storeOf(readModel), clientOf(form, loadAction), { + timezone: 'America/New_York', + }); + + await request(app.callback()) + .post('/agent/v1/users/actions/approve/form') + .send({ recordIds: ['42'] }); + + expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['42'], 'America/New_York'); }); it('returns 400 invalid_request with no agent call when recordIds is missing', async () => { @@ -473,7 +488,7 @@ describe('action execute', () => { .post('/agent/v1/users/actions/approve/execute') .send({ recordIds: ['42'], values: { reason: 'x' } }); - expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['42']); + expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['42'], TIMEZONE); expect(setFields).toHaveBeenCalledWith({ reason: 'x' }); expect(execute).toHaveBeenCalledTimes(1); // Order is the behaviour named by this test: executing before the values are applied would run @@ -481,6 +496,20 @@ describe('action execute', () => { expect(setFields.mock.invocationCallOrder[0]).toBeLessThan(execute.mock.invocationCallOrder[0]); }); + it('forwards the request-resolved timezone to the action execute load', async () => { + const form = makeAction(); + const loadAction = jest.fn(async () => form); + const app = buildApp(storeOf(readModel), clientOf(form, loadAction), { + timezone: 'Asia/Tokyo', + }); + + await request(app.callback()) + .post('/agent/v1/users/actions/approve/execute') + .send({ recordIds: ['42'] }); + + expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['42'], 'Asia/Tokyo'); + }); + it('normalizes a Success result to 200 with invalidated as an array', async () => { const form = makeAction({ execute: jest.fn(async () => ({ diff --git a/packages/agent-bff/test/action/agent-action-client.test.ts b/packages/agent-bff/test/action/agent-action-client.test.ts index c0c1b12e28..365e672ce8 100644 --- a/packages/agent-bff/test/action/agent-action-client.test.ts +++ b/packages/agent-bff/test/action/agent-action-client.test.ts @@ -18,7 +18,7 @@ describe('createAgentActionClient', () => { mockedHttpRequester.mockImplementation(() => ({ query, stream } as unknown as HttpRequester)); }); - it('loads the action via createRemoteAgentClient().collection(name).action(name, { recordIds })', async () => { + it('loads the action via createRemoteAgentClient().collection(name).action(name, { recordIds, timezone })', async () => { const loadedAction = { tag: 'action' }; const actionFn = jest.fn(async () => loadedAction); const collectionFn = jest.fn(() => ({ action: actionFn })); @@ -30,7 +30,7 @@ describe('createAgentActionClient', () => { token: 'jwt-token', actionEndpoints, }); - const result = await client.loadAction('users', 'approve', ['1', '2']); + const result = await client.loadAction('users', 'approve', ['1', '2'], 'America/New_York'); expect(HttpRequester).toHaveBeenCalledWith('jwt-token', { url: 'https://agent.example.com' }); expect(createRemoteAgentClientMock).toHaveBeenCalledWith({ @@ -40,7 +40,10 @@ describe('createAgentActionClient', () => { httpRequester: expect.objectContaining({ query }), }); expect(collectionFn).toHaveBeenCalledWith('users'); - expect(actionFn).toHaveBeenCalledWith('approve', { recordIds: ['1', '2'] }); + expect(actionFn).toHaveBeenCalledWith('approve', { + recordIds: ['1', '2'], + timezone: 'America/New_York', + }); expect(result).toBe(loadedAction); }); From 7a4eaeab35acecb09ede5cd0fdb8b18c8167a34c Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 25 Aug 2026 23:56:38 +0200 Subject: [PATCH 03/11] refactor(agent-bff): pass load action arguments as one object --- .../src/action/action-routes-middleware.ts | 16 ++++++- .../src/action/agent-action-client.ts | 16 +++---- .../action/action-routes-middleware.test.ts | 42 ++++++++++++++++--- .../test/action/agent-action-client.test.ts | 7 +++- 4 files changed, 65 insertions(+), 16 deletions(-) diff --git a/packages/agent-bff/src/action/action-routes-middleware.ts b/packages/agent-bff/src/action/action-routes-middleware.ts index b118960502..02edd7af6a 100644 --- a/packages/agent-bff/src/action/action-routes-middleware.ts +++ b/packages/agent-bff/src/action/action-routes-middleware.ts @@ -90,7 +90,13 @@ async function handleForm( // callAgent so a local BFF bug surfaces as a 500, not a mislabelled agent error. Fields and // layout are read AFTER tryToSetFields because a change hook rebuilds them in place. const action = await callAgent( - () => client.loadAction(collection, actionName, recordIds, ctx.state.timezone as string), + () => + client.loadAction({ + collection, + action: actionName, + recordIds, + timezone: ctx.state.timezone as string, + }), logger, ); const skippedFields = await callAgent(() => action.tryToSetFields(values), logger); @@ -109,7 +115,13 @@ async function handleExecute( logger: Logger, ): Promise { const action = await callAgent( - () => client.loadAction(collection, actionName, recordIds, ctx.state.timezone as string), + () => + client.loadAction({ + collection, + action: actionName, + recordIds, + timezone: ctx.state.timezone as string, + }), logger, ); diff --git a/packages/agent-bff/src/action/agent-action-client.ts b/packages/agent-bff/src/action/agent-action-client.ts index c315b46c23..222d281970 100644 --- a/packages/agent-bff/src/action/agent-action-client.ts +++ b/packages/agent-bff/src/action/agent-action-client.ts @@ -29,13 +29,15 @@ export interface Action extends ActionForm { execute(): Promise; } +export interface LoadActionParams { + collection: string; + action: string; + recordIds: string[]; + timezone: string; +} + export interface AgentActionClient { - loadAction( - collection: string, - action: string, - recordIds: string[], - timezone: string, - ): Promise; + loadAction(params: LoadActionParams): Promise; } export interface AgentActionClientOptions { @@ -75,7 +77,7 @@ export default function createAgentActionClient({ }); return { - loadAction: (collection, action, recordIds, timezone) => + loadAction: ({ collection, action, recordIds, timezone }) => client.collection(collection).action(action, { recordIds, timezone }), }; } diff --git a/packages/agent-bff/test/action/action-routes-middleware.test.ts b/packages/agent-bff/test/action/action-routes-middleware.test.ts index e5f424cdd8..379baa8c63 100644 --- a/packages/agent-bff/test/action/action-routes-middleware.test.ts +++ b/packages/agent-bff/test/action/action-routes-middleware.test.ts @@ -302,7 +302,12 @@ describe('action routes middleware', () => { .post('/agent/v1/users/actions/approve/form') .send({ recordIds: ['1|2'] }); - expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['1|2'], TIMEZONE); + expect(loadAction).toHaveBeenCalledWith({ + collection: 'users', + action: 'approve', + recordIds: ['1|2'], + timezone: TIMEZONE, + }); }); it('coerces a numeric zero recordId to a string so it survives the downstream filter', async () => { @@ -314,7 +319,12 @@ describe('action routes middleware', () => { .post('/agent/v1/users/actions/approve/form') .send({ recordIds: [0] }); - expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['0'], TIMEZONE); + expect(loadAction).toHaveBeenCalledWith({ + collection: 'users', + action: 'approve', + recordIds: ['0'], + timezone: TIMEZONE, + }); }); it('accepts an empty recordIds array for a global action', async () => { @@ -327,7 +337,12 @@ describe('action routes middleware', () => { .send({ recordIds: [] }); expect(response.status).toBe(200); - expect(loadAction).toHaveBeenCalledWith('users', 'approve', [], TIMEZONE); + expect(loadAction).toHaveBeenCalledWith({ + collection: 'users', + action: 'approve', + recordIds: [], + timezone: TIMEZONE, + }); }); it('forwards the request-resolved timezone to the action form load', async () => { @@ -341,7 +356,12 @@ describe('action routes middleware', () => { .post('/agent/v1/users/actions/approve/form') .send({ recordIds: ['42'] }); - expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['42'], 'America/New_York'); + expect(loadAction).toHaveBeenCalledWith({ + collection: 'users', + action: 'approve', + recordIds: ['42'], + timezone: 'America/New_York', + }); }); it('returns 400 invalid_request with no agent call when recordIds is missing', async () => { @@ -488,7 +508,12 @@ describe('action execute', () => { .post('/agent/v1/users/actions/approve/execute') .send({ recordIds: ['42'], values: { reason: 'x' } }); - expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['42'], TIMEZONE); + expect(loadAction).toHaveBeenCalledWith({ + collection: 'users', + action: 'approve', + recordIds: ['42'], + timezone: TIMEZONE, + }); expect(setFields).toHaveBeenCalledWith({ reason: 'x' }); expect(execute).toHaveBeenCalledTimes(1); // Order is the behaviour named by this test: executing before the values are applied would run @@ -507,7 +532,12 @@ describe('action execute', () => { .post('/agent/v1/users/actions/approve/execute') .send({ recordIds: ['42'] }); - expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['42'], 'Asia/Tokyo'); + expect(loadAction).toHaveBeenCalledWith({ + collection: 'users', + action: 'approve', + recordIds: ['42'], + timezone: 'Asia/Tokyo', + }); }); it('normalizes a Success result to 200 with invalidated as an array', async () => { diff --git a/packages/agent-bff/test/action/agent-action-client.test.ts b/packages/agent-bff/test/action/agent-action-client.test.ts index 365e672ce8..a20dc12ac0 100644 --- a/packages/agent-bff/test/action/agent-action-client.test.ts +++ b/packages/agent-bff/test/action/agent-action-client.test.ts @@ -30,7 +30,12 @@ describe('createAgentActionClient', () => { token: 'jwt-token', actionEndpoints, }); - const result = await client.loadAction('users', 'approve', ['1', '2'], 'America/New_York'); + const result = await client.loadAction({ + collection: 'users', + action: 'approve', + recordIds: ['1', '2'], + timezone: 'America/New_York', + }); expect(HttpRequester).toHaveBeenCalledWith('jwt-token', { url: 'https://agent.example.com' }); expect(createRemoteAgentClientMock).toHaveBeenCalledWith({ From 847307aaf9c6b82e636abaa9e615dedc379951b0 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 25 Aug 2026 23:56:39 +0200 Subject: [PATCH 04/11] docs(agent-client): describe the per-request hook timezone --- packages/agent-client/CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent-client/CLAUDE.md b/packages/agent-client/CLAUDE.md index 3bfdf190d1..cc773a0f93 100644 --- a/packages/agent-client/CLAUDE.md +++ b/packages/agent-client/CLAUDE.md @@ -16,10 +16,10 @@ Entry point is `createRemoteAgentClient(...)` in `src/index.ts`, which wires an **Two things flow through everything:** -1. **`HttpRequester`** (`http-requester.ts`) is the only thing that touches the network (superagent). It bearer-auths, always sends `timezone=Europe/Paris`, JSON:API-deserializes responses into camelCase (falling back to raw body/text), and on HTTP errors throws a typed `AgentHttpError(status, body, responseText)`. Network/timeout errors (no response) propagate raw. +1. **`HttpRequester`** (`http-requester.ts`) is the only thing that touches the network (superagent). It bearer-auths, defaults the query to `timezone=Europe/Paris` (a caller-supplied `query.timezone` overrides it), JSON:API-deserializes responses into camelCase (falling back to raw body/text), and on HTTP errors throws a typed `AgentHttpError(status, body, responseText)`. Network/timeout errors (no response) propagate raw. 2. **`QuerySerializer`** (`query-serializer.ts`) turns a `SelectOptions` (`types.ts`) into the agent's query-string shape (`fields[collection]`, `page[size]`, JSON-stringified `filters`/`sort`). -**Smart actions** are the most stateful part. `Collection.action()` looks up the action's endpoint in the `ActionEndpointsByCollection` schema, then `FieldFormStates` (`action-fields/field-form-states.ts`) POSTs to `:endpoint/hooks/load` to fetch the dynamic form; `setFields` re-POSTs to `:endpoint/hooks/change` to re-evaluate dependent fields. Concrete `ActionField*` classes (`action-fields/`) are typed accessors over those form states; `getField` dispatches on the field type string. `Action.execute()` POSTs the collected values, and `toActionError` (`domains/action.ts`) translates `AgentHttpError` into semantic `ActionRequiresApprovalError` (403) / `ActionFormValidationError` (400/422). +**Smart actions** are the most stateful part. `Collection.action()` looks up the action's endpoint in the `ActionEndpointsByCollection` schema, then `FieldFormStates` (`action-fields/field-form-states.ts`) POSTs to `:endpoint/hooks/load` to fetch the dynamic form; `setFields` re-POSTs to `:endpoint/hooks/change` to re-evaluate dependent fields. Both hook calls carry `timezone` in the query when the action context supplied one, so date-dependent form defaults resolve in the caller's timezone instead of the `Europe/Paris` default; `Action.execute()` still uses the default. Concrete `ActionField*` classes (`action-fields/`) are typed accessors over those form states; `getField` dispatches on the field type string. `Action.execute()` POSTs the collected values, and `toActionError` (`domains/action.ts`) translates `AgentHttpError` into semantic `ActionRequiresApprovalError` (403) / `ActionFormValidationError` (400/422). Depends on `@forestadmin/datasource-toolkit` (for `PlainFilter`/`PlainSortClause`/chart types) and `@forestadmin/forestadmin-client` (for `ForestSchemaAction` and condition-tree types). From b30307ea35ebf0e08a7cb42fca58a1bcc8a5d51a Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 26 Aug 2026 00:05:33 +0200 Subject: [PATCH 05/11] refactor(agent-bff): load the action once before dispatching the verb --- .../src/action/action-routes-middleware.ts | 48 +++++++------------ 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/packages/agent-bff/src/action/action-routes-middleware.ts b/packages/agent-bff/src/action/action-routes-middleware.ts index 02edd7af6a..078138fe53 100644 --- a/packages/agent-bff/src/action/action-routes-middleware.ts +++ b/packages/agent-bff/src/action/action-routes-middleware.ts @@ -1,4 +1,4 @@ -import type { AgentActionClient, AgentActionClientOptions } from './agent-action-client'; +import type { Action, AgentActionClient, AgentActionClientOptions } from './agent-action-client'; import type { Logger } from '../ports/logger-port'; import type ReadModelStore from '../read-model/read-model-store'; import type { Context, Middleware } from 'koa'; @@ -79,26 +79,13 @@ export interface ActionRoutesMiddlewareOptions { async function handleForm( ctx: Context, - client: AgentActionClient, - collection: string, - actionName: string, - recordIds: string[], + action: Action, values: Record, logger: Logger, ): Promise { // Each agent-hitting call is wrapped on its own; getFields/extractRawLayout/mapping stay outside // callAgent so a local BFF bug surfaces as a 500, not a mislabelled agent error. Fields and // layout are read AFTER tryToSetFields because a change hook rebuilds them in place. - const action = await callAgent( - () => - client.loadAction({ - collection, - action: actionName, - recordIds, - timezone: ctx.state.timezone as string, - }), - logger, - ); const skippedFields = await callAgent(() => action.tryToSetFields(values), logger); ctx.status = 200; @@ -107,24 +94,10 @@ async function handleForm( async function handleExecute( ctx: Context, - client: AgentActionClient, - collection: string, - actionName: string, - recordIds: string[], + action: Action, values: Record, logger: Logger, ): Promise { - const action = await callAgent( - () => - client.loadAction({ - collection, - action: actionName, - recordIds, - timezone: ctx.state.timezone as string, - }), - logger, - ); - // setFields is strict: an unknown submitted field is a client error (400), not a 500. A transport // failure from the change-hook it triggers is a genuine agent error, so it goes to the mapper. try { @@ -214,10 +187,21 @@ export default function createActionRoutesMiddleware({ timeoutMs, }); + const action = await callAgent( + () => + client.loadAction({ + collection, + action: actionName, + recordIds, + timezone: ctx.state.timezone as string, + }), + logger, + ); + if (verb === 'execute') { - await handleExecute(ctx, client, collection, actionName, recordIds, values, logger); + await handleExecute(ctx, action, values, logger); } else { - await handleForm(ctx, client, collection, actionName, recordIds, values, logger); + await handleForm(ctx, action, values, logger); } }; } From 3aea93e0e36a4f06541c5e75eb49c04bd90547d7 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 26 Aug 2026 00:09:41 +0200 Subject: [PATCH 06/11] refactor(agent-bff): type the form handler on the form action subset --- .../agent-bff/src/action/action-routes-middleware.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/agent-bff/src/action/action-routes-middleware.ts b/packages/agent-bff/src/action/action-routes-middleware.ts index 078138fe53..d1f0e7ddbd 100644 --- a/packages/agent-bff/src/action/action-routes-middleware.ts +++ b/packages/agent-bff/src/action/action-routes-middleware.ts @@ -1,4 +1,9 @@ -import type { Action, AgentActionClient, AgentActionClientOptions } from './agent-action-client'; +import type { + Action, + ActionForm, + AgentActionClient, + AgentActionClientOptions, +} from './agent-action-client'; import type { Logger } from '../ports/logger-port'; import type ReadModelStore from '../read-model/read-model-store'; import type { Context, Middleware } from 'koa'; @@ -79,7 +84,7 @@ export interface ActionRoutesMiddlewareOptions { async function handleForm( ctx: Context, - action: Action, + action: ActionForm, values: Record, logger: Logger, ): Promise { From dbe46248c4741a1d94c533b4a45aceca857fe5d7 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 26 Aug 2026 09:29:57 +0200 Subject: [PATCH 07/11] refactor(agent-bff): pass action handler arguments as one object --- .../src/action/action-routes-middleware.ts | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/agent-bff/src/action/action-routes-middleware.ts b/packages/agent-bff/src/action/action-routes-middleware.ts index d1f0e7ddbd..0fa42320a6 100644 --- a/packages/agent-bff/src/action/action-routes-middleware.ts +++ b/packages/agent-bff/src/action/action-routes-middleware.ts @@ -82,12 +82,19 @@ export interface ActionRoutesMiddlewareOptions { createClient?: (options: AgentActionClientOptions) => AgentActionClient; } -async function handleForm( - ctx: Context, - action: ActionForm, - values: Record, - logger: Logger, -): Promise { +interface ActionHandlerArgs { + ctx: Context; + action: TAction; + values: Record; + logger: Logger; +} + +async function handleForm({ + ctx, + action, + values, + logger, +}: ActionHandlerArgs): Promise { // Each agent-hitting call is wrapped on its own; getFields/extractRawLayout/mapping stay outside // callAgent so a local BFF bug surfaces as a 500, not a mislabelled agent error. Fields and // layout are read AFTER tryToSetFields because a change hook rebuilds them in place. @@ -97,12 +104,12 @@ async function handleForm( ctx.body = mapActionForm(action, skippedFields, extractRawLayout(action)); } -async function handleExecute( - ctx: Context, - action: Action, - values: Record, - logger: Logger, -): Promise { +async function handleExecute({ + ctx, + action, + values, + logger, +}: ActionHandlerArgs): Promise { // setFields is strict: an unknown submitted field is a client error (400), not a 500. A transport // failure from the change-hook it triggers is a genuine agent error, so it goes to the mapper. try { @@ -203,10 +210,12 @@ export default function createActionRoutesMiddleware({ logger, ); + const handlerArgs = { ctx, action, values, logger }; + if (verb === 'execute') { - await handleExecute(ctx, action, values, logger); + await handleExecute(handlerArgs); } else { - await handleForm(ctx, action, values, logger); + await handleForm(handlerArgs); } }; } From 84b55419e2e167900812b1ed575542ddfa771f89 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 26 Aug 2026 16:16:55 +0200 Subject: [PATCH 08/11] refactor(agent-client): build field form states from an options object --- .../src/action-fields/field-form-states.ts | 34 ++- .../agent-client/src/domains/collection.ts | 18 +- .../test/action-fields/action-fields.test.ts | 12 +- .../action-fields/field-form-states.test.ts | 209 +++++++++--------- .../test/action-fields/file-value.test.ts | 12 +- .../test/domains/collection.test.ts | 40 ++-- 6 files changed, 167 insertions(+), 158 deletions(-) diff --git a/packages/agent-client/src/action-fields/field-form-states.ts b/packages/agent-client/src/action-fields/field-form-states.ts index 40bc0ba855..76c17b7618 100644 --- a/packages/agent-client/src/action-fields/field-form-states.ts +++ b/packages/agent-client/src/action-fields/field-form-states.ts @@ -9,6 +9,18 @@ import ActionFieldMultipleChoice from './action-field-multiple-choice'; import FieldGetter from './field-getter'; import encodeFileFieldValue from './file-value'; +export interface FieldFormStatesOptions { + actionName: string; + actionPath: string; + collectionName: string; + httpRequester: HttpRequester; + ids: string[]; + hooks?: ForestSchemaAction['hooks']; + fallbackFields?: ForestSchemaAction['fields']; + fallbackLayout?: ForestSchemaAction['layout']; + timezone?: string; +} + export default class FieldFormStates { private readonly fields: FieldGetter[]; private readonly actionName: string; @@ -22,17 +34,17 @@ export default class FieldFormStates { private readonly fallbackLayout?: ForestSchemaAction['layout']; private readonly timezone?: string; - constructor( - actionName: string, - actionPath: string, - collectionName: string, - httpRequester: HttpRequester, - ids: string[], - hooks?: ForestSchemaAction['hooks'], - fallbackFields?: ForestSchemaAction['fields'], - fallbackLayout?: ForestSchemaAction['layout'], - timezone?: string, - ) { + constructor({ + actionName, + actionPath, + collectionName, + httpRequester, + ids, + hooks, + fallbackFields, + fallbackLayout, + timezone, + }: FieldFormStatesOptions) { this.fields = []; this.actionName = actionName; this.actionPath = actionPath; diff --git a/packages/agent-client/src/domains/collection.ts b/packages/agent-client/src/domains/collection.ts index 9130cb508e..8cb3c25339 100644 --- a/packages/agent-client/src/domains/collection.ts +++ b/packages/agent-client/src/domains/collection.ts @@ -37,17 +37,17 @@ export default class Collection extends CollectionChart { .filter((id): id is RecordId => Boolean(id)) .map(serializeRecordId); - const fieldsFormStates = new FieldFormStates( + const fieldsFormStates = new FieldFormStates({ actionName, - actionInfo.endpoint, - this.name, - this.httpRequester, + actionPath: actionInfo.endpoint, + collectionName: this.name, + httpRequester: this.httpRequester, ids, - actionInfo.hooks, - actionInfo.fields, - actionInfo.layout, - actionContext?.timezone, - ); + hooks: actionInfo.hooks, + fallbackFields: actionInfo.fields, + fallbackLayout: actionInfo.layout, + timezone: actionContext?.timezone, + }); const action = new Action( this.name, diff --git a/packages/agent-client/test/action-fields/action-fields.test.ts b/packages/agent-client/test/action-fields/action-fields.test.ts index 139aedded9..a590eaf65e 100644 --- a/packages/agent-client/test/action-fields/action-fields.test.ts +++ b/packages/agent-client/test/action-fields/action-fields.test.ts @@ -24,13 +24,13 @@ describe('ActionField implementations', () => { beforeEach(() => { jest.clearAllMocks(); httpRequester = { query: jest.fn() } as unknown as jest.Mocked; - fieldFormStates = new FieldFormStates( - 'testAction', - '/forest/actions/test', - 'users', + fieldFormStates = new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test', + collectionName: 'users', httpRequester, - ['1'], - ); + ids: ['1'], + }); }); const setupFields = async (fields: PlainField[]) => { diff --git a/packages/agent-client/test/action-fields/field-form-states.test.ts b/packages/agent-client/test/action-fields/field-form-states.test.ts index 5987996124..573e17d691 100644 --- a/packages/agent-client/test/action-fields/field-form-states.test.ts +++ b/packages/agent-client/test/action-fields/field-form-states.test.ts @@ -18,13 +18,13 @@ describe('FieldFormStates', () => { httpRequester = { query: jest.fn(), } as unknown as jest.Mocked; - fieldFormStates = new FieldFormStates( - 'testAction', - '/forest/actions/test-action', - 'users', + fieldFormStates = new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test-action', + collectionName: 'users', httpRequester, - ['1', '2'], - ); + ids: ['1', '2'], + }); }); describe('loadInitialState', () => { @@ -277,17 +277,14 @@ describe('FieldFormStates', () => { describe('timezone', () => { const withTimezone = (timezone?: string) => - new FieldFormStates( - 'testAction', - '/forest/actions/test-action', - 'users', + new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test-action', + collectionName: 'users', httpRequester, - ['1'], - undefined, - undefined, - undefined, + ids: ['1'], timezone, - ); + }); it('should send the timezone in the load hook query when one is provided', async () => { httpRequester.query.mockResolvedValue({ fields: [], layout: [] }); @@ -362,14 +359,14 @@ describe('FieldFormStates', () => { describe('hooks configuration', () => { it('should not throw when hooks.load is false and server returns 404', async () => { - const formStates = new FieldFormStates( - 'testAction', - '/forest/actions/test-action', - 'users', + const formStates = new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test-action', + collectionName: 'users', httpRequester, - ['1'], - { load: false, change: [] }, - ); + ids: ['1'], + hooks: { load: false, change: [] }, + }); const error404 = new AgentHttpError(404, null, 'Not Found'); httpRequester.query.mockRejectedValue(error404); @@ -388,15 +385,15 @@ describe('FieldFormStates', () => { { field: 'note', type: 'String' }, ]; - const formStates = new FieldFormStates( - 'testAction', - '/forest/actions/test-action', - 'users', + const formStates = new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test-action', + collectionName: 'users', httpRequester, - ['1'], - { load: false, change: [] }, + ids: ['1'], + hooks: { load: false, change: [] }, fallbackFields, - ); + }); await formStates.loadInitialState(); @@ -436,15 +433,15 @@ describe('FieldFormStates', () => { }, ]; - const formStates = new FieldFormStates( - 'testAction', - '/forest/actions/test-action', - 'users', + const formStates = new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test-action', + collectionName: 'users', httpRequester, - ['1'], - { load: false, change: ['onFieldChanged'] }, + ids: ['1'], + hooks: { load: false, change: ['onFieldChanged'] }, fallbackFields, - ); + }); await formStates.loadInitialState(); @@ -468,16 +465,16 @@ describe('FieldFormStates', () => { { component: 'Input', fieldId: 'note' }, ] as never[]; - const formStates = new FieldFormStates( - 'testAction', - '/forest/actions/test-action', - 'users', + const formStates = new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test-action', + collectionName: 'users', httpRequester, - ['1'], - { load: false, change: [] }, + ids: ['1'], + hooks: { load: false, change: [] }, fallbackFields, fallbackLayout, - ); + }); await formStates.loadInitialState(); @@ -486,15 +483,15 @@ describe('FieldFormStates', () => { }); it('should skip the request when hooks.load is false and the static form is empty', async () => { - const formStates = new FieldFormStates( - 'testAction', - '/forest/actions/test-action', - 'users', + const formStates = new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test-action', + collectionName: 'users', httpRequester, - ['1'], - { load: false, change: [] }, - [], - ); + ids: ['1'], + hooks: { load: false, change: [] }, + fallbackFields: [], + }); await formStates.loadInitialState(); @@ -503,13 +500,13 @@ describe('FieldFormStates', () => { }); it('should probe and swallow the 404 when the schema has no hooks at all', async () => { - const formStates = new FieldFormStates( - 'testAction', - '/forest/actions/test-action', - 'users', + const formStates = new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test-action', + collectionName: 'users', httpRequester, - ['1'], - ); + ids: ['1'], + }); const error404 = new AgentHttpError(404, null, 'Not Found'); httpRequester.query.mockRejectedValue(error404); @@ -523,14 +520,14 @@ describe('FieldFormStates', () => { }); it('should rethrow the 404 when the schema declares a load hook', async () => { - const formStates = new FieldFormStates( - 'testAction', - '/forest/actions/test-action', - 'users', + const formStates = new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test-action', + collectionName: 'users', httpRequester, - ['1'], - { load: true, change: [] }, - ); + ids: ['1'], + hooks: { load: true, change: [] }, + }); const error404 = new AgentHttpError(404, null, 'Not Found'); httpRequester.query.mockRejectedValue(error404); @@ -539,14 +536,14 @@ describe('FieldFormStates', () => { }); it('should throw when hooks.load is false but server returns 500', async () => { - const formStates = new FieldFormStates( - 'testAction', - '/forest/actions/test-action', - 'users', + const formStates = new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test-action', + collectionName: 'users', httpRequester, - ['1'], - { load: false, change: [] }, - ); + ids: ['1'], + hooks: { load: false, change: [] }, + }); const error500 = new AgentHttpError(500, null, 'Internal Server Error'); httpRequester.query.mockRejectedValue(error500); @@ -555,14 +552,14 @@ describe('FieldFormStates', () => { }); it('should load fields when hooks.load is false but server responds successfully', async () => { - const formStates = new FieldFormStates( - 'testAction', - '/forest/actions/test-action', - 'users', + const formStates = new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test-action', + collectionName: 'users', httpRequester, - ['1'], - { load: false, change: [] }, - ); + ids: ['1'], + hooks: { load: false, change: [] }, + }); httpRequester.query.mockResolvedValue({ fields: [ @@ -578,14 +575,14 @@ describe('FieldFormStates', () => { }); it('should call loadInitialState when hooks.load is true', async () => { - const formStates = new FieldFormStates( - 'testAction', - '/forest/actions/test-action', - 'users', + const formStates = new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test-action', + collectionName: 'users', httpRequester, - ['1'], - { load: true, change: [] }, - ); + ids: ['1'], + hooks: { load: true, change: [] }, + }); httpRequester.query.mockResolvedValue({ fields: [], layout: [] }); @@ -597,14 +594,14 @@ describe('FieldFormStates', () => { }); it('should skip change hook when hooks.change is empty', async () => { - const formStates = new FieldFormStates( - 'testAction', - '/forest/actions/test-action', - 'users', + const formStates = new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test-action', + collectionName: 'users', httpRequester, - ['1'], - { load: true, change: [] }, - ); + ids: ['1'], + hooks: { load: true, change: [] }, + }); httpRequester.query.mockResolvedValue({ fields: [ @@ -621,14 +618,14 @@ describe('FieldFormStates', () => { }); it('should call change hook when the changed field has a hook', async () => { - const formStates = new FieldFormStates( - 'testAction', - '/forest/actions/test-action', - 'users', + const formStates = new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test-action', + collectionName: 'users', httpRequester, - ['1'], - { load: true, change: ['changeHook'] }, - ); + ids: ['1'], + hooks: { load: true, change: ['changeHook'] }, + }); httpRequester.query.mockResolvedValue({ fields: [ @@ -661,14 +658,14 @@ describe('FieldFormStates', () => { }); it('should skip change hook when the changed field has no hook, even with change hooks', async () => { - const formStates = new FieldFormStates( - 'testAction', - '/forest/actions/test-action', - 'users', + const formStates = new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test-action', + collectionName: 'users', httpRequester, - ['1'], - { load: true, change: ['changeHook'] }, - ); + ids: ['1'], + hooks: { load: true, change: ['changeHook'] }, + }); httpRequester.query.mockResolvedValue({ fields: [ diff --git a/packages/agent-client/test/action-fields/file-value.test.ts b/packages/agent-client/test/action-fields/file-value.test.ts index 13af88a019..886d145f22 100644 --- a/packages/agent-client/test/action-fields/file-value.test.ts +++ b/packages/agent-client/test/action-fields/file-value.test.ts @@ -30,13 +30,13 @@ describe('file values in action forms', () => { beforeEach(() => { jest.clearAllMocks(); httpRequester = { query: jest.fn() } as unknown as jest.Mocked; - fieldFormStates = new FieldFormStates( - 'attachDocument', - '/forest/actions/attach-document', - 'operations', + fieldFormStates = new FieldFormStates({ + actionName: 'attachDocument', + actionPath: '/forest/actions/attach-document', + collectionName: 'operations', httpRequester, - ['1'], - ); + ids: ['1'], + }); }); describe('on a File field', () => { diff --git a/packages/agent-client/test/domains/collection.test.ts b/packages/agent-client/test/domains/collection.test.ts index eb2944edea..7055a0b810 100644 --- a/packages/agent-client/test/domains/collection.test.ts +++ b/packages/agent-client/test/domains/collection.test.ts @@ -408,33 +408,33 @@ describe('Collection', () => { it('should forward the action context timezone to the form states', async () => { await collection.action('sendEmail', { recordIds: ['1'], timezone: 'America/New_York' }); - expect(FieldFormStates).toHaveBeenCalledWith( - 'sendEmail', - '/forest/actions/send-email', - 'users', + expect(FieldFormStates).toHaveBeenCalledWith({ + actionName: 'sendEmail', + actionPath: '/forest/actions/send-email', + collectionName: 'users', httpRequester, - ['1'], - { load: false, change: [] }, - [], - undefined, - 'America/New_York', - ); + ids: ['1'], + hooks: { load: false, change: [] }, + fallbackFields: [], + fallbackLayout: undefined, + timezone: 'America/New_York', + }); }); it('should forward no timezone when the action context omits it', async () => { await collection.action('sendEmail', { recordIds: ['1'] }); - expect(FieldFormStates).toHaveBeenCalledWith( - 'sendEmail', - '/forest/actions/send-email', - 'users', + expect(FieldFormStates).toHaveBeenCalledWith({ + actionName: 'sendEmail', + actionPath: '/forest/actions/send-email', + collectionName: 'users', httpRequester, - ['1'], - { load: false, change: [] }, - [], - undefined, - undefined, - ); + ids: ['1'], + hooks: { load: false, change: [] }, + fallbackFields: [], + fallbackLayout: undefined, + timezone: undefined, + }); }); it('should pipe-encode composite recordIds when executing the action', async () => { From 768de7f85f3ab1198bf5bbd9a5f45c2dc6040189 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 26 Aug 2026 16:21:29 +0200 Subject: [PATCH 09/11] test(agent-client): assert the hook query by path instead of call index --- .../test/action-fields/field-form-states.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/agent-client/test/action-fields/field-form-states.test.ts b/packages/agent-client/test/action-fields/field-form-states.test.ts index 573e17d691..ad58a5deb4 100644 --- a/packages/agent-client/test/action-fields/field-form-states.test.ts +++ b/packages/agent-client/test/action-fields/field-form-states.test.ts @@ -304,7 +304,12 @@ describe('FieldFormStates', () => { await withTimezone().loadInitialState(); - expect(httpRequester.query.mock.calls[0][0].query).toBeUndefined(); + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/forest/actions/test-action/hooks/load', + query: undefined, + }), + ); }); it('should send the timezone in the change hook query when one is provided', async () => { @@ -353,7 +358,12 @@ describe('FieldFormStates', () => { await formStates.setFieldValue('name', 'updated'); - expect(httpRequester.query.mock.calls[1][0].query).toBeUndefined(); + expect(httpRequester.query).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/forest/actions/test-action/hooks/change', + query: undefined, + }), + ); }); }); From f6e104f8edfe657315ea4b74d9166b657f02033b Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 26 Aug 2026 16:21:30 +0200 Subject: [PATCH 10/11] refactor(agent-bff): name the load action param actionName --- .../agent-bff/src/action/action-routes-middleware.ts | 2 +- packages/agent-bff/src/action/agent-action-client.ts | 6 +++--- .../test/action/action-routes-middleware.test.ts | 12 ++++++------ .../test/action/agent-action-client.test.ts | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/agent-bff/src/action/action-routes-middleware.ts b/packages/agent-bff/src/action/action-routes-middleware.ts index 0fa42320a6..842f36e434 100644 --- a/packages/agent-bff/src/action/action-routes-middleware.ts +++ b/packages/agent-bff/src/action/action-routes-middleware.ts @@ -203,7 +203,7 @@ export default function createActionRoutesMiddleware({ () => client.loadAction({ collection, - action: actionName, + actionName, recordIds, timezone: ctx.state.timezone as string, }), diff --git a/packages/agent-bff/src/action/agent-action-client.ts b/packages/agent-bff/src/action/agent-action-client.ts index 222d281970..8ee1adf19e 100644 --- a/packages/agent-bff/src/action/agent-action-client.ts +++ b/packages/agent-bff/src/action/agent-action-client.ts @@ -31,7 +31,7 @@ export interface Action extends ActionForm { export interface LoadActionParams { collection: string; - action: string; + actionName: string; recordIds: string[]; timezone: string; } @@ -77,7 +77,7 @@ export default function createAgentActionClient({ }); return { - loadAction: ({ collection, action, recordIds, timezone }) => - client.collection(collection).action(action, { recordIds, timezone }), + loadAction: ({ collection, actionName, recordIds, timezone }) => + client.collection(collection).action(actionName, { recordIds, timezone }), }; } diff --git a/packages/agent-bff/test/action/action-routes-middleware.test.ts b/packages/agent-bff/test/action/action-routes-middleware.test.ts index 379baa8c63..35a053f681 100644 --- a/packages/agent-bff/test/action/action-routes-middleware.test.ts +++ b/packages/agent-bff/test/action/action-routes-middleware.test.ts @@ -304,7 +304,7 @@ describe('action routes middleware', () => { expect(loadAction).toHaveBeenCalledWith({ collection: 'users', - action: 'approve', + actionName: 'approve', recordIds: ['1|2'], timezone: TIMEZONE, }); @@ -321,7 +321,7 @@ describe('action routes middleware', () => { expect(loadAction).toHaveBeenCalledWith({ collection: 'users', - action: 'approve', + actionName: 'approve', recordIds: ['0'], timezone: TIMEZONE, }); @@ -339,7 +339,7 @@ describe('action routes middleware', () => { expect(response.status).toBe(200); expect(loadAction).toHaveBeenCalledWith({ collection: 'users', - action: 'approve', + actionName: 'approve', recordIds: [], timezone: TIMEZONE, }); @@ -358,7 +358,7 @@ describe('action routes middleware', () => { expect(loadAction).toHaveBeenCalledWith({ collection: 'users', - action: 'approve', + actionName: 'approve', recordIds: ['42'], timezone: 'America/New_York', }); @@ -510,7 +510,7 @@ describe('action execute', () => { expect(loadAction).toHaveBeenCalledWith({ collection: 'users', - action: 'approve', + actionName: 'approve', recordIds: ['42'], timezone: TIMEZONE, }); @@ -534,7 +534,7 @@ describe('action execute', () => { expect(loadAction).toHaveBeenCalledWith({ collection: 'users', - action: 'approve', + actionName: 'approve', recordIds: ['42'], timezone: 'Asia/Tokyo', }); diff --git a/packages/agent-bff/test/action/agent-action-client.test.ts b/packages/agent-bff/test/action/agent-action-client.test.ts index a20dc12ac0..d8e69f2b95 100644 --- a/packages/agent-bff/test/action/agent-action-client.test.ts +++ b/packages/agent-bff/test/action/agent-action-client.test.ts @@ -32,7 +32,7 @@ describe('createAgentActionClient', () => { }); const result = await client.loadAction({ collection: 'users', - action: 'approve', + actionName: 'approve', recordIds: ['1', '2'], timezone: 'America/New_York', }); From 11c2a8334764a6edc066fada5a7bd43274c3f3f6 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 26 Aug 2026 16:21:31 +0200 Subject: [PATCH 11/11] docs(agent-bff): drop the ticket id from the raw layout comment --- packages/agent-bff/src/action/agent-action-client.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent-bff/src/action/agent-action-client.ts b/packages/agent-bff/src/action/agent-action-client.ts index 8ee1adf19e..af2be2c3df 100644 --- a/packages/agent-bff/src/action/agent-action-client.ts +++ b/packages/agent-bff/src/action/agent-action-client.ts @@ -49,8 +49,8 @@ export interface AgentActionClientOptions { // The raw layout must be read AFTER tryToSetFields: a change hook rebuilds fields+layout in place. // agent-client's `Action.getLayout()` only returns an `ActionLayoutRoot` wrapper whose element array -// lives in a protected field. PRD-673's rollback contract forbade agent-client changes, so we read -// it through a cast rather than adding a public accessor. `extract-raw-layout.test.ts` builds a real +// lives in a protected field. The rollback contract forbade agent-client changes, so we read it +// through a cast rather than adding a public accessor. `extract-raw-layout.test.ts` builds a real // `ActionLayoutRoot` and asserts this unwraps it, so a rename of that field fails a test. export function extractRawLayout(action: ActionForm): ForestServerActionFormLayoutElement[] { const root = action.getLayout() as { layout?: ForestServerActionFormLayoutElement[] };