Skip to content

Commit 2b35374

Browse files
fix(app-shell): a team made in Setup no longer reads as managed by an outside identity provider, and a header action refreshes the team's member count (#30417)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgHA15WwWBix9eGkVwzCLb4i)_ domain:ui#2 · os-bill · session_01Utp6Cb5R6JDdfyvtj3dPR2 Part of #30337 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:① 身份类对象(团队、用户、成员等 `better-auth` 表)的说明从「由身份提供方管理」改为「经内置身份服务写入」,正文说明用本页操作或登录/邀请/安全流程增改记录,10 个语言包同改,只改值不增删键;② 记录页上声明 `refreshAfter: true` 的操作成功后,除了重读本记录,还刷新本页列出的每个关联对象,关联列表重取、标签页与卡片计数重算。 - 为什么改:在 Setup 手工建的团队被标成「由身份提供方管理」,读起来像外部同步来的、不能改;用表头「添加成员」加人后,「团队成员」标签页仍显示 0(列表却有 2 条),要等列表里删一个人才追上。 - 风险与回滚:只动 `RecordDetailView.tsx` 的刷新范围(新增 `notifyViewChanged`,五处 `refreshAfter` 入口改用它)和文案;不动 spec、服务端、元数据。代价是带 `refreshAfter` 的操作后多发几次子列表读取,父记录仍只读一次(测试钉住)。回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/views/RecordDetailView.tsx` 的 `notifyViewChanged` 与 `pageChildObjectsRef` → `RecordDetailView.childWriteRefreshesParent-12244.test.tsx` 末尾的 #30337 describe → `ui/packages/i18n/src/locales/zh.ts` 的 `managedByBadge.betterAuth`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/components/__tests__/ManagedByBadge.keyboard-27521.test.tsx packages/app-shell/src/views/RecordDetailView.childWriteRefreshesParent-12244.test.tsx`,预期 10/10 通过(在 main 源码上新增的 3 条失败)。 - 需要你做的:无,席位落地。卡的第 2 项(成员行「标题」列显示原始 id)是 server 半边,见 Acceptance notes,席位另立单。 ## What changes The card's three Expected bullets, and what this PR does for each: > The label says something true of a team made here, or does not appear. Done. `sys_team` declares `managedBy: 'better-auth'`, and that bucket is right: the identity service owns the table and every write to it goes through that service's endpoints (the team's own actions). The words were wrong. The `managedByBadge.betterAuth` note now reads "Written through the built-in identity service" / 「经内置身份服务写入」, and its body says these records are created and changed with the actions on the page or through sign-in, invitation and security flows, all of which go through that service. That holds for every `better-auth` object, a hand-made team included, and it names no outside provider. Values change in all ten packs and in `ManagedByBadge`'s English fallback; no key is added or removed. What the bucket means for other objects is unchanged: the badge still draws for the same objects, under the same authoring gate, with the same CRUD affordances hidden. > Member rows read as names. If the title column adds nothing beside 用户, it goes. Not in this PR: only the server can fix it without the renderer second-guessing declared metadata. See Acceptance notes. > The tab count matches the list as soon as members change. Done. The tab badge reads `RelatedCountStore`, keyed on the CHILD object (`sys_team_member`) and invalidated by the data-change bus under that name. The team's header action `add_team_member` (`type: 'api'`, `refreshAfter: true`) succeeded and the page posted only `sys_team` and this record. So the count cached when the team had no members stayed 0, and the list read fresh when its tab opened. A row action on the membership list posts `sys_team_member`, which is why the count caught up after a removal. - `refreshAfter` is the spec's "Refresh view after execution". On a record page the view is the record and its related lists, so the page now refreshes both: `notifyViewChanged` posts this record's change and then one change per child object the page lists (`childRelations` plus authored `record:related_list` / `record:reference_rail` children, the same set the child-write subscription reads). The lists refetch, the counts re-count, and the parent row is still read once, because the bumps land in one batch. - It is used at every `refreshAfter` entry on the page: the `api` action door, the direct `api` fetch, the declared-operation script path, the flow launch, and the server action handler's `onRefresh`. An action's target is opaque to the client, so the page cannot know which objects it wrote. The undo path and form saves keep the record-only refresh. ## Tests - `ManagedByBadge.keyboard-27521.test.tsx`, a new top-level describe (#30337): the `better-auth` tooltip in English (fallback) and in Chinese (zh pack, through `I18nProvider`) names the built-in identity service and the page's actions, and does not contain "identity provider" / 「身份提供方」. - `RecordDetailView.childWriteRefreshesParent-12244.test.tsx`, a new top-level describe (#30337) on the same harness: a header `type: 'api'` action with `refreshAfter: true` whose door adds a child row server-side. After the click the list shows the new row, `RelatedCountStore` reads 3 (it was 2), and the parent is read exactly once more. The harness's authenticated fetch is now a hoisted spy that answers nothing unless a test gives it an implementation, as the bare `vi.fn()` did. - `RecordDetailView.setupManagedByChip-13385.test.tsx`: the two header-note expectations follow the new title. - Red first, on `4779e798` (tests only): `Tests 3 failed | 7 passed (10)`. The two badge tests got the old note. The header-action test found the door call but `Unable to find an element with the text: Support plan`, because the list never refetched. Green after the fix: `Test Files 3 passed (3)`, `Tests 15 passed (15)`. The red leg on the parent commit is the ablation: the fix is the only source change, and app-shell tests import `src`. - Every ui test file that names `RecordDetailView`, `ManagedByBadge` or `managedByBadge`, plus all of `packages/i18n/`: `Test Files 382 passed (382)`, `Tests 3754 passed | 13 skipped (3767)` (under the verify lock, 17m12s). - `pnpm exec turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/i18n --concurrency=2` (under the verify lock): `Tasks: 69 successful, 69 total`. - `eslint` on the changed ui files: 0 errors. `RecordDetailView.tsx` has 124 warnings against 123 on main: one more `react-hooks/refs` heuristic warning on the `withNewTabUrlFastPath(apiHandler, …)` memo, because `apiHandler` now reaches a ref through `notifyViewChanged` inside a callback. The other files carry no new warnings. ## Acceptance notes - **Item 2 is the server half** (triage on the card says the same). The 标题 column is `sys_team_member.display_title`, the object's declared `nameField`, a formula `record.user_id + ' in ' + record.team_id`. A formula is evaluated on the stored row before `$expand`, so it can only ever draw the two ids. A related list with no authored columns leads with the declared name (`leadWithNameField`, ADR-0079), which exempts a declared `nameField` by design, and then takes `highlightFields` (`user_id`, then `team_id`, dropped as the key back to the team, then `created_at`). The renderer cannot tell that this title adds nothing without parsing the formula, which is second-guessing declared metadata (`ui/AGENTS.md` #0.1). The producer-side fix sits in `packages/platform-objects/src/identity/sys-team-member.object.ts`, outside this card's file surface: `relatedListColumns: ['user_id', 'created_at']` on the `team_id` lookup (and `['team_id', 'created_at']` on `user_id` for the user page's teams list). Authored relationship columns are drawn as written, with no name lead, so the member list would read 用户 / 创建时间. Reported to the seat to file. This PR says `Part of`, so merging it leaves the card open for that half. - `refreshAfter` refreshes more than it did on a record page. That is the spec's wording ("the view"), and the change stays inside the page's own lists. It is not a `'*'`: other mounted views are not refetched. ## Verification <details><summary>Derived gates (dispatch-gates --commands, final tree `53cf42ca`, after merging origin/main)</summary> - `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-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-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/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/spec run check:duration-unit-keys` :: exit 0 - `pnpm check:changeset-gate-self-tests` :: exit 0 - `pnpm check:closed-ceremony-text` :: exit 0 - `pnpm check:cross-package-test-inputs` :: exit 0 - `pnpm check:dispatcher-error-vocabulary` :: 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:query-options-erasure` :: 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: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:watch-hint-literal` :: exit 0 - `pnpm check:where-matcher` :: exit 0 `dispatch-gates --ran`: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. `pnpm check:type-check-debt` ran under the verify lock. </details> <details><summary>Final-head re-run</summary> - On `53cf42ca`: the three changed test files plus `RecordDetailView.updateActionRefresh-29935.test.tsx` (the declared-operation refresh path): `Test Files 4 passed (4)`, `Tests 18 passed (18)`. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZGJdHbf9pjnedaUeD)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ac56acf commit 2b35374

