diff --git a/packages/agent-bff/src/action/action-routes-middleware.ts b/packages/agent-bff/src/action/action-routes-middleware.ts index 25c6409c7f..842f36e434 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 { 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'; @@ -77,42 +82,34 @@ export interface ActionRoutesMiddlewareOptions { createClient?: (options: AgentActionClientOptions) => AgentActionClient; } -async function handleForm( - ctx: Context, - client: AgentActionClient, - collection: string, - actionName: string, - recordIds: string[], - 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. - const action = await callAgent( - () => client.loadAction(collection, actionName, recordIds), - logger, - ); const skippedFields = await callAgent(() => action.tryToSetFields(values), logger); ctx.status = 200; ctx.body = mapActionForm(action, skippedFields, extractRawLayout(action)); } -async function handleExecute( - ctx: Context, - client: AgentActionClient, - collection: string, - actionName: string, - recordIds: string[], - values: Record, - logger: Logger, -): Promise { - const action = await callAgent( - () => client.loadAction(collection, actionName, recordIds), - logger, - ); - +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 { @@ -202,10 +199,23 @@ export default function createActionRoutesMiddleware({ timeoutMs, }); + const action = await callAgent( + () => + client.loadAction({ + collection, + actionName, + recordIds, + timezone: ctx.state.timezone as string, + }), + logger, + ); + + const handlerArgs = { ctx, action, values, logger }; + if (verb === 'execute') { - await handleExecute(ctx, client, collection, actionName, recordIds, values, logger); + await handleExecute(handlerArgs); } else { - await handleForm(ctx, client, collection, actionName, recordIds, values, logger); + await handleForm(handlerArgs); } }; } diff --git a/packages/agent-bff/src/action/agent-action-client.ts b/packages/agent-bff/src/action/agent-action-client.ts index cd597ef960..af2be2c3df 100644 --- a/packages/agent-bff/src/action/agent-action-client.ts +++ b/packages/agent-bff/src/action/agent-action-client.ts @@ -29,8 +29,15 @@ export interface Action extends ActionForm { execute(): Promise; } +export interface LoadActionParams { + collection: string; + actionName: string; + recordIds: string[]; + timezone: string; +} + export interface AgentActionClient { - loadAction(collection: string, action: string, recordIds: string[]): Promise; + loadAction(params: LoadActionParams): Promise; } export interface AgentActionClientOptions { @@ -42,7 +49,7 @@ 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 +// 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[] { @@ -70,7 +77,7 @@ export default function createAgentActionClient({ }); return { - loadAction: (collection, action, recordIds) => - client.collection(collection).action(action, { recordIds }), + 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 13e0827dea..35a053f681 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,12 @@ 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({ + collection: 'users', + actionName: 'approve', + recordIds: ['1|2'], + timezone: TIMEZONE, + }); }); it('coerces a numeric zero recordId to a string so it survives the downstream filter', async () => { @@ -313,7 +319,12 @@ describe('action routes middleware', () => { .post('/agent/v1/users/actions/approve/form') .send({ recordIds: [0] }); - expect(loadAction).toHaveBeenCalledWith('users', 'approve', ['0']); + expect(loadAction).toHaveBeenCalledWith({ + collection: 'users', + actionName: 'approve', + recordIds: ['0'], + timezone: TIMEZONE, + }); }); it('accepts an empty recordIds array for a global action', async () => { @@ -326,7 +337,31 @@ describe('action routes middleware', () => { .send({ recordIds: [] }); expect(response.status).toBe(200); - expect(loadAction).toHaveBeenCalledWith('users', 'approve', []); + expect(loadAction).toHaveBeenCalledWith({ + collection: 'users', + actionName: 'approve', + recordIds: [], + timezone: 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({ + collection: 'users', + actionName: 'approve', + recordIds: ['42'], + timezone: 'America/New_York', + }); }); it('returns 400 invalid_request with no agent call when recordIds is missing', async () => { @@ -473,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']); + expect(loadAction).toHaveBeenCalledWith({ + collection: 'users', + actionName: '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 @@ -481,6 +521,25 @@ 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({ + collection: 'users', + actionName: 'approve', + recordIds: ['42'], + timezone: '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..d8e69f2b95 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,12 @@ describe('createAgentActionClient', () => { token: 'jwt-token', actionEndpoints, }); - const result = await client.loadAction('users', 'approve', ['1', '2']); + const result = await client.loadAction({ + collection: 'users', + actionName: 'approve', + recordIds: ['1', '2'], + timezone: 'America/New_York', + }); expect(HttpRequester).toHaveBeenCalledWith('jwt-token', { url: 'https://agent.example.com' }); expect(createRemoteAgentClientMock).toHaveBeenCalledWith({ @@ -40,7 +45,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); }); 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). 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..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; @@ -20,17 +32,19 @@ export default class FieldFormStates { private readonly hooks?: ForestSchemaAction['hooks']; private readonly fallbackFields?: ForestSchemaAction['fields']; private readonly fallbackLayout?: ForestSchemaAction['layout']; - - constructor( - actionName: string, - actionPath: string, - collectionName: string, - httpRequester: HttpRequester, - ids: string[], - hooks?: ForestSchemaAction['hooks'], - fallbackFields?: ForestSchemaAction['fields'], - fallbackLayout?: ForestSchemaAction['layout'], - ) { + private readonly timezone?: string; + + constructor({ + actionName, + actionPath, + collectionName, + httpRequester, + ids, + hooks, + fallbackFields, + fallbackLayout, + timezone, + }: FieldFormStatesOptions) { this.fields = []; this.actionName = actionName; this.actionPath = actionPath; @@ -41,6 +55,7 @@ export default class FieldFormStates { this.hooks = hooks; this.fallbackFields = fallbackFields; this.fallbackLayout = fallbackLayout; + this.timezone = timezone; } getFieldValues(): Record { @@ -108,6 +123,7 @@ export default class FieldFormStates { method: 'post', path: `${this.actionPath}/hooks/load`, body: requestBody, + query: this.buildTimezoneQuery(), }); this.clearFieldsAndLayout(); @@ -147,6 +163,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 +193,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..8cb3c25339 100644 --- a/packages/agent-client/src/domains/collection.ts +++ b/packages/agent-client/src/domains/collection.ts @@ -37,16 +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, - ); + 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 74da0ca332..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 @@ -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', () => { @@ -275,16 +275,108 @@ describe('FieldFormStates', () => { }); }); + describe('timezone', () => { + const withTimezone = (timezone?: string) => + new FieldFormStates({ + actionName: 'testAction', + actionPath: '/forest/actions/test-action', + collectionName: 'users', + httpRequester, + ids: ['1'], + 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).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 () => { + 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).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/forest/actions/test-action/hooks/change', + query: undefined, + }), + ); + }); + }); + 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); @@ -303,15 +395,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(); @@ -351,15 +443,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(); @@ -383,16 +475,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(); @@ -401,15 +493,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(); @@ -418,13 +510,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); @@ -438,14 +530,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); @@ -454,14 +546,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); @@ -470,14 +562,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: [ @@ -493,14 +585,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: [] }); @@ -512,14 +604,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: [ @@ -536,14 +628,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: [ @@ -576,14 +668,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 8df256bae2..7055a0b810 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({ + actionName: 'sendEmail', + actionPath: '/forest/actions/send-email', + collectionName: 'users', + httpRequester, + 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({ + actionName: 'sendEmail', + actionPath: '/forest/actions/send-email', + collectionName: 'users', + httpRequester, + ids: ['1'], + hooks: { load: false, change: [] }, + fallbackFields: [], + fallbackLayout: undefined, + timezone: 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: {} }));