Skip to content

Commit c2be734

Browse files
fix(core): a successful operation: 'update' action refreshes the record it wrote (#29970)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB4xjiquevJqfBXiUoqfQwa1)_ Fixes #29935 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:声明式更新动作(`operation: 'update'`,比如教程里的「Resolve」)在服务器返回成功后,Console 现在会重新读取这条记录:记录页(整页和抽屉)显示写入后的值,`visible` 条件变为假的按钮随之消失,来源列表里不再符合筛选条件的记录也会离开列表。作者不需要再额外写 `refreshAfter: true`。 - **为什么改**:spec 会把没写的 `refreshAfter` 填成 `false`,Console 拿到的正是这个解析后的形状;服务端动作分派原本只在 `refreshAfter !== false` 时刷新,所以声明式更新从不刷新。服务器已经写成「已解决」、提示也说成功了,页面却还显示「Open」和「Resolve」按钮,用户会以为失败而重复点击。 - **风险与回滚**:风险低。只改了一处判断:声明式更新成功后发出记录级失效通知(与客户端 `dataSource.update` 已有的行为一致);其他动作仍按 `refreshAfter` 决定是否刷新,spec 默认值没有动。回滚即还原本 PR。 - **先看**:`ui/packages/core/src/actions/serverActionHandler.ts`(`shouldRefresh` 一行及其注释)、`ui/packages/app-shell/src/views/RecordDetailView.updateActionRefresh-29935.test.tsx`(整页、抽屉、列表、对照组)。 - **两分钟验证**:在仓根运行 `pnpm --dir ui exec vitest run packages/app-shell/src/views/RecordDetailView.updateActionRefresh-29935.test.tsx packages/core/src/actions/__tests__/serverActionHandler.test.ts`,预期 33 个测试全部通过。 - **需要你做的**:无,席位落地。 ## What changes **Which path the tutorial action takes, measured.** An `operation: 'update'` action never reaches the record page's `api` handler (the two `refreshAfter === true` gates in `RecordDetailView.tsx` that the card names). The spec pins `type: 'script'` on it, and `ActionRunner.dispatchCollected` reads `operation` before `type`. So it goes to `executeUpdateOperation`, which hands it to the registered `script` dispatch. On the record page that dispatch is `createConsoleServerActionHandler`, which wraps core's `createServerActionHandler`. That handler refreshed on `action.refreshAfter !== false`. **Why the two paths look different but behave the same.** `ActionSchema.safeParse` on the card's action, run against `packages/spec/src` at base `40fa4335`, gives `{"type":"script","operation":"update","refreshAfter":false}`. The spec's `.default(false)` fills in the key, and the Console is served that parsed shape. With the key present, `!== false` behaves exactly like `=== true`. The two spellings differ only for a row that reaches the client unparsed. That is why the network capture shows the 200 and no re-read. **The fix.** One line in `createServerActionHandler`: ```ts const shouldRefresh = action.operation === 'update' || action.refreshAfter !== false; ``` A successful declared update now calls `onRefresh`. On the record page, `onRefresh` is `notifyRecordChanged`, which sends `notifyDataChanged({ objectName, recordId })`. The console list runtime (`useConsoleActionRuntime`) passes its own `onRefresh` to the same factory, so a declared update run from a list row refreshes there too. **Re-read, not apply.** The route does answer with the written record (`{ ok, action, objectName, recordId, result }`). The page does not apply it. The record page reads its record with `$expand` on every readable relation, and the server strips fields the reader may not read. The route's `result` is the engine's update answer, which has neither shape. Also, the invalidation bus carries scopes, not rows. So the page re-reads through its own read path. **The list mechanism.** A record-scoped change matches every reader that subscribes with `useDataInvalidation(objectName)` and no record id (`dataChangeMatches`: "a hook WITHOUT recordId is a list/aggregate reader"). `ListView` and `ObjectView` subscribe that way and refetch with their own filter, so the record leaves a list it no longer matches. The drawer is the same `RecordDetailView`, mounted `embedded` beside its list. One event refreshes both. **What `refreshAfter: false` still governs.** It still decides the refresh for: - every server (`script`) action that is not a declared update, - the record page's `api` handler (both `=== true` gates are unchanged), - the `newTabUrl` fast path in `createConsoleServerActionHandler`, - the runner's `result.reload`. The spec default is untouched. Nothing under `packages/spec/**` changes, and no lint rule is added. **Docs (ui `AGENTS.md` #2).** Two one-line edits: - the `onRefresh` comment in `ui/packages/core/README.md` now says it also runs after every `operation: 'update'`; - the "Runs but the list looks stale" row in `content/docs/ui/actions.mdx` now says a declared update re-reads on its own. ## Tests - New `ui/packages/app-shell/src/views/RecordDetailView.updateActionRefresh-29935.test.tsx`. It mounts the real record page, header, runner and console server-action handler, and only the authenticated fetch is faked. The action is the card's action as the Console is served it, with `type: 'script'` and `refreshAfter: false` filled in. - **Full page.** Before the click, the path bar's current stage is "Open, current stage". After the 200, `findOne` runs again, the current stage is "Resolved, current stage", and Resolve is gone from the header toolbar. - **Drawer** (`embedded`, mounted next to a list reader that holds the Open filter on `useDataInvalidation(objectName)`). The same click re-reads the record and removes the Resolve button. The Open list goes from `[tkt_1, tkt_2]` to `[tkt_2]`. - **CONTROL.** A non-update `script` action served with `refreshAfter: false` gets its 200 and its toast, and `findOne` is not called again. - `ui/packages/core/src/actions/__tests__/serverActionHandler.test.ts` adds two tests: - a successful update with `refreshAfter: false` calls `onRefresh` once and returns `reload: true`; - a refused update (403) does not call `onRefresh`. ## Acceptance notes - The card's order points at `RecordDetailView.tsx`. That file is not touched, because the tutorial action never reaches the gates there (measured above). The diff stays on the action-success path, in core's dispatch. - `skills/objectstack-ui/rules/actions.md` still says "Set `refreshAfter: true` whenever the action mutates the current record". That is still harmless, but it is no longer needed for an `operation: 'update'` action. `skills/**` is a Tier H surface, so I left it alone. carrier: the ui seat, if it wants a skills follow-up. - Reading, not filed: an action that reaches the client unparsed with no `refreshAfter` key still refreshes through `!== false`, while the parsed default `false` does not. This difference was there before this PR and this diff does not change it. The spec's `.default(false)` is the contract. carrier: none. ## Verification All of it ran on head `61f02c31`, in worktree `objectstack-issue-29935`, under `scripts/pm/os-verify-lock.sh`. - Build first: `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' --filter @objectstack/formula build`, VERDICT command-exit 0 (9m51s). - The new pins: `pnpm --dir ui exec vitest run packages/app-shell/src/views/RecordDetailView.updateActionRefresh-29935.test.tsx packages/core/src/actions/__tests__/serverActionHandler.test.ts`, VERDICT 0, 2 files and 33 tests passed. - Consumer sweep: 114 test files, VERDICT 0, 1629 tests passed. The set is every ui test file that names `createServerActionHandler`, `consoleServerAction` or `useConsoleActionRuntime` and does not mock `consoleServerAction`, plus all of `packages/core/src/actions/__tests__/` and the new file. CI's Test UI runs the rest. - `cd ui && pnpm exec turbo run build type-check --concurrency=2 --filter=@object-ui/core --filter=@object-ui/app-shell`, VERDICT 0, 68/68 tasks. Both `type-check` scripts ran with their `tsconfig.test.json` leg, so the test files are compiled too. - `node scripts/check-sdui-manifest.mjs`: exit 0. **Ablation** (with `scripts/ablation-replace.mjs`, committed head; vitest aliases `@object-ui/core` to `src`, so no dist rebuild): 1. I restored the pre-fix line `const shouldRefresh = action.refreshAfter !== false;`. 3 tests went red: the core update pin, plus the full-page and drawer tests. The other 30 stayed green, including the CONTROL and the refused-update pin. On restore, the blob was `dc0a20f3` == HEAD and `git diff HEAD` was empty. 2. I put in the `=== true` gate the card names (`const shouldRefresh = action.refreshAfter === true;`). The full-page and drawer tests went red, and the CONTROL stayed green. The restored blob was `dc0a20f3` == HEAD. <details><summary>Derived gates: dispatch-gates --commands, 67 at 61f02c3, all exit 0; --ran: 67 derived, 67 run, 0 NOT-MEASURED</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:role-word :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `check:skill-examples` first exited 3 (PREREQUISITE NOT MET, no client dist). It went green after `pnpm --filter '@objectstack/client-react...' build`, under the lock, VERDICT 0. `pnpm --dir ui exec vitest run scripts/`: 170 files passed, 2 skipped (4979 tests). </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI runs it) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (except the 维护者速读 block the dispatch contract requires) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufziMa1x7KKmd4N1nPLV --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 30dcd8a commit c2be734

6 files changed

Lines changed: 382 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/core': patch
3+
---
4+
5+
fix(core): a successful `operation: 'update'` action now refreshes the record it wrote, with no `refreshAfter: true` (#29935)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The spec fills in `refreshAfter: false` on an action authored without it, and that parsed shape is what the Console is served. The server-action dispatch (`createServerActionHandler`) refreshed only when `refreshAfter !== false`, so a declared update never refreshed: the route wrote the record and answered 200, the toast reported success, and the record page kept the old values and the action button whose `visible` predicate had turned false. The list the record came from kept it under a filter it no longer matched.
11+
12+
A declared `operation: 'update'` writes the record it addresses by definition, so its success is now treated as a record change whatever `refreshAfter` says, the same way a client-side `dataSource.update` already reaches every reader. The record-scoped invalidation re-reads the record page (drawer and full page) and refetches every list of that object. `refreshAfter` keeps governing every other server action: a non-update action served with the default `refreshAfter: false` still sends no re-read. The spec default is unchanged.

‎content/docs/ui/actions.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,7 +677,7 @@ and [Connect an MCP Client](/docs/ai/connect-mcp).
677677
| Click → `Action 'x' … not found` | `target`-style script with no registered handler — add the `registerAction` call in `onEnable` (Path B above) |
678678
| REST → 400 naming the action's `type` | The type has no server dispatch (`url`/`modal`/`form`/`api`) — open its `target` in the client, or call that endpoint directly |
679679
| Appears in UI, missing from `list_actions` (MCP) | `ai.exposed` not `true`, `ai.description` under 40 characters, or the type isn't headless-callable |
680-
| Runs but the list looks stale | Add `refreshAfter: true` |
680+
| Runs but the list looks stale | Add `refreshAfter: true` (an `operation: 'update'` action re-reads its record and the object's lists on its own) |
681681

682682
## Related
683683

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#29935 — after a successful declared `operation: 'update'` the
11+
* record page shows what the route wrote, with no `refreshAfter: true`.
12+
*
13+
* The spec materializes `refreshAfter: false` on an action authored without
14+
* it, and that parsed shape is what the Console is served. The runner hands an
15+
* update action to the page's registered `script` dispatch, whose refresh read
16+
* `refreshAfter !== false`: the route answered 200, the toast said "Ticket
17+
* resolved.", and the page kept the old status and the Resolve button whose
18+
* `visible` predicate was now false. A declared update's success is now a
19+
* record change.
20+
*
21+
* Where it reaches: the record-scoped `notifyDataChanged` the page sends
22+
* re-reads this record, and every list reader of the object
23+
* (`useDataInvalidation(objectName)` with no record id — the hook `ListView`
24+
* and `ObjectView` refetch on) refetches with its own filter, so the record
25+
* leaves a list it no longer matches. The drawer is this same view mounted
26+
* `embedded` beside its list.
27+
*
28+
* CONTROL: a non-update `script` action served with the same
29+
* `refreshAfter: false` still sends no re-read.
30+
*
31+
* Harness: the `RecordDetailView.apiNewTabUrl-26747` mount — the real record
32+
* page, header and runner, and the real console server-action handler, with
33+
* only the authenticated fetch answered here.
34+
*/
35+
36+
import * as React from 'react';
37+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
38+
import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react';
39+
import '@testing-library/jest-dom';
40+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
41+
42+
const authFetchSpy = vi.fn();
43+
vi.mock('@object-ui/auth', async (importOriginal) => ({
44+
...(await importOriginal<Record<string, unknown>>()),
45+
useAuth: () => ({ user: null, activeOrganization: null }),
46+
createAuthenticatedFetch: () => authFetchSpy,
47+
}));
48+
49+
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
50+
...(await importOriginal<Record<string, unknown>>()),
51+
useRecordPresence: () => [],
52+
PresenceAvatars: () => null,
53+
}));
54+
55+
vi.mock('sonner', () => ({
56+
toast: Object.assign(vi.fn(), {
57+
success: vi.fn(),
58+
error: vi.fn(),
59+
info: vi.fn(),
60+
warning: vi.fn(),
61+
loading: vi.fn(),
62+
dismiss: vi.fn(),
63+
}),
64+
}));
65+
66+
vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null }));
67+
vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null }));
68+
vi.mock('./FlowRunner', () => ({ FlowRunner: () => null }));
69+
vi.mock('./MetadataInspector', () => ({
70+
MetadataPanel: () => null,
71+
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
72+
}));
73+
74+
import { toast } from 'sonner';
75+
import { I18nProvider } from '@object-ui/i18n';
76+
import { MetadataCtx, useDataInvalidation } from '@object-ui/react';
77+
import { RecordDetailView } from './RecordDetailView';
78+
import { FavoritesProvider } from '../context/FavoritesProvider';
79+
80+
const OBJECT_NAME = 'support_desk_ticket';
81+
const RECORD_ID = 'tkt_1';
82+
const OTHER_ID = 'tkt_2';
83+
const RECORD_PATH = `/apps/support_desk/${OBJECT_NAME}/record/${RECORD_ID}`;
84+
const ACTION_URL = `/api/v1/actions/${OBJECT_NAME}/resolve_ticket`;
85+
86+
const FIELDS = {
87+
id: { type: 'text', label: 'Id' },
88+
subject: { type: 'text', label: 'Subject' },
89+
status: {
90+
type: 'select',
91+
label: 'Status',
92+
options: [
93+
{ value: 'open', label: 'Open' },
94+
{ value: 'resolved', label: 'Resolved' },
95+
{ value: 'closed', label: 'Closed' },
96+
],
97+
},
98+
};
99+
100+
/**
101+
* The tutorial's action as the Console is served it: parsed, so
102+
* `type: 'script'` and `refreshAfter: false` are materialized although the
103+
* author wrote neither.
104+
*/
105+
const resolveTicket = {
106+
name: 'resolve_ticket',
107+
label: 'Resolve',
108+
objectName: OBJECT_NAME,
109+
type: 'script',
110+
operation: 'update',
111+
patch: { status: 'resolved' },
112+
locations: ['record_header'],
113+
visible: 'record.status != "resolved" && record.status != "closed"',
114+
successMessage: 'Ticket resolved.',
115+
refreshAfter: false,
116+
};
117+
118+
/** CONTROL: a server-side script action that writes nothing the page reads. */
119+
const notifyOwner = {
120+
name: 'notify_owner',
121+
label: 'Notify owner',
122+
objectName: OBJECT_NAME,
123+
type: 'script',
124+
locations: ['record_header'],
125+
successMessage: 'Owner notified.',
126+
refreshAfter: false,
127+
};
128+
129+
/** The stored rows; the action route writes here, the reads read here. */
130+
let rows: Array<Record<string, unknown>>;
131+
132+
function makeDataSource() {
133+
return {
134+
find: vi.fn(async (_object: string, query?: { $filter?: Record<string, unknown> }) => {
135+
const status = query?.$filter?.status;
136+
const data = rows.filter((r) => status === undefined || r.status === status).map((r) => ({ ...r }));
137+
return { data, total: data.length };
138+
}),
139+
findOne: vi.fn(async (_object: string, id: string) => ({ ...rows.find((r) => r.id === id)! })),
140+
create: vi.fn(async () => ({})),
141+
update: vi.fn(async () => ({})),
142+
delete: vi.fn(async () => ({})),
143+
} as any;
144+
}
145+
146+
/**
147+
* A list reader of the object — the `useDataInvalidation(objectName)` a list
148+
* view refetches on — drawing the Open list.
149+
*/
150+
function OpenTicketsProbe({ dataSource }: { dataSource: any }) {
151+
const nonce = useDataInvalidation(OBJECT_NAME);
152+
const [ids, setIds] = React.useState<string[] | null>(null);
153+
React.useEffect(() => {
154+
let live = true;
155+
void dataSource.find(OBJECT_NAME, { $filter: { status: 'open' } }).then((res: { data: Array<{ id: string }> }) => {
156+
if (live) setIds(res.data.map((r) => r.id));
157+
});
158+
return () => { live = false; };
159+
}, [dataSource, nonce]);
160+
return <ul data-testid="open-list">{(ids ?? []).map((id) => <li key={id}>{id}</li>)}</ul>;
161+
}
162+
163+
async function mount(actions: unknown[], mode: 'page' | 'drawer') {
164+
const objects = [
165+
{ name: OBJECT_NAME, label: 'Ticket', managedBy: 'platform', fields: FIELDS, actions },
166+
];
167+
const metadata = {
168+
objects,
169+
pages: [],
170+
loading: false,
171+
error: null,
172+
refresh: async () => {},
173+
invalidate: () => {},
174+
ensureType: async () => [],
175+
getItem: async () => null,
176+
getItemsByType: () => [],
177+
} as any;
178+
const dataSource = makeDataSource();
179+
const view = mode === 'page'
180+
? (
181+
<Routes>
182+
<Route
183+
path="/apps/:appName/:objectName/record/:recordId"
184+
element={<RecordDetailView dataSource={dataSource} objects={objects} onEdit={() => {}} />}
185+
/>
186+
</Routes>
187+
)
188+
: (
189+
<>
190+
<OpenTicketsProbe dataSource={dataSource} />
191+
<RecordDetailView
192+
dataSource={dataSource}
193+
objects={objects}
194+
onEdit={() => {}}
195+
objectNameOverride={OBJECT_NAME}
196+
recordIdOverride={RECORD_ID}
197+
embedded
198+
/>
199+
</>
200+
);
201+
render(
202+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
203+
<MetadataCtx.Provider value={metadata}>
204+
<FavoritesProvider>
205+
<MemoryRouter initialEntries={[RECORD_PATH]}>{view}</MemoryRouter>
206+
</FavoritesProvider>
207+
</MetadataCtx.Provider>
208+
</I18nProvider>,
209+
);
210+
await waitFor(() => expect(dataSource.findOne).toHaveBeenCalled());
211+
await waitFor(() => expect(document.querySelector('[role="toolbar"]')).toBeTruthy());
212+
return dataSource;
213+
}
214+
215+
/** Every inline CTA the header currently renders, by visible label. */
216+
function headerCtaLabels(): string[] {
217+
const bar = document.querySelector('[role="toolbar"]');
218+
if (!bar) return [];
219+
return Array.from(bar.querySelectorAll('button'))
220+
.map((b) => (b.textContent ?? '').trim())
221+
.filter(Boolean);
222+
}
223+
224+
/**
225+
* The header path bar's current stage, as each rendering of it (wide and
226+
* narrow) names it to assistive tech.
227+
*/
228+
function currentStages(): string[] {
229+
return Array.from(document.querySelectorAll('[role="listitem"][aria-current="step"]'))
230+
.map((el) => el.getAttribute('aria-label') ?? '');
231+
}
232+
233+
function actionPosts(): string[] {
234+
return authFetchSpy.mock.calls
235+
.map((c) => String(c[0]))
236+
.filter((u) => u.includes('/api/v1/actions/'));
237+
}
238+
239+
async function click(label: string) {
240+
await act(async () => {
241+
fireEvent.click(screen.getByRole('button', { name: label }));
242+
});
243+
}
244+
245+
beforeEach(() => {
246+
cleanup();
247+
localStorage.clear();
248+
rows = [
249+
{ id: RECORD_ID, subject: 'Printer jam', status: 'open' },
250+
{ id: OTHER_ID, subject: 'VPN down', status: 'open' },
251+
];
252+
authFetchSpy.mockReset();
253+
authFetchSpy.mockImplementation(async (url: string, init?: RequestInit) => {
254+
// The action route: write the declared patch AS the route does, answer
255+
// the declarative update's envelope with the written record.
256+
if (String(url).endsWith(ACTION_URL) && init?.method === 'POST') {
257+
const row = rows.find((r) => r.id === RECORD_ID)!;
258+
row.status = 'resolved';
259+
return new Response(
260+
JSON.stringify({ success: true, data: { ok: true, action: 'resolve_ticket', objectName: OBJECT_NAME, recordId: RECORD_ID, result: { ...row } } }),
261+
{ status: 200, headers: { 'content-type': 'application/json' } },
262+
);
263+
}
264+
return new Response(JSON.stringify({ success: true, data: {} }), {
265+
status: 200,
266+
headers: { 'content-type': 'application/json' },
267+
});
268+
});
269+
vi.stubGlobal(
270+
'fetch',
271+
vi.fn(async () =>
272+
new Response(JSON.stringify({ data: [] }), {
273+
status: 200,
274+
headers: { 'content-type': 'application/json' },
275+
}),
276+
),
277+
);
278+
});
279+
280+
afterEach(() => {
281+
vi.unstubAllGlobals();
282+
vi.restoreAllMocks();
283+
});
284+
285+
describe('objectstack#29935 — a declared update action reflects its write on the record page', () => {
286+
it('full page: after the 200 the page re-reads, the path bar reads Resolved and the Resolve button is gone', async () => {
287+
const dataSource = await mount([resolveTicket], 'page');
288+
expect(headerCtaLabels()).toContain('Resolve');
289+
await waitFor(() => expect(currentStages()).toContain('Open, current stage'));
290+
const readsBefore = dataSource.findOne.mock.calls.length;
291+
292+
await click('Resolve');
293+
294+
expect(actionPosts()).toEqual([expect.stringContaining(ACTION_URL)]);
295+
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Ticket resolved.', expect.anything()));
296+
await waitFor(() => expect(dataSource.findOne.mock.calls.length).toBeGreaterThan(readsBefore));
297+
await waitFor(() => expect(headerCtaLabels()).not.toContain('Resolve'));
298+
await waitFor(() => expect(currentStages()).toContain('Resolved, current stage'));
299+
expect(currentStages()).not.toContain('Open, current stage');
300+
});
301+
302+
it('drawer: the same click re-reads the record and the Open list it came from drops it', async () => {
303+
const dataSource = await mount([resolveTicket], 'drawer');
304+
const openList = () => Array.from(screen.getByTestId('open-list').querySelectorAll('li')).map((li) => li.textContent);
305+
await waitFor(() => expect(openList()).toEqual([RECORD_ID, OTHER_ID]));
306+
await waitFor(() => expect(currentStages()).toContain('Open, current stage'));
307+
const readsBefore = dataSource.findOne.mock.calls.length;
308+
309+
await click('Resolve');
310+
311+
await waitFor(() => expect(dataSource.findOne.mock.calls.length).toBeGreaterThan(readsBefore));
312+
await waitFor(() => expect(headerCtaLabels()).not.toContain('Resolve'));
313+
await waitFor(() => expect(currentStages()).toContain('Resolved, current stage'));
314+
await waitFor(() => expect(openList()).toEqual([OTHER_ID]));
315+
});
316+
317+
it('CONTROL: a non-update script action served with `refreshAfter: false` sends no re-read', async () => {
318+
const dataSource = await mount([notifyOwner], 'page');
319+
const readsBefore = dataSource.findOne.mock.calls.length;
320+
321+
await click('Notify owner');
322+
323+
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Owner notified.', expect.anything()));
324+
expect(actionPosts()).toEqual([expect.stringContaining(`/api/v1/actions/${OBJECT_NAME}/notify_owner`)]);
325+
// Let any refetch the click could have scheduled land before counting.
326+
await act(async () => { await new Promise((r) => setTimeout(r, 50)); });
327+
expect(dataSource.findOne.mock.calls.length).toBe(readsBefore);
328+
});
329+
});