16 files changed

Lines changed: 188 additions & 38 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(app-shell): a team made in Setup no longer reads as managed by an outside identity provider, and a header action's write shows on the page's related lists and their counts at once
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- The `better-auth` note (the Identity tag's tooltip, and the Setup record header's object-label note) read "Managed by the identity provider" / 「由身份提供方管理」, which told the maker of a hand-made team that the row came from elsewhere and was not theirs to change. It now reads "Written through the built-in identity service" / 「经内置身份服务写入」, and its body says these records are created and changed with the actions on the page or through sign-in, invitation and security flows, all of which go through that service. The values change in all ten language packs; no key is added or removed.
12+
- A record-page action that declares `refreshAfter: true` (an `api` action such as a team's Add Member, a server action, a flow, a declared operation) now refreshes the record AND every related list the page shows: the lists refetch and their tab and card counts re-count. It used to refresh the record alone, so a team's members tab read 0 beside a list of 2 until a later row action on that list.
13+
- Nothing to do on upgrade.

‎ui/packages/app-shell/src/components/ManagedByBadge.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,13 +155,17 @@ const VARIANTS: Record<VariantKey, Variant> = {
155155
"Immutable audit log. Rows cannot be created, edited, or deleted from the UI — they're written by the platform when events occur. Use Export to download for compliance review.",
156156
tone: 'border-zinc-300/60 bg-zinc-50 text-zinc-900 hover:bg-zinc-100 dark:border-zinc-500/40 dark:bg-zinc-950/40 dark:text-zinc-100',
157157
},
158+
// objectstack#30337 — the note holds for every row of the bucket, a team an
159+
// admin made in Setup included: the built-in identity service writes the
160+
// rows, and the page's own actions are how to change them. It names no
161+
// outside provider: nothing here says the row came from elsewhere.
158162
'better-auth': {
159163
icon: ShieldAlert,
160164
i18nKey: 'betterAuth',
161165
short: 'Identity',
162-
title: 'Managed by the identity provider',
166+
title: 'Written through the built-in identity service',
163167
body: (display) =>
164-
`This object's schema is owned by ${display}. Direct edits bypass password hashing, session validation, two-factor checks, and audit hooks. Manage these records through your authentication provider's sign-in, invitation, and security flows instead.`,
168+
`This object's schema belongs to ${display}, the platform's built-in identity service. Create and change these records with the actions on this page, or through sign-in, invitation and security flows; every one of them goes through that service. Editing the table directly would bypass password hashing, session validation, two-factor checks and audit hooks.`,
165169
tone: 'border-amber-300/60 bg-amber-50 text-amber-900 hover:bg-amber-100 dark:border-amber-500/40 dark:bg-amber-950/40 dark:text-amber-100',
166170
},
167171
};

