Skip to content

Commit 6903c93

Browse files
feat(fields, spec): show a referenceNameVisible lookup's served name as plain text, and offer its field-form row (#29639)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbUkkKyWnKsQpoatsf3hYxiL)_ Fixes #29462 Clause-②: no Metadata-protocol: none Claim: 6588482199 (amendments 6588547542, 6590220295) ## 维护者速读 - 改了什么:字段声明 `referenceNameVisible: true` 的 lookup / user 字段,当服务端对读者无权读取的目标返回 `{ id, name, readRefused: true }` 时,列表单元格与记录详情页把名称显示为纯文本:不带链接、不弹悬浮卡、不读取目标记录;多值字段每个元素一个文本标签。字段表单(`field` 元数据表单)新增该开关,只在 lookup 与 user 类型上显示,并补了中日西三语文案。 - 为什么改:服务端半边(#29460,PR #29576)已开始下发名称;按裁决 6574398310 字母 B,控制台要显示名称而不是「受限记录」占位,且不得链接到读者打不开的记录。 - 风险与回滚:只认带 `readRefused: true` 的值;不带该键的字段、可读目标、裸 id 的「受限记录」占位全部不变(#13335 测试原样通过)。回滚即 revert 本 PR。 - 先看:`ui/packages/fields/src/index.tsx` 的 `isReferenceNameOnly` / `ReferenceNameOnly` 与 `LookupCellRenderer`、`UserCellRenderer` 中的三处分支;`packages/spec/src/data/field.form.ts` 新增的一行。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/fields/src/__tests__/lookupCell.referenceNameVisible-29462.test.tsx`,预期 11 个用例全部通过。 - 需要你做的:无,席位落地。 ## What changes The console half of `FieldSchema.referenceNameVisible` (director ruling 6574398310, letter B), rendered against the shape PR #29576 serves: on `$expand`, a `lookup` / `user` field declaring the key answers a target the reader is refused as `{ id, name, readRefused: true }`, once per element on `multiple: true`, with the floor `Record #ID` under a field-level rule on the name column. | Value the cell receives | Before | Now | |---|---|---| | `{ id, name, readRefused: true }` on a `lookup` | a link to the refused record, with a hover card that reads it | the served `name` as plain text (`data-slot="reference-name-only"`), no link, no hover card, no read | | the same on a `user` | an avatar and the name | the served `name` as plain text, no avatar | | an array of those (`multiple: true`) | one linked chip / avatar per element | one text chip per element; other elements keep their faces | | an expanded record without the marker | a link | unchanged | | a bare id whose read answers "not found" (field without the key) | the restricted-record placeholder | unchanged | - `isReferenceNameOnly` judges the in-band `readRefused === true` the server writes, never the shape of the rest, so a readable record expanded with a narrow projection stays a link. - The served name is drawn as served: the viewer's policy on the target object (which is what refused the record) is not applied to it, and the field's `displayField` does not re-name it. - A value made only of served names does not ask for the referenced object's definition either (`useRefObjectSchema` gets no object), so the cell makes no read at all. - The record page's field grid (`DetailSection`) draws lookup and user values through these same cells, so it changes with them; `plugin-detail` source is untouched. - **Field form.** `packages/spec/src/data/field.form.ts` gains the `referenceNameVisible` row ("Show Name When Unreadable"), `visibleWhen: data.type in ['lookup','user']`. That predicate is also the key's parse gate: `FieldSchema` refuses the key on every other type, an authored `false` included. The `omit` ledger entry in `metadata-form-zod-reconciliation.test.ts` is removed with the row (the card's one spec edit). The row's label and help text are regenerated into the `platform-objects` metadata-form bundles and translated for zh-CN, ja-JP and es-ES. ## Tests - `ui/packages/fields/src/__tests__/lookupCell.referenceNameVisible-29462.test.tsx` (new, 11): served name as text with no anchor, and a recording data source (find, findOne, getObjectSchema all answering) asked nothing, after hover and focus; the floor `Record #l1` drawn as served; `displayField` does not re-name it; `multiple: true` one chip per element; a mixed array keeps the readable element's link; `user` single and multiple; CONTROLS: a record without the marker and `readRefused: 'yes'` stay links, a field without the key keeps the restricted placeholder, an expanded user keeps its avatar. - `ui/packages/plugin-detail/src/__tests__/DetailSection.referenceNameVisible-29462.test.tsx` (new, 5): the real `DetailSection` draws lookup, user and multi-value user rows as text with no anchor, reads nothing about the refused targets, and keeps a readable sibling's link. - `packages/spec/src/data/field-form-reference-name-visible.test.ts` (new, 3): the row is declared once, its predicate shows it on `lookup` and `user` only over every `FieldType`, and that set equals the set `FieldSchema` accepts the key on. - Unchanged control: `lookupCell.restrictedRecord-13335.test.tsx` green, unmodified. - Ablations (`scripts/ablation-replace.mjs`, WRAP mode, each restored to the HEAD blob), each red: A1 lookup single branch removed, 3 failed; A2 array element branch removed, 2 failed; A3 user single branch removed, 2 failed; A4 user array branch removed, 2 failed; A5 schema read no longer skipped, 1 failed; A6 the row's predicate widened to `master_detail`, 2 failed. ## Acceptance notes - Clause-② hint: `dispatch-gates --tier` derives no path mandate (default tier). It prints a Clause-② SUSPECT hint on `packages/spec/src/data/field.form.ts` (`packages/spec/src/**`). The row offers a key `FieldSchema` already accepts on exactly these types (the accept set does not move), so the claim's `Clause-②: no` is kept; the seat reads the hint. - Surface: the claim's eight files plus one count pin the seat added by amendment 6590220295: `object-lifecycle-panel-echo-decisions.test.ts` counts metadata-form labels authored in all three locales, 699 → 700 with its comment line, as every new form row does. - The Studio object designer's field panel is the hand-written `ObjectFieldInspector`, which does not read `field.form.ts`. A live probe as the admin (lookup selected in the designer) shows no such control there. The seat files that control as its own card; this PR leaves the inspector alone. - Seen on the live server, not changed here: the same lookup WITHOUT the key, whose target the member's object gate refuses, draws the unresolved face (the id with a question mark, inside a link) rather than the restricted-record placeholder. The record read answers 403 for an object-gate refusal, and the cell reads only a "not found" answer as restricted. Reported to the seat. - Local gates on the merged head `7fb3a29d`: `dispatch-gates --commands` derives 85 commands; each ran with exit 0 (`check:type-check-debt` and `pnpm --dir ui exec vitest run scripts/` under the verify lock), and `--ran` reconciles 85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN. Type-checks exit 0: spec, platform-objects, and `turbo run type-check` for `@object-ui/fields` and `@object-ui/plugin-detail`. Suites: spec 891/891 files (25223 tests) and fields 295 files / plugin-detail 369 files before the main merge; on the merged head platform-objects 110/110 files, spec `src/system/` plus the field-form tests 70/70 files, the four lookup/detail files 31/31, and `spec check:generated` exit 0. The repo-wide sweep (`pnpm lint` and the rest of the farm) is CI's. ## Verification Real backend and the source Console, on head `5c25e7f3` (before the main merge; this PR's cell and form code is unchanged since). Screenshots and the scripts are in the project folder (`issue-29462/shots/`, `live-setup.mjs`, `live-grant.mjs`, `live-shot.cjs`), not on GitHub. <details><summary><code>pnpm dev:crm -- --fresh</code> plus the Console Vite dev server, a member who may read the expense but not the payroll run</summary> - A scratch package over REST: `rnv_payroll` (`sharingModel: private`, admin-owned rows) and `rnv_expense` (`public_read`) with `payroll` (lookup, `referenceNameVisible: true`), `payroll_plain` (the same lookup without the key) and `payrolls` (`multiple: true`, with the key). A member created with `admin/create-user`, granted read on `rnv_expense` only by a package permission set, then impersonated. - `GET /api/v1/data/rnv_payroll/ID` as the member: 403. `GET /api/v1/data/rnv_expense/ID?expand=payroll,payroll_plain,payrolls` as the member: `payroll` = `{ id, name: "Payroll Q3 2026", readRefused: true }`, `payrolls` = two such values, `payroll_plain` = the bare id. As the admin, all three are the full records. - List view (`/apps/com.example.rnv/rnv_expense`) and record page, as the member: `Payroll (name visible)` reads "Payroll Q3 2026" as plain text; `Payrolls` shows two text chips "Payroll Q3 2026" and "Payroll Q4 2026"; three `reference-name-only` elements, none inside a link. Hovering each of them made no request. The only `rnv_payroll` requests on the page (one record read, one object definition read) and the only link to `rnv_payroll` belong to `Payroll (no key)`, the control field, which draws as before. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTfRUqsXsgEtuXogw2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 57958aa commit 6903c93

13 files changed

Lines changed: 607 additions & 17 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/fields': patch
3+
'@objectstack/spec': patch
4+
'@objectstack/platform-objects': patch
5+
---
6+
7+
feat(fields, spec): a `referenceNameVisible` lookup or user field shows the name of a record the reader may not open as plain text, and the field form offers the key
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
The console half of `FieldSchema.referenceNameVisible`, after its server half began serving the name:
13+
14+
- **Lookup and user cells, and the record page.** When `$expand` answers a reference as `{ id, name, readRefused: true }` (the reader may read the record carrying the field, but not the record it points at), the lookup cell and the user cell draw `name` as plain text: no link to the record, no hover card, and no read of that record or of its object. A `multiple: true` field draws one text chip per such element, and its other elements keep their faces. The floor name `Record #` with the id is drawn exactly as served. The record page's field grid draws these values through the same cells.
15+
- **Unchanged.** A field without the key, and a target the reader can read, keep today's faces: a readable record is a link, and a bare id the reader's own read answers "not found" for keeps the restricted-record placeholder.
16+
- **Field form.** The `field` metadata form gains a `referenceNameVisible` switch ("Show Name When Unreadable"), shown on `lookup` and `user` fields only, the types the schema accepts the key on. Its label and help text are translated for `zh-CN`, `ja-JP` and `es-ES`.

‎packages/lint/src/validate-predicate-path-refs.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -696,7 +696,10 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
696696
// It is 90 today, an ADDITION of ONE: #28255 offered the field form's
697697
// `percentDisplay` row once the console's percent face reads it, gated on
698698
// `data.type == 'percent'`. It reaches the literal-comparison census below.
699-
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(90);
699+
// It is 91 today, an ADDITION of ONE: #29462 offered the field form's
700+
// `referenceNameVisible` row, gated on `data.type in ['lookup','user']`. It
701+
// is an in-list, so it does not reach the literal-comparison census below.
702+
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(91);
700703

701704
const findings = validatePredicatePathRefs(corrupted);
702705
expect(findings).toHaveLength(predicates);

‎packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
721721
label: "Reference",
722722
helpText: "Referenced object name"
723723
},
724+
referenceNameVisible: {
725+
label: "Show Name When Unreadable",
726+
helpText: "Lets a reader of this record see the referenced record's name, as plain text with no link, even when they may not open that record. Nothing else of it is shown, and a field-level rule on the name still holds (the name then reads Record # and the id). Unset: such a reference shows as a restricted record."
727+
},
724728
deleteBehavior: {
725729
label: "Delete Behavior",
726730
helpText: "What happens when referenced record is deleted"

‎packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
721721
label: "Referencia",
722722
helpText: "Nombre del objeto referenciado"
723723
},
724+
referenceNameVisible: {
725+
label: "Mostrar nombre si no es legible",
726+
helpText: "Permite que quien lee este registro vea el nombre del registro referenciado, como texto sin enlace, aunque no pueda abrir ese registro. No se muestra nada más de él, y una regla de nivel de campo sobre el nombre sigue aplicándose (el nombre se muestra entonces como Record # y el id). Sin definir: esa referencia se muestra como «Registro restringido»."
727+
},
724728
deleteBehavior: {
725729
label: "Comportamiento al eliminar",
726730
helpText: "Qué ocurre cuando se elimina el registro referenciado"

‎packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
721721
label: "参照",
722722
helpText: "参照先オブジェクト名"
723723
},
724+
referenceNameVisible: {
725+
label: "閲覧不可でも名前を表示",
726+
helpText: "このレコードの閲覧者が参照先レコードを開けない場合でも、その名前をリンクなしのプレーンテキストで表示します。それ以外の内容は表示されず、名前フィールドに対するフィールドレベルのルールは引き続き適用されます(その場合、名前は Record # と ID で表示されます)。未設定の場合、このような参照は「制限付きレコード」として表示されます。"
727+
},
724728
deleteBehavior: {
725729
label: "削除動作",
726730
helpText: "参照先レコード削除時の動作"

‎packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1253,7 +1253,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
12531253
// 699 since #28879: the action form's `cancelText` row (the dismiss
12541254
// label of the action's confirm and param dialogs) — one row label,
12551255
// authored in all three locales.
1256-
expect(translated.length, `${locale} positive control`).toBe(699);
1256+
// 700 since #29462: the field form's `referenceNameVisible` row (a lookup
1257+
// or user field shows a refused target's name as plain text) — one row
1258+
// label, authored in all three locales.
1259+
expect(translated.length, `${locale} positive control`).toBe(700);
12571260
}
12581261
// ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the
12591262
// all-three predicate returns 0 while the per-locale one returns 1, so the

