diff --git a/.changeset/29462-reference-name-visible-plain-text.md b/.changeset/29462-reference-name-visible-plain-text.md new file mode 100644 index 00000000000..5b088c3aef5 --- /dev/null +++ b/.changeset/29462-reference-name-visible-plain-text.md @@ -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`. diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index 63543dcab6d..219a9391737 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -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); diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 007c270574a..ea7cdc82915 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -721,6 +721,10 @@ export const enMetadataForms: NonNullable = { 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" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index cc3200cbe44..5a2b7b66761 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -721,6 +721,10 @@ export const esESMetadataForms: NonNullable = 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" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 66c656c855c..ebf4c0a03ff 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -721,6 +721,10 @@ export const jaJPMetadataForms: NonNullable = label: "参照", helpText: "参照先オブジェクト名" }, + referenceNameVisible: { + label: "閲覧不可でも名前を表示", + helpText: "このレコードの閲覧者が参照先レコードを開けない場合でも、その名前をリンクなしのプレーンテキストで表示します。それ以外の内容は表示されず、名前フィールドに対するフィールドレベルのルールは引き続き適用されます(その場合、名前は Record # と ID で表示されます)。未設定の場合、このような参照は「制限付きレコード」として表示されます。" + }, deleteBehavior: { label: "削除動作", helpText: "参照先レコード削除時の動作" diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index c0dd9c47fa0..a7e37933afc 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -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 diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index e72385d29e8..65e85f90706 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -721,6 +721,10 @@ export const zhCNMetadataForms: NonNullable = label: "引用对象", helpText: "被引用的对象名称" }, + referenceNameVisible: { + label: "不可读时显示名称", + helpText: "允许此记录的读者看到被引用记录的名称(纯文本,不带链接),即使其无权打开该记录。该记录的其他内容一律不显示;名称字段上的字段级规则仍然生效(此时名称显示为 Record # 加 id)。未设置:此类引用显示为「受限记录」。" + }, deleteBehavior: { label: "删除行为", helpText: "被引用记录删除时的处理方式" diff --git a/packages/spec/src/data/field-form-reference-name-visible.test.ts b/packages/spec/src/data/field-form-reference-name-visible.test.ts new file mode 100644 index 00000000000..512f7b84038 --- /dev/null +++ b/packages/spec/src/data/field-form-reference-name-visible.test.ts @@ -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()); + }); +}); diff --git a/packages/spec/src/data/field.form.ts b/packages/spec/src/data/field.form.ts index 8243d3bd3b6..2ad14f23ed6 100644 --- a/packages/spec/src/data/field.form.ts +++ b/packages/spec/src/data/field.form.ts @@ -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' }, diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 54beb49eac2..2a610dc6dc5 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -417,17 +417,6 @@ const LEDGER: ReadonlyArray = [ 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', diff --git a/ui/packages/fields/src/__tests__/lookupCell.referenceNameVisible-29462.test.tsx b/ui/packages/fields/src/__tests__/lookupCell.referenceNameVisible-29462.test.tsx new file mode 100644 index 00000000000..3339f364117 --- /dev/null +++ b/ui/packages/fields/src/__tests__/lookupCell.referenceNameVisible-29462.test.tsx @@ -0,0 +1,209 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#29462 — a `lookup` / `user` field declaring + * `referenceNameVisible: true` (director ruling 6574398310, letter B). + * + * For a target the reader is refused, the server's `$expand` answers + * `{ id, name, readRefused: true }`, once per element on `multiple: true` + * (objectql `engine-reference-name-visible.ts`). The cell draws that name as + * plain text: no link, no hover card, and no read of the target object (the + * recording data source below answers every call, and is asked nothing). The + * floor name `Record #` a field-level rule on the name column serves is + * drawn as served. + * + * CONTROLS: an expanded record WITHOUT the marker stays a link, and a bare id + * whose read answers "not found" keeps the objectui#13335 restricted-record + * placeholder (`lookupCell.restrictedRecord-13335.test.tsx` pins that face in + * full and is unchanged). + */ + +import React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, cleanup, waitFor, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { SchemaRendererProvider, RelatedRecordActionsProvider } from '@object-ui/react'; +import { getCellRenderer } from '../index'; + +afterEach(() => cleanup()); + +const TARGET = 'hr_payroll_29462'; + +/** Records every call a renderer makes; every method answers, so a read would happen. */ +function recordingDataSource() { + const calls: Array<{ method: string; object?: unknown }> = []; + const record = (method: string, answer: unknown) => + vi.fn(async (object?: unknown) => { + calls.push({ method, object }); + return answer; + }); + const ds = { + findOne: record('findOne', null), + find: record('find', { data: [] }), + getObjectSchema: record('getObjectSchema', { name: TARGET, nameField: 'title', fields: { title: { type: 'text' } } }), + } as any; + return { ds, calls }; +} + +const hostLinks = { + resolve: () => ({}), + recordHref: (objectName: string, recordId: string | number) => `/o/${objectName}/r/${recordId}`, + openRecord: vi.fn(), +} as any; + +function renderCell(type: 'lookup' | 'user', value: unknown, field: Record = {}) { + const { ds, calls } = recordingDataSource(); + const Renderer = getCellRenderer(type); + const utils = render( + + + + + , + ); + return { ...utils, ds, calls }; +} + +const nameOnly = (root: HTMLElement) => root.querySelectorAll('[data-slot="reference-name-only"]'); +const restricted = (root: HTMLElement) => root.querySelectorAll('[data-slot="restricted-reference"]'); + +/** Lets every effect and microtask a read would start settle, then reports the calls. */ +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +} + +describe('#29462 — a lookup the reader is refused, served by name', () => { + it('draws the served name as plain text: no link, and no read of the target', async () => { + const { container, calls } = renderCell('lookup', { id: 'pay-1', name: 'Payroll Q3', readRefused: true }); + + expect(nameOnly(container)).toHaveLength(1); + expect(container.textContent).toBe('Payroll Q3'); + expect(container.querySelector('a'), 'no link to a record the reader is refused').toBeNull(); + expect(restricted(container), 'not the restricted placeholder').toHaveLength(0); + + // Hovering and focusing the name opens no card: nothing reads the target. + fireEvent.mouseEnter(nameOnly(container)[0]!); + fireEvent.pointerEnter(nameOnly(container)[0]!); + fireEvent.focus(nameOnly(container)[0]!); + await settle(); + expect(calls, 'the recording data source is asked nothing').toEqual([]); + }); + + it('draws the floor name `Record #` exactly as served', async () => { + const { container, calls } = renderCell('lookup', { id: 'l1', name: 'Record #l1', readRefused: true }); + expect(container.textContent).toBe('Record #l1'); + expect(nameOnly(container)[0]).toHaveAttribute('title', 'Record #l1'); + expect(container.querySelector('a')).toBeNull(); + await settle(); + expect(calls).toEqual([]); + }); + + it('the field\'s own displayField does not re-name the served value', () => { + const { container } = renderCell( + 'lookup', + { id: 'pay-2', name: 'Payroll Q4', readRefused: true }, + { displayField: 'secret_code' }, + ); + expect(container.textContent).toBe('Payroll Q4'); + expect(container.querySelector('a')).toBeNull(); + }); + + it('multiple: true draws one text chip per element, none of them a link', async () => { + const { container, calls } = renderCell( + 'lookup', + [ + { id: 'pay-3', name: 'Payroll Q1', readRefused: true }, + { id: 'pay-4', name: 'Record #pay-4', readRefused: true }, + ], + { multiple: true }, + ); + const chips = nameOnly(container); + expect(chips).toHaveLength(2); + expect([...chips].map((c) => c.textContent)).toEqual(['Payroll Q1', 'Record #pay-4']); + expect(container.querySelector('a')).toBeNull(); + await settle(); + expect(calls).toEqual([]); + }); + + it('multiple: true keeps each other element as it was: a readable record stays a link', () => { + const { container } = renderCell( + 'lookup', + [ + { id: 'pay-5', name: 'Payroll Open' }, + { id: 'pay-6', name: 'Payroll Closed', readRefused: true }, + ], + { multiple: true }, + ); + const anchors = container.querySelectorAll('a'); + expect(anchors).toHaveLength(1); + expect(anchors[0]).toHaveAttribute('href', `/o/${TARGET}/r/pay-5`); + expect(anchors[0]).toHaveTextContent('Payroll Open'); + expect(nameOnly(container)).toHaveLength(1); + expect(nameOnly(container)[0]!.textContent).toBe('Payroll Closed'); + expect(nameOnly(container)[0]!.closest('a')).toBeNull(); + }); +}); + +describe('#29462 — a user the reader is refused, served by name', () => { + it('draws the served name as plain text, with no avatar and no read', async () => { + const { container, calls } = renderCell('user', { id: 'u-9', name: 'Grace Hopper', readRefused: true }); + expect(nameOnly(container)).toHaveLength(1); + expect(container.textContent).toBe('Grace Hopper'); + expect(container.querySelector('a')).toBeNull(); + // An avatar would add its initials (`GH`) and its round frame. + expect(container.querySelector('.rounded-full'), 'no avatar for a person the reader is refused').toBeNull(); + await settle(); + expect(calls).toEqual([]); + }); + + it('multiple: true draws one text chip per element', () => { + const { container } = renderCell( + 'user', + [ + { id: 'u-1', name: 'Ada Lovelace', readRefused: true }, + { id: 'u-2', name: 'Record #u-2', readRefused: true }, + ], + { multiple: true }, + ); + expect([...nameOnly(container)].map((c) => c.textContent)).toEqual(['Ada Lovelace', 'Record #u-2']); + }); +}); + +describe('#29462 — CONTROLS: no marker, no change', () => { + it('an expanded record without `readRefused` is still a link', () => { + const { container } = renderCell('lookup', { id: 'pay-7', name: 'Payroll Readable' }); + expect(nameOnly(container)).toHaveLength(0); + expect(container.querySelector('a')).toHaveAttribute('href', `/o/${TARGET}/r/pay-7`); + }); + + it('`readRefused` other than `true` is not the served form', () => { + const { container } = renderCell('lookup', { id: 'pay-8', name: 'Payroll Eight', readRefused: 'yes' }); + expect(nameOnly(container)).toHaveLength(0); + expect(container.querySelector('a')).toHaveAttribute('href', `/o/${TARGET}/r/pay-8`); + }); + + it('a field without the key whose bare id reads "not found" keeps the restricted placeholder', async () => { + const { container } = renderCell('lookup', 'pay-hidden-29462', { referenceNameVisible: undefined }); + await waitFor(() => expect(restricted(container)).toHaveLength(1)); + expect(nameOnly(container)).toHaveLength(0); + expect(container.querySelector('a')).toBeNull(); + }); + + it('an expanded user without `readRefused` still draws its avatar', () => { + const { container } = renderCell('user', { id: 'u-3', name: 'Alan Turing' }); + expect(nameOnly(container)).toHaveLength(0); + expect(container.querySelector('.rounded-full')).not.toBeNull(); + expect(container.textContent).toBe('ATAlan Turing'); + }); +}); diff --git a/ui/packages/fields/src/index.tsx b/ui/packages/fields/src/index.tsx index 630a20cf9f4..999bae7633f 100644 --- a/ui/packages/fields/src/index.tsx +++ b/ui/packages/fields/src/index.tsx @@ -2686,9 +2686,16 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R (field as { reference_field?: string }).reference_field || undefined; + // A value made only of served names (`referenceNameVisible`, a target the + // reader is refused) resolves nothing: each name is drawn as served. So it + // asks nothing of the referenced object either, not even its definition. + const nameOnlyValue = Array.isArray(value) + ? value.length > 0 && value.every(isReferenceNameOnly) + : isReferenceNameOnly(value); + // Referenced object's schema (nameField / titleFormat) so expanded records // resolve through the same unified resolver as the picker (issue #2357). - const refSchema = useRefObjectSchema(referenceTo); + const refSchema = useRefObjectSchema(nameOnlyValue ? undefined : referenceTo); // The permission policy the referenced record is named under: every name // below is resolved from the row with the fields it denies on @@ -2761,6 +2768,11 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R } } + // A `referenceNameVisible` field's target the reader is refused: the served + // name, as text — no link and no hover card, both of which lead to or read + // the refused record (director ruling 6574398310, letter B). + if (isReferenceNameOnly(value)) return ; + // Server-side $expand returns the related record as a nested object // (e.g. { id, name }). Render its display name directly — no fetch needed. if (!Array.isArray(value) && typeof value === 'object') { @@ -2821,7 +2833,14 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R }; if (Array.isArray(value)) { - const itemDisplay = (item: unknown): { label: string; unresolved: boolean; restricted: boolean } => { + const itemDisplay = ( + item: unknown, + ): { label: string; unresolved: boolean; restricted: boolean; nameOnly: boolean } => { + // The served name of an element whose target the reader is refused, + // as served — per element, as the server serves it. + if (isReferenceNameOnly(item)) { + return { label: item.name, unresolved: false, restricted: false, nameOnly: true }; + } if (item != null && typeof item === 'object') { return { label: @@ -2829,12 +2848,18 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R String((item as any).id || (item as any)._id || '[Object]'), unresolved: false, restricted: false, + nameOnly: false, }; } const r = resolveLabel(item); // A restricted entry's label is the restricted sentence, never its id — // the overflow chip's `title` lists labels too. - return { label: r.restricted ? restrictedLabel : r.text, unresolved: r.unresolved, restricted: r.restricted }; + return { + label: r.restricted ? restrictedLabel : r.text, + unresolved: r.unresolved, + restricted: r.restricted, + nameOnly: false, + }; }; // Cap the chips the same way UserCellRenderer caps its avatars: a @@ -2848,7 +2873,18 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R return (
{visible.map((item, idx) => { - const { label, unresolved, restricted } = itemDisplay(item); + const { label, unresolved, restricted, nameOnly } = itemDisplay(item); + if (nameOnly) { + // One text chip per refused element: its served name, no link. + return ( + + + + ); + } if (restricted) { // No link: the reader's read of this record answered "not found". return ( @@ -3203,6 +3239,62 @@ function RestrictedLookupReference({ label }: { label: string }): React.ReactEle ); } +/** + * The value a `lookup` / `user` field declaring `referenceNameVisible: true` + * is served on `$expand` when the reader may read the record carrying it but + * is refused the record it points at (director ruling 6574398310, letter B): + * the id the row holds, the target's display name, and the refusal said in + * the value. The server's own type is `ReferenceNameOnlyValue` in + * `@objectstack/objectql` (`engine-reference-name-visible.ts`); it is served + * once per element on a `multiple: true` field. `name` is the target's + * ADR-0079 display name, or the floor `Record #` when a field-level rule + * on the name column holds it back, and is drawn exactly as served. + */ +interface ReferenceNameOnlyValue { + id: string; + name: string; + readRefused: true; +} + +/** + * Is this one reference value the served name-only form? Judged on the + * in-band `readRefused: true` the server writes, never on the shape of the + * rest: an expanded record the reader may read can also carry just an id and + * a name (a narrow projection), and that one stays a link. + */ +function isReferenceNameOnly(value: unknown): value is ReferenceNameOnlyValue { + if (value == null || typeof value !== 'object' || Array.isArray(value)) return false; + const candidate = value as { readRefused?: unknown; name?: unknown }; + return candidate.readRefused === true && typeof candidate.name === 'string'; +} + +/** + * The name of a referenced record the reader is refused, as plain text. + * + * ⛔ No link and no hover card: both lead to, or read, a record this reader's + * own read is refused, so the link would open a page that answers 404 and the + * card would make the read the server just refused. ⛔ No second read to name + * it either: the server served the name, under its own field-level rules, and + * this draws that text and nothing else. The viewer's policy on the target + * object is not applied to it, because that policy is what refused the record + * and the server already decided which name the reader may see. + * + * Distinct from {@link RestrictedLookupReference}: that face is a field + * WITHOUT the key, whose reader's own read answered "not found", so there is + * no name to show. This one is a field whose author declared the name visible. + */ +function ReferenceNameOnly({ name, className }: { name: string; className?: string }): React.ReactElement { + return ( + + {name} + + ); +} + /** * The shipped PRESENTATION of an unresolved reference, shared by the two * renderers that state one (objectui#8434 for `user`, objectui#8695 for @@ -3300,6 +3392,18 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme if (typeof entry !== 'object' || entry === null) { return ; } + // A person the reader is refused, named because the field declares + // `referenceNameVisible`: one text chip, the name as served. + if (isReferenceNameOnly(entry)) { + return ( + + + + ); + } // Each person as the viewer may read it (objectui#10535). const user = withoutDeniedFields(entry, perms, personObject); // An entry carrying nothing names no person (objectui#8596) — the @@ -3350,6 +3454,12 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme // // Judged on the person as the viewer may read it (objectui#10535), as is // everything below. + // + // ...except a person the reader is refused, served by name because the field + // declares `referenceNameVisible`: the name as served, as text, with no + // avatar. The viewer's `sys_user` policy is what refused the record, so it is + // not applied to the one name the server chose to serve. + if (isReferenceNameOnly(value)) return ; const user = withoutDeniedFields(value, perms, personObject); if (Object.keys(user).length === 0) { return ; diff --git a/ui/packages/plugin-detail/src/__tests__/DetailSection.referenceNameVisible-29462.test.tsx b/ui/packages/plugin-detail/src/__tests__/DetailSection.referenceNameVisible-29462.test.tsx new file mode 100644 index 00000000000..5d771cca9cc --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/DetailSection.referenceNameVisible-29462.test.tsx @@ -0,0 +1,150 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#29462 — the record detail page's half of a `referenceNameVisible` + * lookup / user field (director ruling 6574398310, letter B). + * + * The server's `$expand` answers a target the reader is refused as + * `{ id, name, readRefused: true }`. These drive the real `DetailSection`, the + * record page's field grid, so they stay red if the plain-text face is wired + * anywhere the detail body does not reach: the row draws the served name with + * no link and no hover card, and the recording data source the page hands its + * renderers is asked nothing about the target. A readable sibling on the same + * page keeps its link (the CONTROL). + */ +import { describe, it, expect, vi, beforeAll } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { DetailSection } from '../DetailSection'; +import { RelatedRecordActionsProvider, SchemaRendererProvider } from '@object-ui/react'; +import type { DetailViewSection } from '@object-ui/types'; + +// The desktop read row only renders above the mobile breakpoint. +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); + +const objectSchema = { + fields: { + payroll_id: { type: 'lookup', reference: 'hr_payroll', referenceNameVisible: true }, + approvers: { type: 'user', multiple: true, referenceNameVisible: true }, + manager_id: { type: 'user', referenceNameVisible: true }, + project_id: { type: 'lookup', reference: 'pm_project', referenceNameVisible: true }, + }, +}; + +const section: DetailViewSection = { + fields: [ + { name: 'payroll_id', label: 'Payroll' }, + { name: 'approvers', label: 'Approvers' }, + { name: 'manager_id', label: 'Manager' }, + { name: 'project_id', label: 'Project' }, + ], +} as DetailViewSection; + +const data = { + payroll_id: { id: 'pay-1', name: 'Payroll Q3', readRefused: true }, + approvers: [ + { id: 'u-1', name: 'Ada Lovelace', readRefused: true }, + { id: 'u-2', name: 'Record #u-2', readRefused: true }, + ], + manager_id: { id: 'u-9', name: 'Grace Hopper', readRefused: true }, + // CONTROL: the reader may read this target, so it is the expanded record. + project_id: { id: 'prj-1', name: 'Apollo' }, +}; + +function recordingDataSource() { + const calls: Array<{ method: string; object?: unknown }> = []; + const record = (method: string, answer: unknown) => + vi.fn(async (object?: unknown) => { + calls.push({ method, object }); + return answer; + }); + return { + calls, + ds: { + findOne: record('findOne', null), + find: record('find', { data: [] }), + getObjectSchema: record('getObjectSchema', null), + } as any, + }; +} + +const host = { + resolve: () => ({}), + recordHref: (objectName: string, recordId: string | number) => `/apps/demo/${objectName}/record/${recordId}`, + openRecord: vi.fn(), +} as any; + +function renderSection() { + const { ds, calls } = recordingDataSource(); + const utils = render( + + + + + , + ); + return { ...utils, calls }; +} + +/** The value region of one field row, located from its label. */ +function valueRegion(label: string): HTMLElement { + return screen.getByText(label).parentElement as HTMLElement; +} + +const nameOnly = (root: HTMLElement) => root.querySelectorAll('[data-slot="reference-name-only"]'); + +describe('DetailSection — a referenceNameVisible target the reader is refused (#29462)', () => { + it('a lookup row draws the served name as plain text, with no link', () => { + renderSection(); + const row = valueRegion('Payroll'); + expect(nameOnly(row)).toHaveLength(1); + expect(nameOnly(row)[0]!.textContent).toBe('Payroll Q3'); + expect(row.querySelector('a')).toBeNull(); + }); + + it('a user row draws the served name as plain text', () => { + renderSection(); + const row = valueRegion('Manager'); + expect(nameOnly(row)).toHaveLength(1); + expect(nameOnly(row)[0]!.textContent).toBe('Grace Hopper'); + expect(row.querySelector('a')).toBeNull(); + }); + + it('a multi-value row draws one text chip per element', () => { + renderSection(); + const row = valueRegion('Approvers'); + expect([...nameOnly(row)].map((c) => c.textContent)).toEqual(['Ada Lovelace', 'Record #u-2']); + expect(row.querySelector('a')).toBeNull(); + }); + + it('nothing reads a refused target, hovered or not', async () => { + const { calls } = renderSection(); + for (const el of nameOnly(valueRegion('Payroll'))) { + fireEvent.mouseEnter(el); + fireEvent.pointerEnter(el); + fireEvent.focus(el); + } + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + const targetReads = calls.filter((c) => c.object === 'hr_payroll' || c.object === 'sys_user'); + expect(targetReads).toEqual([]); + }); + + it('CONTROL: a readable target on the same page keeps its link', () => { + renderSection(); + const row = valueRegion('Project'); + expect(nameOnly(row)).toHaveLength(0); + expect(row.querySelector('a')).toHaveAttribute('href', '/apps/demo/pm_project/record/prj-1'); + }); +});