‎ui/packages/app-shell/src/components/__tests__/ManagedByBadge.keyboard-27521.test.tsx‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import '@testing-library/jest-dom/vitest';
88
import { describe, expect, it } from 'vitest';
99
import { render, screen } from '@testing-library/react';
1010
import userEvent from '@testing-library/user-event';
11+
import { I18nProvider } from '@object-ui/i18n';
1112
import { ManagedByBadge } from '../ManagedByBadge';
1213

1314
describe('ManagedByBadge — the explanation is reachable from the keyboard (objectstack#27521)', () => {
@@ -26,3 +27,40 @@ describe('ManagedByBadge — the explanation is reachable from the keyboard (obj
2627
expect(screen.queryByTestId('managed-by-badge')).toBeNull();
2728
});
2829
});
30+
31+
/**
32+
* objectstack#30337 item 1 — a team an admin made in Setup carries the
33+
* `better-auth` tag, and its note read 「由身份提供方管理」 / "Managed by the
34+
* identity provider": to its maker that says the row came from somewhere else
35+
* and is not theirs to change. The bucket is right (the identity service owns
36+
* the table and every write goes through it); the words were not. The note
37+
* now says what holds for every such object, a hand-made team included: the
38+
* built-in identity service writes the rows, and this page's actions are how
39+
* to change them.
40+
*/
41+
describe('ManagedByBadge — the identity note is true of a team made here (objectstack#30337)', () => {
42+
const openNote = async () => {
43+
await userEvent.tab();
44+
return (await screen.findByRole('tooltip')).textContent ?? '';
45+
};
46+
47+
it('English: names the built-in identity service and the page\'s actions, not an outside provider', async () => {
48+
render(<ManagedByBadge managedBy="better-auth" />);
49+
const note = await openNote();
50+
expect(note).toContain('Written through the built-in identity service');
51+
expect(note).toContain('the actions on this page');
52+
expect(note).not.toContain('identity provider');
53+
});
54+
55+
it('Chinese: the same note, from the zh pack', async () => {
56+
render(
57+
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }}>
58+
<ManagedByBadge managedBy="better-auth" />
59+
</I18nProvider>,
60+
);
61+
const note = await openNote();
62+
expect(note).toContain('经内置身份服务写入');
63+
expect(note).toContain('本页上的操作');
64+
expect(note).not.toContain('身份提供方');
65+
});
66+
});