‎packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
721721
label: "引用对象",
722722
helpText: "被引用的对象名称"
723723
},
724+
referenceNameVisible: {
725+
label: "不可读时显示名称",
726+
helpText: "允许此记录的读者看到被引用记录的名称(纯文本,不带链接),即使其无权打开该记录。该记录的其他内容一律不显示;名称字段上的字段级规则仍然生效(此时名称显示为 Record # 加 id)。未设置:此类引用显示为「受限记录」。"
727+
},
724728
deleteBehavior: {
725729
label: "删除行为",
726730
helpText: "被引用记录删除时的处理方式"
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #29462 — the field form offers `referenceNameVisible` on `lookup` and `user`
5+
* fields, and on no other type (director ruling 6574398310, letter B).
6+
*
7+
* The key is served (`$expand` answers `{ id, name, readRefused: true }` for a
8+
* target the reader is refused) and drawn (the console's lookup and user cells
9+
* show that name as text, with no link), so the form row replaces the `omit`
10+
* entry `metadata-form-zod-reconciliation.test.ts` carried while it was
11+
* neither. The row's `visibleWhen` is also the key's parse gate: `FieldSchema`
12+
* refuses the key on every other type, an authored `false` included, so a row
13+
* shown on any other type would offer a value the save refuses. The set the
14+
* row is shown on is therefore read off the predicate and held equal to the
15+
* set the schema accepts, over every `FieldType`.
16+
*/
17+
18+
import { describe, it, expect } from 'vitest';
19+
20+
import { fieldForm } from './field.form';
21+
import { FieldSchema, FieldType } from './field.zod';
22+
23+
type Predicate = string | { dialect?: string; source?: string } | undefined;
24+
type FormEntry = { field?: string; visibleWhen?: Predicate; fields?: FormEntry[] };
25+
26+
/** Every entry of the form, nested composite / list members included. */
27+
function allEntries(): FormEntry[] {
28+
const out: FormEntry[] = [];
29+
const walk = (entries: FormEntry[] | undefined) => {
30+
for (const e of entries ?? []) {
31+
out.push(e);
32+
walk(e.fields);
33+
}
34+
};
35+
for (const section of (fieldForm as { sections: { fields?: FormEntry[] }[] }).sections) {
36+
walk(section.fields);
37+
}
38+
return out;
39+
}
40+
41+
function sourceOf(predicate: Predicate): string | undefined {
42+
return typeof predicate === 'string' ? predicate : predicate?.source;
43+
}
44+
45+
/**
46+
* The field types a `data.type in [...]` predicate shows its row on. Any other
47+
* predicate shape answers `null`, so a rewrite into a form this reader does not
48+
* know fails the test instead of being read as "no type".
49+
*/
50+
function typesShownBy(source: string | undefined): string[] | null {
51+
const match = /^data\.type in \[((?:'[a-z_]+',?)+)\]$/.exec(source ?? '');
52+
if (!match) return null;
53+
return match[1]!.split(',').map((quoted) => quoted.replace(/'/g, ''));
54+
}
55+
56+
const rows = () => allEntries().filter((e) => e.field === 'referenceNameVisible');
57+
58+
describe('field form: the referenceNameVisible row', () => {
59+
it('is declared once, at the top level of the form', () => {
60+
// Positive control: the walk reaches the reference rows beside it.
61+
expect(allEntries().some((e) => e.field === 'reference')).toBe(true);
62+
expect(rows()).toHaveLength(1);
63+
});
64+
65+
it('is shown on lookup and user, and on no other field type', () => {
66+
const shown = typesShownBy(sourceOf(rows()[0]?.visibleWhen));
67+
expect(shown, 'the predicate is a data.type in [...] list').not.toBeNull();
68+
expect([...shown!].sort()).toEqual(['lookup', 'user']);
69+
const offered = FieldType.options.filter((type) => shown!.includes(type));
70+
expect(offered.sort()).toEqual(['lookup', 'user']);
71+
});
72+
73+
it('is shown exactly where FieldSchema accepts the key, over every field type', () => {
74+
const shown = new Set(typesShownBy(sourceOf(rows()[0]?.visibleWhen)) ?? []);
75+
const accepted = FieldType.options.filter((type) => {
76+
const parsed = FieldSchema.safeParse({ type, label: 'Owner', referenceNameVisible: true });
77+
return parsed.success || !parsed.error.issues.some((i) => i.path[0] === 'referenceNameVisible');
78+
});
79+
// CONTROL: the schema does refuse the key somewhere, so the set is a real cut.
80+
expect(accepted.length).toBeLessThan(FieldType.options.length);
81+
expect([...accepted].sort()).toEqual([...shown].sort());
82+
});
83+
});

‎packages/spec/src/data/field.form.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,17 @@ export const fieldForm = defineForm({
221221
// `helpText` is identical on both — they share one i18n key
222222
// (`metadataForms.field.fields.deleteBehavior`), so divergent text would
223223
// collide silently.
224+
// #29171 (director ruling 6574398310, letter B) — the declared name
225+
// exposure, now that it is served (`$expand` answers
226+
// `{ id, name, readRefused: true }` for a target the reader is refused)
227+
// and drawn (the console's lookup and user cells show that name as
228+
// text, with no link). `visibleWhen` is the key's PARSE gate, not only
229+
// a meaningfulness gate: `FieldSchema` refuses the key on every other
230+
// type, an authored `false` included, so the row is offered exactly
231+
// where a value can be saved. `user` has no `reference` row (its
232+
// target is always `sys_user`), which is why this row is not grouped
233+
// under that one's visibility.
234+
{ field: 'referenceNameVisible', label: 'Show Name When Unreadable', visibleWhen: "data.type in ['lookup','user']", helpText: "Lets a reader of this record see the referenced record's name, as plain text with no link, even when they may not open that record. Nothing else of it is shown, and a field-level rule on the name still holds (the name then reads Record # and the id). Unset: such a reference shows as a restricted record." },
224235
{ field: 'deleteBehavior', visibleWhen: "data.type == 'lookup'", helpText: 'What happens when referenced record is deleted' },
225236
{ field: 'deleteBehavior', type: 'select', visibleWhen: "data.type == 'master_detail'", helpText: 'What happens when referenced record is deleted', options: [
226237
{ label: 'Cascade (delete children)', value: 'cascade' },

‎packages/spec/src/system/metadata-form-zod-reconciliation.test.ts‎

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -418,17 +418,6 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
418418
key: 'lookupAutofill',
419419
why: "planned, not yet read — a lookup's pick autofill pairs (`[{ target, source, overwrite? }]`) are validated at parse (a single-value relationship; each target a writable field of the same object, named once) and their sources at `os validate` / `os build`, but no form or grid fills a field from them until the ui card's lookup widget writes the declared targets on pick. A form control offered today would let an author declare a fill nothing performs",
420420
},
421-
// #29171 (ruling 6574398310, letter B) — a lookup's declared name exposure
422-
// lands spec-first the same way: `[PLANNED — not yet served]` on its
423-
// describe, served by no read or `$expand` until the server lane's card,
424-
// drawn by no renderer until the ui lane's card, which adds the form row.
425-
{
426-
kind: 'omit',
427-
type: 'field',
428-
path: ROOT_PATH,
429-
key: 'referenceNameVisible',
430-
why: "planned, not yet served — a lookup's declared name exposure (`true` lets a reader of the record see the target's `nameField` value and id when it cannot read the target) is refused at parse off `lookup` / `user`, but no read or `$expand` serves the name and no renderer draws it until the server and ui follow-up cards land. A form control offered today would let an author declare an exposure nothing serves",
431-
},
432421
{
433422
kind: 'omit',
434423
type: 'view',

0 commit comments

Comments
 (0)