‎ui/packages/core/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ const script = createServerActionHandler({
9696
fetch: myAuthenticatedFetch, // your auth wrapper (Bearer/cookies/...)
9797
baseUrl: 'https://api.example.com', // '' or omitted = same-origin
9898
resolveObject: () => currentObject, // fallback when the action has no objectName
99-
onRefresh: () => refetchData(), // called per the action's refreshAfter
99+
onRefresh: () => refetchData(), // called per refreshAfter, and after every operation: 'update'
100100
})
101101

102102
// Registered handlers beat the built-in executors:

‎ui/packages/core/src/actions/__tests__/serverActionHandler.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,37 @@ describe('createServerActionHandler — refresh semantics', () => {
340340
expect(onRefresh).toHaveBeenCalledTimes(1);
341341
expect(res).toMatchObject({ success: true, reload: false });
342342
});
343+
344+
// objectstack#29935 — the spec materializes `refreshAfter: false` on an
345+
// action authored without it, so the parsed shape is what reaches here.
346+
it('a successful `operation: \'update\'` refreshes even with the parsed `refreshAfter: false`', async () => {
347+
const onRefresh = vi.fn();
348+
const handler = createServerActionHandler({ fetch: okFetch(), onRefresh });
349+
350+
const res = await handler({
351+
type: 'script', name: 'resolve_ticket', operation: 'update', refreshAfter: false,
352+
params: { recordId: 't1' },
353+
} as ActionDef);
354+
expect(res).toMatchObject({ success: true, reload: true });
355+
expect(onRefresh).toHaveBeenCalledTimes(1);
356+
});
357+
358+
it('a refused `operation: \'update\'` sends no refresh', async () => {
359+
const onRefresh = vi.fn();
360+
const fetch = vi.fn(async () => ({
361+
ok: false,
362+
status: 403,
363+
json: async () => ({ success: false, error: { code: 'PERMISSION_DENIED', message: 'Denied' } }),
364+
})) as unknown as ServerActionFetch;
365+
const handler = createServerActionHandler({ fetch, onRefresh });
366+
367+
const res = await handler({
368+
type: 'script', name: 'resolve_ticket', operation: 'update', refreshAfter: false,
369+
params: { recordId: 't1' },
370+
} as ActionDef);
371+
expect(res.success).toBe(false);
372+
expect(onRefresh).not.toHaveBeenCalled();
373+
});
343374
});
344375

345376
describe('createServerActionHandler — re-entrancy guard', () => {

0 commit comments

Comments
 (0)