‎ui/packages/app-shell/src/views/RecordDetailView.childWriteRefreshesParent-12244.test.tsx‎

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,13 @@ import { MetadataCtx, notifyDataChanged, useMutationInvalidationBridge } from '@
5454
import { RelatedCountStore } from '@object-ui/components';
5555
import type { DataSourceMutationEvent } from '@object-ui/types';
5656

57+
// The page's authenticated fetch. Answers nothing (a bare `vi.fn()`) unless a
58+
// test gives it an implementation: only the header-action describe does.
59+
const authFetchSpy = vi.hoisted(() => vi.fn());
5760
vi.mock('@object-ui/auth', async (importOriginal) => ({
5861
...(await importOriginal<Record<string, unknown>>()),
5962
useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }),
60-
createAuthenticatedFetch: () => vi.fn(),
63+
createAuthenticatedFetch: () => authFetchSpy,
6164
}));
6265

6366
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
@@ -192,7 +195,7 @@ function makeBackend() {
192195
return () => listeners.delete(cb);
193196
},
194197
};
195-
return { ds, rollup };
198+
return { ds, rollup, lines };
196199
}
197200

198201
type FakeDataSource = ReturnType<typeof makeBackend>['ds'];
@@ -203,9 +206,9 @@ function ConsoleHost({ dataSource, children }: { dataSource: unknown; children:
203206
return <>{children}</>;
204207
}
205208

206-
function renderPage(ds: FakeDataSource) {
209+
function renderPage(ds: FakeDataSource, objects: readonly unknown[] = OBJECTS) {
207210
const metadata = {
208-
objects: OBJECTS,
211+
objects,
209212
pages: [],
210213
loading: false,
211214
error: null,
@@ -222,7 +225,7 @@ function renderPage(ds: FakeDataSource) {
222225
<Routes>
223226
<Route
224227
path="/apps/:appName/:objectName/record/:recordId"
225-
element={<RecordDetailView dataSource={ds} objects={OBJECTS} onEdit={() => {}} />}
228+
element={<RecordDetailView dataSource={ds} objects={objects as typeof OBJECTS} onEdit={() => {}} />}
226229
/>
227230
</Routes>
228231
</ConsoleHost>
@@ -243,9 +246,9 @@ const figureShown = (figure: number) => {
243246
return text.includes(plain) || text.includes(grouped);
244247
};
245248

246-
async function settledPage() {
249+
async function settledPage(objects: readonly unknown[] = OBJECTS) {
247250
const backend = makeBackend();
248-
renderPage(backend.ds);
251+
renderPage(backend.ds, objects);
249252
await waitFor(() => expect(parentReads(backend.ds)).toBe(1));
250253
await screen.findByText('Seat licence');
251254
await waitFor(() => expect(figureShown(backend.rollup())).toBe(true));
@@ -354,3 +357,77 @@ describe('RecordDetailView — a child write in a related list refreshes the par
354357
expect(parentReads(ds)).toBe(3);
355358
});
356359
});
360+
361+
/**
362+
* objectstack#30337 item 3 — the reverse direction: a HEADER action on the
363+
* parent writes its children, and the page's related lists and their counts
364+
* follow at once.
365+
*
366+
* The reported reach: a team's 「添加成员」 header action (`type: 'api'`,
367+
* `refreshAfter: true`) adds a membership row server-side. The page then said
368+
* 团队成员 0 on its tab while the list, read when the tab opened, held 2 rows;
369+
* only a later row action on the membership list (which posts the CHILD's
370+
* name) brought the count to the truth.
371+
*
372+
* Mechanism: `refreshAfter` is "refresh the view after execution" (the spec's
373+
* own description), and on a record page the view is the record AND its
374+
* related lists. The page used to post only this record's change, so the
375+
* child's lists and the count store (both keyed on the CHILD object) never
376+
* heard of the write. An `api` action is opaque — the client cannot know which
377+
* objects its target wrote — so the page refreshes what it shows: this record
378+
* and every child object it lists.
379+
*/
380+
describe('RecordDetailView — a header action with `refreshAfter` refreshes the related lists and their counts (objectstack#30337)', () => {
381+
const ACTION_DOOR = `/api/v1/actions/${PARENT}/add_line`;
382+
const parentWithAction = {
383+
...parentObject,
384+
actions: [
385+
{
386+
name: 'add_line',
387+
label: 'Add line',
388+
objectName: PARENT,
389+
type: 'api',
390+
target: '/api/v1/quotes/add-line',
391+
recordIdParam: 'quoteId',
392+
locations: ['record_header'],
393+
refreshAfter: true,
394+
},
395+
],
396+
};
397+
const objectsWithAction = [parentWithAction, childObject, unrelatedObject];
398+
399+
afterEach(() => {
400+
authFetchSpy.mockReset();
401+
});
402+
403+
it('the click adds a child server-side; the list shows it and the count reads 3, with one parent read', async () => {
404+
const backend = await settledPage(objectsWithAction);
405+
const { ds, lines } = backend;
406+
await waitFor(() => expect(RelatedCountStore.get(CHILD, PARENT, RECORD_ID)).toBe(2));
407+
authFetchSpy.mockImplementation(async (url: string, init?: RequestInit) => {
408+
if (String(url).endsWith(ACTION_DOOR) && init?.method === 'POST') {
409+
lines.push({ id: 'l-3', name: 'Support plan', quantity: 1, unit_price: 500, [PARENT]: RECORD_ID });
410+
return new Response(JSON.stringify({ success: true, data: {} }), {
411+
status: 200,
412+
headers: { 'content-type': 'application/json' },
413+
});
414+
}
415+
return undefined;
416+
});
417+
418+
await act(async () => {
419+
await userEvent.click(screen.getByRole('button', { name: 'Add line' }));
420+
});
421+
422+
await waitFor(() => expect(authFetchSpy.mock.calls.map((c) => String(c[0]))).toContain(ACTION_DOOR));
423+
await screen.findByText('Support plan');
424+
await waitFor(() => expect(RelatedCountStore.get(CHILD, PARENT, RECORD_ID)).toBe(3));
425+
// The record and its children are refreshed by one dispatch batch: the
426+
// parent row is read once more, not once per object.
427+
await waitFor(() => expect(parentReads(ds)).toBe(2));
428+
await act(async () => {
429+
await new Promise((r) => setTimeout(r, 50));
430+
});
431+
expect(parentReads(ds)).toBe(2);
432+
});
433+
});

‎ui/packages/app-shell/src/views/RecordDetailView.setupManagedByChip-13385.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,14 +192,14 @@ describe('RecordDetailView — Setup record pages carry the managed-by note on t
192192
await mountRecord('com.objectstack.setup', 'better-auth', AUTHOR);
193193

194194
expect(badges()).toEqual([]);
195-
expect(headerNote()).toEqual({ label: 'Organization', note: 'Managed by the identity provider' });
195+
expect(headerNote()).toEqual({ label: 'Organization', note: 'Written through the built-in identity service' });
196196
});
197197

198198
it('Setup addressed by its app name (`setup`) reads the same', async () => {
199199
await mountRecord('setup', 'better-auth', AUTHOR);
200200

201201
expect(badges()).toEqual([]);
202-
expect(headerNote()).toEqual({ label: 'Organization', note: 'Managed by the identity provider' });
202+
expect(headerNote()).toEqual({ label: 'Organization', note: 'Written through the built-in identity service' });
203203
});
204204

205205
it('CONTROL — a business-app record page still draws the floating badge for an author, and no chip note', async () => {

‎ui/packages/app-shell/src/views/RecordDetailView.tsx‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -982,6 +982,22 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
982982
const notifyRecordChanged = useCallback(() => {
983983
if (objectName) notifyDataChanged({ objectName, recordId: pureRecordId || undefined });
984984
}, [objectName, pureRecordId]);
985+
// objectstack#30337 — what `refreshAfter: true` refreshes: the spec's "refresh
986+
// the view after execution", and on this page the view is the record AND its
987+
// related lists. An action's target may write the CHILDREN (a team's Add
988+
// Member writes a membership row), and the client cannot know which objects
989+
// it wrote, so the page refreshes every child object it lists as well: their
990+
// lists refetch and their counts (keyed on the child) re-count. Read through
991+
// a ref because the child set is derived further down this component; one
992+
// synchronous dispatch batch, so the record is still read once (the
993+
// child-write subscription below and this record's own nonce bump together).
994+
const pageChildObjectsRef = useRef<string[]>([]);
995+
const notifyViewChanged = useCallback(() => {
996+
notifyRecordChanged();
997+
for (const child of pageChildObjectsRef.current) {
998+
if (child !== objectName) notifyDataChanged({ objectName: child });
999+
}
1000+
}, [notifyRecordChanged, objectName]);
9851001
// Manual refresh (objectui#3460) — the producer for `RecordContext.refresh`,
9861002
// which `page:header` turns into the ⟳ button at the end of the header row.
9871003
//
@@ -1686,7 +1702,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
16861702
if (!door.res.ok) return apiActionRefusalResult(door.res, door.json, `HTTP ${door.res.status}`, language);
16871703
const answer = readApiActionDoorSuccess(door, `Action "${action.name}"`);
16881704
if (!answer.ok) return { success: false, error: answer.error };
1689-
if (action.refreshAfter === true) notifyRecordChanged();
1705+
if (action.refreshAfter === true) notifyViewChanged();
16901706
return { success: true, data: answer.data, reload: action.refreshAfter === true };
16911707
}
16921708

@@ -1793,7 +1809,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
17931809
return apiActionRefusalResult(res, errBody, `HTTP ${res.status}`, language);
17941810
}
17951811
const data = await res.json().catch(() => ({}));
1796-
if (action.refreshAfter === true) notifyRecordChanged();
1812+
if (action.refreshAfter === true) notifyViewChanged();
17971813
return { success: true, data, reload: action.refreshAfter === true };
17981814
}
17991815

@@ -1889,7 +1905,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
18891905

18901906
const shouldRefresh = action.refreshAfter === true;
18911907
if (shouldRefresh) {
1892-
notifyRecordChanged();
1908+
notifyViewChanged();
18931909
} else if (undo) {
18941910
// Even when refreshAfter isn't set, reflect the change so the user sees
18951911
// it (and the subsequent Undo) on the open record.
@@ -1988,7 +2004,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
19882004
if (judged.followUp?.kind === 'refusal') {
19892005
setFlowRefusal({ open: true, title: action.label, message: judged.followUp.message });
19902006
}
1991-
if (judged.refresh) notifyRecordChanged();
2007+
if (judged.refresh) notifyViewChanged();
19922008
return judged.result;
19932009
} catch (error) {
19942010
return { success: false, error: (error as Error).message };
@@ -2009,8 +2025,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
20092025
// `action.recordId`; header/more actions carry none and use this page's id.
20102026
// The env ref keeps the handler instance stable across renders (authFetch is
20112027
// memoized once) while the thunks read the live record/object.
2012-
const serverActionEnvRef = useRef({ objectName, pureRecordId, notifyRecordChanged, t, navigate });
2013-
serverActionEnvRef.current = { objectName, pureRecordId, notifyRecordChanged, t, navigate };
2028+
const serverActionEnvRef = useRef({ objectName, pureRecordId, notifyViewChanged, t, navigate });
2029+
serverActionEnvRef.current = { objectName, pureRecordId, notifyViewChanged, t, navigate };
20142030
const serverActionHandler = useMemo(
20152031
() => createConsoleServerActionHandler({
20162032
fetch: authFetch,
@@ -2019,7 +2035,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
20192035
resolveRecordId: (action) => ({
20202036
recordId: (action as { recordId?: unknown }).recordId ?? serverActionEnvRef.current.pureRecordId ?? undefined,
20212037
}),
2022-
onRefresh: () => serverActionEnvRef.current.notifyRecordChanged(),
2038+
onRefresh: () => serverActionEnvRef.current.notifyViewChanged(),
20232039
// SPA route hop for a handler-returned `openIn: 'self'` — react-router's
20242040
// own `navigate`, matching the shared console runtime.
20252041
navigate: (url: string) => serverActionEnvRef.current.navigate(url),
@@ -2298,6 +2314,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
22982314
);
22992315
useEffect(() => {
23002316
const childObjects = JSON.parse(relatedChildObjectsKey) as string[];
2317+
// What `notifyViewChanged` refreshes besides this record (objectstack#30337).
2318+
pageChildObjectsRef.current = childObjects;
23012319
if (childObjects.length === 0) return;
23022320
return subscribeDataChanges((change) => {
23032321
if (childObjects.some((child) => dataChangeMatches(change, child))) {

‎ui/packages/i18n/src/locales/ar.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,8 +1017,8 @@ const ar = {
10171017
},
10181018
betterAuth: {
10191019
short: "الهوية",
1020-
title: "يديره مزوّد الهوية",
1021-
body: "مخطط هذا الكائن مملوك لـ {{provider}}. التعديلات المباشرة تتجاوز تجزئة كلمات المرور والتحقق من الجلسات وفحوصات المصادقة الثنائية وخطافات التدقيق. أدر هذه السجلات عبر تدفقات تسجيل الدخول والدعوة والأمان لدى مزوّد المصادقة.",
1020+
title: "يُكتب عبر خدمة الهوية المدمجة",
1021+
body: "مخطط هذا الكائن مملوك لـ {{provider}}، خدمة الهوية المدمجة في المنصة. أنشئ هذه السجلات وعدّلها بالإجراءات في هذه الصفحة أو عبر تدفقات تسجيل الدخول والدعوة والأمان؛ فكلها تمر عبر هذه الخدمة. أما تعديل الجدول مباشرة فيتجاوز تجزئة كلمات المرور والتحقق من الجلسات وفحوصات المصادقة الثنائية وخطافات التدقيق.",
10221022
},
10231023
},
10241024
kanban: {

‎ui/packages/i18n/src/locales/de.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -908,8 +908,8 @@ const de = {
908908
},
909909
betterAuth: {
910910
short: "Identität",
911-
title: "Vom Identitätsanbieter verwaltet",
912-
body: "Das Schema dieses Objekts gehört {{provider}}. Direkte Änderungen umgehen Passwort-Hashing, Sitzungsvalidierung, Zwei-Faktor-Prüfungen und Audit-Hooks. Verwalten Sie diese Datensätze über die Anmelde-, Einladungs- und Sicherheitsabläufe Ihres Authentifizierungsanbieters.",
911+
title: "Über den integrierten Identitätsdienst geschrieben",
912+
body: "Das Schema dieses Objekts gehört {{provider}}, dem integrierten Identitätsdienst der Plattform. Legen Sie diese Datensätze mit den Aktionen auf dieser Seite oder über Anmelde-, Einladungs- und Sicherheitsabläufe an und ändern Sie sie dort; alle laufen über diesen Dienst. Direkte Änderungen an der Tabelle würden Passwort-Hashing, Sitzungsvalidierung, Zwei-Faktor-Prüfungen und Audit-Hooks umgehen.",
913913
},
914914
},
915915
kanban: {

0 commit comments

Comments
 (0)