Skip to content

Commit eff90af

Browse files
fix(console): the shared-record page shows a user field as "Linked user", never its sys_user id (#31365)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XS5vctb61zkww6CKydWkzsR)_ Fixes #31350 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:分享链接页 `/s/TOKEN` 上,有值的「用户」类型字段不再显示存储的 `sys_user` ID,改为显示本地化的中性占位「已关联用户」/ "Linked user";空值仍显示为空。判断依据是 resolve 应答 `fields` 投影里已下发的 `type: 'user'`,与查找字段(#31326)走同一个引用类型集合,不另开代码路径。 - 为什么改:匿名访客看到一串内部用户 ID,既无用又像页面坏了;与 #31326 同一类展示缺陷。显示用户姓名属于 #27200 选项 B 的安全边界问题,本 PR 不碰。 - 风险与回滚:只改 console 一页的展示层,外加 `@object-ui/i18n` 每个语言包一个新键 `sharedRecord.linkedUser`;不改 resolve 接口、不改服务端、不多发请求、不读 `sys_user`。回滚即 revert。 - 先看:`ui/apps/console/src/pages/SharedRecordFields.tsx`(`RECORD_REFERENCE_TYPES` 加入 `user`,`referencePlaceholder` 按类型选占位文案)、`SharedRecordPage-27121.test.tsx` 末尾新增的 `objectstack#31350` describe。 - 两分钟验证:`pnpm --dir ui exec vitest run apps/console/src/pages/SharedRecordPage-27121.test.tsx`,预期 18 个用例全绿,其中用户字段三例断言整个 DOM 里找不到 `sys_user` ID。 - 需要你做的:无,席位落地。 ## What changes - **`SharedRecordFields.tsx`** (`@object-ui/console`): `user` joins `lookup` and `master_detail` in `RECORD_REFERENCE_TYPES`, the one reference-type set #31326 added, so a user row takes the same branch: a filled one (an id, or a non-empty array of ids for a multiple user field) renders a neutral placeholder in the muted foreground, and an empty one (null, undefined, empty string, empty array) renders the page's existing empty face. A new `referencePlaceholder` picks the wording by the projected type: `t('sharedRecord.linkedUser')` for `user`, `t('sharedRecord.linkedRecord')` otherwise. The decision reads the `type` the resolve answer's `fields` projection already serves, never the value's shape, and nothing reads the user record. The module header and the set's doc comment now name user fields. - **`@object-ui/i18n`**: every built-in pack gains `sharedRecord.linkedUser` (en "Linked user", zh-Hans 「已关联用户」, the other nine translated). `zh-Hant.ts` was produced by `pnpm --filter @object-ui/i18n gen:zh-hant` (「已關聯使用者」). - **Changeset** `.changeset/31350-shared-user-placeholder.md`: `@object-ui/console` and `@object-ui/i18n` patch, with both declaration lines. - Not changed: `GET /api/v1/share-links/TOKEN/resolve`, `packages/types/src/share-link-resolve-answer.ts`, and any server code. No read of `sys_user`. ## Tests - `SharedRecordPage-27121.test.tsx`, the page's existing harness, rendered through the real route with the resolve answer stubbed the way both producers send it. New top-level describe for objectstack#31350 (5 cases), fed a `ticket` answer: - a filled user field reads "Linked user", its id is absent from `document.body.innerHTML`, and the only request is the resolve one; - a multiple user field reads "Linked user" and none of its ids is in the DOM; - an empty user field (null) and an empty multiple one (`[]`) render the empty face; - under a zh `I18nProvider` the placeholder is the zh-Hans pack's value (guarded as a real zh row); - CONTROL: text fields render their values as before, and a lookup in the same record still reads "Linked record". - Ablation on head `7e62d74b` through `scripts/ablation-replace.mjs`: `user` dropped from `RECORD_REFERENCE_TYPES` (anchor 1 to 0, blob `83ad4488` to `f3f23dcc`). Result: 3 of 18 red (filled, multiple, zh); the empty-field case and the control stay green, as expected, since an empty user value and a text value render the same either way. Restored: blob equal to HEAD (`83ad4488`) and `git diff HEAD` empty. The console tests import `SharedRecordFields.tsx` from source by relative path, so no build sat between the mutation and the run. ## Acceptance notes - **Measured before the first edit: the projection serves `type: 'user'`.** On `origin/main` (`dc7f5c4b`), a throwaway probe (not committed, deleted) registered an object on a real `ObjectQL` over `@objectstack/driver-sql` + better-sqlite3 and ran `projectSharedRecordFields(engine.getSchema(...), record, [])` from `@objectstack/types` dist. A declared user field projected `{"label":"Sales Rep","type":"user","reference":"sys_user"}`; a multiple one added `"multiple":true`. The registry-injected `owner_id`, `created_by` and `updated_by` project as `type: 'lookup'` and were already covered by #31326. - **Why a new key rather than reusing `sharedRecord.linkedRecord`.** "Linked record" under a person's label (for example "Assigned To") names a person as a record, in every pack; one key was added, as the order allows. The ui seat agreed before the edit. - **Declaration lines.** The claim opened at `Clause-②: no`; adding an `@object-ui/i18n` key is published surface, so this PR and its changeset declare `yes (widening)`. `Metadata-protocol: none`: nothing a metadata author writes changes. - **An older server.** An answer without `fields` carries no `type`, so a user field there still shows its id, as before; guessing from the value's shape is ruled out. - Showing the person's name stays with option B on #27200. ## Verification Run on head `7e62d74b` (base `d0512c2f`, which contains PR #31346's merge `9a63ecbc`). <details><summary>Build, tests and type-check</summary> - `pnpm --workspace-concurrency=2 --filter '@object-ui/console^...' build` under the verify lock: VERDICT command-exit 0. - `pnpm --dir ui exec vitest run --maxWorkers=2 apps/console/src/pages/SharedRecordPage-27121.test.tsx apps/console/src/pages/SharedRecordPage-26767.test.tsx apps/console/src/pages/consolePagesLocale-secondPass-13460.test.tsx`: Test Files 3 passed (3), Tests 34 passed (34). - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/i18n/`: Test Files 99 passed (99), Tests 1804 passed, 13 skipped. - `pnpm --filter @object-ui/console run type-check` and `pnpm --filter @object-ui/i18n run type-check`: exit 0. eslint on the two changed console files: exit 0. `pnpm --dir ui run check:new-line-citations`: 0 new citations, exit 0. </details> <details><summary>Derived gates (38)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 38 families on this diff, and all 38 exited 0, among them `pnpm --dir ui exec vitest run scripts/` (run alone under the lock: Test Files 170 passed, 2 skipped; Tests 4996 passed) and `pnpm check:type-check-debt` (0 raw tsc errors, none above the ledger). `pnpm check:changeset-fixed` was also run (its roster sits under `.changeset/`): exit 0. `--ran` reconciliation: 38 derived, 38 run, 0 NOT-MEASURED, 0 UNRUN. Changed lines: 136, under the 3,000 threshold. </details> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 69c7cd6 commit eff90af

14 files changed

Lines changed: 125 additions & 11 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/console': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(console): the shared-record page shows a user field as "Linked user", never its sys_user id
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **`@object-ui/console`**: on a share link (`/s/TOKEN`), a user field with a value now reads "Linked user" instead of the stored `sys_user` id, which an anonymous visitor could not use. An empty one still reads as empty. It follows the lookup rule from the same page: the field is recognized by the `type: 'user'` the resolve answer's `fields` projection already serves, the page sends no extra request, and nothing reads the user record.
12+
- **`@object-ui/i18n`**: every pack gains `sharedRecord.linkedUser` (en "Linked user", zh-Hans 「已关联用户」).

‎ui/apps/console/src/pages/SharedRecordFields.tsx‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@
2525
* string through the console's one date-display path, a number through its
2626
* one number-display path, a boolean as Yes / No (the detail view's
2727
* read-only word), and an array or object as short text — never raw JSON.
28-
* - A lookup or master-detail value is the stored id of ANOTHER record, and
29-
* neither that id nor the target's name is anything the visitor should read:
30-
* a filled one shows a neutral, localized "Linked record" and an empty one
31-
* the empty face. Which field is one is read from the type the projection
32-
* serves, never guessed from the value's shape; nothing reads the target.
28+
* - A lookup, master-detail or user value is the stored id of ANOTHER record
29+
* (a user field's is a `sys_user` id), and neither that id nor the target's
30+
* name is anything the visitor should read: a filled one shows a neutral,
31+
* localized "Linked record" ("Linked user" for a user field) and an empty
32+
* one the empty face. Which field is one is read from the type the
33+
* projection serves, never guessed from the value's shape; nothing reads the
34+
* target.
3335
*
3436
* System fields are shown, not hidden: the record detail view's notion of a
3537
* framework-managed column (`isSystemManagedField`, `@object-ui/types`) only
@@ -117,14 +119,22 @@ const NO_FETCH_CELL_RENDERERS: ReadonlySet<React.FC<CellRendererProps>> = new Se
117119

118120
/**
119121
* The field types whose value is the stored id of another record, as the
120-
* projection serves `type`. The value never reaches the page as text.
122+
* projection serves `type`; a `user` field's is a `sys_user` id. The value
123+
* never reaches the page as text.
121124
*/
122-
const RECORD_REFERENCE_TYPES: ReadonlySet<string> = new Set(['lookup', 'master_detail']);
125+
const RECORD_REFERENCE_TYPES: ReadonlySet<string> = new Set(['lookup', 'master_detail', 'user']);
123126

124127
function isRecordReference(field: SharedFieldDisplay | undefined): boolean {
125128
return !!field?.type && RECORD_REFERENCE_TYPES.has(field.type);
126129
}
127130

131+
/** The neutral face of a filled reference: a person for a user field, a record otherwise. */
132+
function referencePlaceholder(field: SharedFieldDisplay | undefined, t: Translate): string {
133+
return field?.type === 'user'
134+
? t('sharedRecord.linkedUser', { defaultValue: 'Linked user' })
135+
: t('sharedRecord.linkedRecord', { defaultValue: 'Linked record' });
136+
}
137+
128138
/** A value with nothing in it: no id was stored, so there is no link to name. */
129139
function isEmptyValue(value: unknown): boolean {
130140
return value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0);
@@ -168,9 +178,7 @@ export function SharedRecordFields({ record, fields }: SharedRecordFieldsProps)
168178
isEmptyValue(record[key]) ? (
169179
EMPTY
170180
) : (
171-
<span className="text-muted-foreground">
172-
{t('sharedRecord.linkedRecord', { defaultValue: 'Linked record' })}
173-
</span>
181+
<span className="text-muted-foreground">{referencePlaceholder(field, t)}</span>
174182
)
175183
) : Renderer ? (
176184
<Renderer

‎ui/apps/console/src/pages/SharedRecordPage-27121.test.tsx‎

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
* `fields` projection (objectstack#26991): each row carries the field's
66
* authored label, a picklist value its authored option label, a currency
77
* value its currency through `resolveFieldCurrency`, and a filled lookup a
8-
* neutral "Linked record" in place of its stored id (objectstack#31326). A key
8+
* neutral "Linked record" in place of its stored id (objectstack#31326), a
9+
* filled user field "Linked user" in place of its sys_user id
10+
* (objectstack#31350). A key
911
* with no `fields` entry, and an answer from a server that predates the
1012
* projection, keep the humanized label and the JSON-shape value.
1113
*
@@ -344,3 +346,84 @@ describe('SharedRecordPage — a lookup shows a neutral placeholder, never its s
344346
expect(cellOf('Record ID').textContent).toBe('opp_7');
345347
});
346348
});
349+
350+
/**
351+
* objectstack#31350 — a user field stores a `sys_user` id, the same way a
352+
* lookup stores its target's. The projection serves it as `type: 'user'`, and
353+
* the page shows a neutral, localized "Linked user" for a filled one and the
354+
* empty face for an empty one; the id never reaches the DOM, and nothing reads
355+
* the user record.
356+
*/
357+
describe('SharedRecordPage — a user field shows a neutral placeholder, never its sys_user id (objectstack#31350)', () => {
358+
/** A `ticket` as resolve hands it over: a filled and an empty user field, a multiple one, a lookup beside them. */
359+
const TICKET = {
360+
id: 'tkt_3',
361+
subject: 'Printer on fire',
362+
assignee: 'usr_7d41a0',
363+
reviewer: null,
364+
watchers: ['usr_w_alpha', 'usr_w_beta'],
365+
backup: [],
366+
account_id: 'acc_t_55',
367+
};
368+
const TICKET_FIELDS = {
369+
id: { label: 'Record ID', type: 'text' },
370+
subject: { label: 'Subject', type: 'text' },
371+
assignee: { label: 'Assigned To', type: 'user', reference: 'sys_user' },
372+
reviewer: { label: 'Reviewer', type: 'user', reference: 'sys_user' },
373+
watchers: { label: 'Watchers', type: 'user', reference: 'sys_user', multiple: true },
374+
backup: { label: 'Backup', type: 'user', reference: 'sys_user', multiple: true },
375+
account_id: { label: 'Account', type: 'lookup', reference: 'account', displayField: 'name' },
376+
};
377+
const USER_IDS = ['usr_7d41a0', 'usr_w_alpha', 'usr_w_beta'];
378+
379+
it('a filled user field shows the placeholder, its id appears nowhere in the DOM, and only resolve is fetched', async () => {
380+
const requested = shareServer(TICKET, TICKET_FIELDS, 'ticket');
381+
renderLink();
382+
383+
await screen.findByText('Shared ticket');
384+
expect(cellOf('Assigned To').textContent).toBe('Linked user');
385+
expect(document.body.innerHTML).not.toContain('usr_7d41a0');
386+
// No read of sys_user, the data source or /meta: one request, to resolve.
387+
expect(requested).toEqual([RESOLVE_PATH]);
388+
});
389+
390+
it('a multiple user field shows the placeholder, and none of its ids', async () => {
391+
shareServer(TICKET, TICKET_FIELDS, 'ticket');
392+
renderLink();
393+
394+
await screen.findByText('Shared ticket');
395+
expect(cellOf('Watchers').textContent).toBe('Linked user');
396+
for (const id of USER_IDS) expect(document.body.innerHTML).not.toContain(id);
397+
});
398+
399+
it('an empty user field renders empty, as before', async () => {
400+
shareServer(TICKET, TICKET_FIELDS, 'ticket');
401+
renderLink();
402+
403+
await screen.findByText('Shared ticket');
404+
expect(cellOf('Reviewer').textContent).toBe('—');
405+
expect(cellOf('Backup').textContent).toBe('—');
406+
});
407+
408+
it('the placeholder speaks the session language', async () => {
409+
const zh = builtInLocales['zh-Hans'].sharedRecord.linkedUser;
410+
// Guard: a real zh row, not an English copy.
411+
expect(zh).not.toBe(builtInLocales.en.sharedRecord.linkedUser);
412+
shareServer(TICKET, TICKET_FIELDS, 'ticket');
413+
renderLink(undefined, 'zh');
414+
415+
await screen.findByText(builtInLocales['zh-Hans'].sharedRecord.sharedObject.replace('{{object}}', 'ticket'));
416+
expect(cellOf('Assigned To').textContent).toBe(zh);
417+
expect(document.body.innerHTML).not.toContain('usr_7d41a0');
418+
});
419+
420+
it('a text field renders its value and a lookup keeps "Linked record" (control)', async () => {
421+
shareServer(TICKET, TICKET_FIELDS, 'ticket');
422+
renderLink();
423+
424+
await screen.findByText('Shared ticket');
425+
expect(cellOf('Subject').textContent).toBe('Printer on fire');
426+
expect(cellOf('Record ID').textContent).toBe('tkt_3');
427+
expect(cellOf('Account').textContent).toBe('Linked record');
428+
});
429+
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4093,6 +4093,7 @@ const ar = {
40934093
sharedObject: '{{object}} مشترك',
40944094
redactedFields: 'أخفى المالك بعض الحقول: {{fields}}',
40954095
linkedRecord: 'سجل مرتبط',
4096+
linkedUser: 'مستخدم مرتبط',
40964097
},
40974098
publicForm: {
40984099
submit: "إرسال",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3788,6 +3788,7 @@ const de = {
37883788
sharedObject: 'Geteilt: {{object}}',
37893789
redactedFields: 'Einige Felder hat der Eigentümer ausgeblendet: {{fields}}',
37903790
linkedRecord: 'Verknüpfter Datensatz',
3791+
linkedUser: 'Verknüpfter Benutzer',
37913792
},
37923793
publicForm: {
37933794
submit: "Absenden",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4593,6 +4593,7 @@ const en = {
45934593
sharedObject: 'Shared {{object}}',
45944594
redactedFields: 'Some fields are hidden by the owner: {{fields}}',
45954595
linkedRecord: 'Linked record',
4596+
linkedUser: 'Linked user',
45964597
},
45974598
publicForm: {
45984599
submit: 'Submit',

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3865,6 +3865,7 @@ const es = {
38653865
sharedObject: '{{object}} compartido',
38663866
redactedFields: 'El propietario ocultó algunos campos: {{fields}}',
38673867
linkedRecord: 'Registro vinculado',
3868+
linkedUser: 'Usuario vinculado',
38683869
},
38693870
publicForm: {
38703871
submit: "Enviar",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3861,6 +3861,7 @@ const fr = {
38613861
sharedObject: '{{object}} partagé',
38623862
redactedFields: 'Certains champs sont masqués par le propriétaire : {{fields}}',
38633863
linkedRecord: 'Enregistrement lié',
3864+
linkedUser: 'Utilisateur lié',
38643865
},
38653866
publicForm: {
38663867
submit: "Envoyer",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3790,6 +3790,7 @@ const ja = {
37903790
sharedObject: '共有された {{object}}',
37913791
redactedFields: '一部の項目は所有者によって非表示にされています: {{fields}}',
37923792
linkedRecord: '関連レコード',
3793+
linkedUser: '関連ユーザー',
37933794
},
37943795
publicForm: {
37953796
submit: "送信",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3787,6 +3787,7 @@ const ko = {
37873787
sharedObject: '공유된 {{object}}',
37883788
redactedFields: '일부 필드는 소유자가 숨겼습니다: {{fields}}',
37893789
linkedRecord: '연결된 레코드',
3790+
linkedUser: '연결된 사용자',
37903791
},
37913792
publicForm: {
37923793
submit: "제출",

0 commit comments

Comments
 (0)