Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/29462-reference-name-visible-plain-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/fields': patch
'@objectstack/spec': patch
'@objectstack/platform-objects': patch
---

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

Clause-②: no
Metadata-protocol: none

The console half of `FieldSchema.referenceNameVisible`, after its server half began serving the name:

- **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.
- **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.
- **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`.
5 changes: 4 additions & 1 deletion packages/lint/src/validate-predicate-path-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -696,7 +696,10 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
// It is 90 today, an ADDITION of ONE: #28255 offered the field form's
// `percentDisplay` row once the console's percent face reads it, gated on
// `data.type == 'percent'`. It reaches the literal-comparison census below.
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(90);
// It is 91 today, an ADDITION of ONE: #29462 offered the field form's
// `referenceNameVisible` row, gated on `data.type in ['lookup','user']`. It
// is an in-list, so it does not reach the literal-comparison census below.
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(91);

const findings = validatePredicatePathRefs(corrupted);
expect(findings).toHaveLength(predicates);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
label: "Reference",
helpText: "Referenced object name"
},
referenceNameVisible: {
label: "Show Name When Unreadable",
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."
},
deleteBehavior: {
label: "Delete Behavior",
helpText: "What happens when referenced record is deleted"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "Referencia",
helpText: "Nombre del objeto referenciado"
},
referenceNameVisible: {
label: "Mostrar nombre si no es legible",
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»."
},
deleteBehavior: {
label: "Comportamiento al eliminar",
helpText: "Qué ocurre cuando se elimina el registro referenciado"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "参照",
helpText: "参照先オブジェクト名"
},
referenceNameVisible: {
label: "閲覧不可でも名前を表示",
helpText: "このレコードの閲覧者が参照先レコードを開けない場合でも、その名前をリンクなしのプレーンテキストで表示します。それ以外の内容は表示されず、名前フィールドに対するフィールドレベルのルールは引き続き適用されます(その場合、名前は Record # と ID で表示されます)。未設定の場合、このような参照は「制限付きレコード」として表示されます。"
},
deleteBehavior: {
label: "削除動作",
helpText: "参照先レコード削除時の動作"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1253,7 +1253,10 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
// 699 since #28879: the action form's `cancelText` row (the dismiss
// label of the action's confirm and param dialogs) — one row label,
// authored in all three locales.
expect(translated.length, `${locale} positive control`).toBe(699);
// 700 since #29462: the field form's `referenceNameVisible` row (a lookup
// or user field shows a refused target's name as plain text) — one row
// label, authored in all three locales.
expect(translated.length, `${locale} positive control`).toBe(700);
}
// ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the
// all-three predicate returns 0 while the per-locale one returns 1, so the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "引用对象",
helpText: "被引用的对象名称"
},
referenceNameVisible: {
label: "不可读时显示名称",
helpText: "允许此记录的读者看到被引用记录的名称(纯文本,不带链接),即使其无权打开该记录。该记录的其他内容一律不显示;名称字段上的字段级规则仍然生效(此时名称显示为 Record # 加 id)。未设置:此类引用显示为「受限记录」。"
},
deleteBehavior: {
label: "删除行为",
helpText: "被引用记录删除时的处理方式"
Expand Down
83 changes: 83 additions & 0 deletions packages/spec/src/data/field-form-reference-name-visible.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #29462 — the field form offers `referenceNameVisible` on `lookup` and `user`
* fields, and on no other type (director ruling 6574398310, letter B).
*
* The key is served (`$expand` answers `{ id, name, readRefused: true }` for a
* target the reader is refused) and drawn (the console's lookup and user cells
* show that name as text, with no link), so the form row replaces the `omit`
* entry `metadata-form-zod-reconciliation.test.ts` carried while it was
* neither. The row's `visibleWhen` is also the key's parse gate: `FieldSchema`
* refuses the key on every other type, an authored `false` included, so a row
* shown on any other type would offer a value the save refuses. The set the
* row is shown on is therefore read off the predicate and held equal to the
* set the schema accepts, over every `FieldType`.
*/

import { describe, it, expect } from 'vitest';

import { fieldForm } from './field.form';
import { FieldSchema, FieldType } from './field.zod';

type Predicate = string | { dialect?: string; source?: string } | undefined;
type FormEntry = { field?: string; visibleWhen?: Predicate; fields?: FormEntry[] };

/** Every entry of the form, nested composite / list members included. */
function allEntries(): FormEntry[] {
const out: FormEntry[] = [];
const walk = (entries: FormEntry[] | undefined) => {
for (const e of entries ?? []) {
out.push(e);
walk(e.fields);
}
};
for (const section of (fieldForm as { sections: { fields?: FormEntry[] }[] }).sections) {
walk(section.fields);
}
return out;
}

function sourceOf(predicate: Predicate): string | undefined {
return typeof predicate === 'string' ? predicate : predicate?.source;
}

/**
* The field types a `data.type in [...]` predicate shows its row on. Any other
* predicate shape answers `null`, so a rewrite into a form this reader does not
* know fails the test instead of being read as "no type".
*/
function typesShownBy(source: string | undefined): string[] | null {
const match = /^data\.type in \[((?:'[a-z_]+',?)+)\]$/.exec(source ?? '');
if (!match) return null;
return match[1]!.split(',').map((quoted) => quoted.replace(/'/g, ''));
}

const rows = () => allEntries().filter((e) => e.field === 'referenceNameVisible');

describe('field form: the referenceNameVisible row', () => {
it('is declared once, at the top level of the form', () => {
// Positive control: the walk reaches the reference rows beside it.
expect(allEntries().some((e) => e.field === 'reference')).toBe(true);
expect(rows()).toHaveLength(1);
});

it('is shown on lookup and user, and on no other field type', () => {
const shown = typesShownBy(sourceOf(rows()[0]?.visibleWhen));
expect(shown, 'the predicate is a data.type in [...] list').not.toBeNull();
expect([...shown!].sort()).toEqual(['lookup', 'user']);
const offered = FieldType.options.filter((type) => shown!.includes(type));
expect(offered.sort()).toEqual(['lookup', 'user']);
});

it('is shown exactly where FieldSchema accepts the key, over every field type', () => {
const shown = new Set(typesShownBy(sourceOf(rows()[0]?.visibleWhen)) ?? []);
const accepted = FieldType.options.filter((type) => {
const parsed = FieldSchema.safeParse({ type, label: 'Owner', referenceNameVisible: true });
return parsed.success || !parsed.error.issues.some((i) => i.path[0] === 'referenceNameVisible');
});
// CONTROL: the schema does refuse the key somewhere, so the set is a real cut.
expect(accepted.length).toBeLessThan(FieldType.options.length);
expect([...accepted].sort()).toEqual([...shown].sort());
});
});
11 changes: 11 additions & 0 deletions packages/spec/src/data/field.form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,17 @@ export const fieldForm = defineForm({
// `helpText` is identical on both — they share one i18n key
// (`metadataForms.field.fields.deleteBehavior`), so divergent text would
// collide silently.
// #29171 (director ruling 6574398310, letter B) — the declared name
// exposure, now that it is served (`$expand` answers
// `{ id, name, readRefused: true }` for a target the reader is refused)
// and drawn (the console's lookup and user cells show that name as
// text, with no link). `visibleWhen` is the key's PARSE gate, not only
// a meaningfulness gate: `FieldSchema` refuses the key on every other
// type, an authored `false` included, so the row is offered exactly
// where a value can be saved. `user` has no `reference` row (its
// target is always `sys_user`), which is why this row is not grouped
// under that one's visibility.
{ 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." },
{ field: 'deleteBehavior', visibleWhen: "data.type == 'lookup'", helpText: 'What happens when referenced record is deleted' },
{ field: 'deleteBehavior', type: 'select', visibleWhen: "data.type == 'master_detail'", helpText: 'What happens when referenced record is deleted', options: [
{ label: 'Cascade (delete children)', value: 'cascade' },
Expand Down
11 changes: 0 additions & 11 deletions packages/spec/src/system/metadata-form-zod-reconciliation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,17 +417,6 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
key: 'lookupAutofill',
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",
},
// #29171 (ruling 6574398310, letter B) — a lookup's declared name exposure
// lands spec-first the same way: `[PLANNED — not yet served]` on its
// describe, served by no read or `$expand` until the server lane's card,
// drawn by no renderer until the ui lane's card, which adds the form row.
{
kind: 'omit',
type: 'field',
path: ROOT_PATH,
key: 'referenceNameVisible',
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",
},
{
kind: 'omit',
type: 'view',
Expand Down
Loading
Loading