From c5e024b339db06fadf0bfeb1a4bc222ff202ab17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:09:42 +0000 Subject: [PATCH 1/2] feat(app-shell): Studio field panel toggles referenceNameVisible on lookup and user fields Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSxKwWNhQ9e5AS942Y --- .../29629-studio-reference-name-visible.md | 11 + ...pector.referenceNameVisible-29629.test.tsx | 197 ++++++++++++++++++ .../inspectors/ObjectFieldInspector.tsx | 82 +++++++- ui/packages/i18n/src/namespaces/studio/en.ts | 2 + ui/packages/i18n/src/namespaces/studio/zh.ts | 2 + 5 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 .changeset/29629-studio-reference-name-visible.md create mode 100644 ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.referenceNameVisible-29629.test.tsx diff --git a/.changeset/29629-studio-reference-name-visible.md b/.changeset/29629-studio-reference-name-visible.md new file mode 100644 index 00000000000..c28df446e8f --- /dev/null +++ b/.changeset/29629-studio-reference-name-visible.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(app-shell): the Studio field panel can turn on a lookup's or user field's `referenceNameVisible` + +Clause-②: no +Metadata-protocol: none + +The object designer's field panel (`ObjectFieldInspector`) now offers a "Show Name When Unreadable" checkbox on `lookup` and `user` fields, the two types `FieldSchema` accepts `referenceNameVisible` on. Checking it saves `referenceNameVisible: true`, so a reader of the record sees the referenced record's name as plain text when they cannot open that record; clearing it removes the key. On a `lookup` it sits after the picker config; a `user` field, whose target is always `sys_user`, shows the checkbox alone. A retype to any other type removes the key, since the spec refuses it there (`false` included); `lookup` to `user` keeps it. The label and help text match the generic `field` metadata form's row for the same key, in the studio namespace's `en` and `zh` tables. Other field types are unchanged. diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.referenceNameVisible-29629.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.referenceNameVisible-29629.test.tsx new file mode 100644 index 00000000000..6a1a93e4fc3 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.referenceNameVisible-29629.test.tsx @@ -0,0 +1,197 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The field inspector authors `referenceNameVisible` (objectstack#29629), the + * declared name exposure of objectstack#29171 (ruling 6574398310, letter B): + * a reader of the record may see the referenced record's name, as text with + * no link, when they cannot open that record. + * + * - The toggle is offered on `lookup` and `user` only, the two types + * `FieldSchema` accepts the key on. Checking it writes `true`; clearing it + * removes the key, so the field carries no `referenceNameVisible` at all. + * - A `user` field's target is always `sys_user`: the section opens for the + * toggle and offers none of the lookup picker controls. + * - The spec refuses the key, `false` included, on every other type, so a + * retype away from `lookup` / `user` removes it; `lookup` → `user` keeps it. + * + * Every write case ends at the written field's own keys and at `FieldSchema`, + * never at "it rendered". + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FieldSchema } from '@objectstack/spec/data'; +import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio'; + +const h = vi.hoisted(() => { + const CATALOG: Array> = [{ name: 'vendor', label: 'Vendor' }]; + const get = async (_type: string, name: string) => + name === 'vendor' ? { name, fields: { name: { type: 'text', label: 'Name' } } } : null; + const client = { + get, + withPreviewDrafts: (_on: boolean) => ({ list: async (_type: string) => CATALOG, get }), + list: async (_type: string) => CATALOG, + listDrafts: async () => [] as unknown[], + }; + return { client }; +}); +vi.mock('../useMetadata', () => ({ useMetadataClient: () => h.client })); +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [], loading: false, error: null }), +})); + +import { ObjectFieldInspector } from './ObjectFieldInspector'; +import { t, type SupportedLocale } from '../i18n'; + +// The zh case reads the lazy `studio` catalogue; load it at import so no +// assertion races the loader. +await loadStudioCatalogue('zh'); + +afterEach(cleanup); + +type Def = Record; + +const LABEL = t('designer.field.lookup.referenceNameVisible', 'en-US'); +const HELP = t('designer.field.lookup.referenceNameVisible.help', 'en-US'); + +/** A host that feeds each patch back, as Studio's draft does. */ +function renderField(def: Def, opts: { locale?: SupportedLocale } = {}) { + const onPatch = vi.fn(); + const initial: Record = { + name: 'po', + label: 'Purchase order', + fields: { title: { type: 'text', label: 'Title' }, party: def }, + }; + function Host() { + const [draft, setDraft] = React.useState(initial); + return ( + ) => { + onPatch(patch); + setDraft((d) => ({ ...d, ...patch })); + }} + onClearSelection={vi.fn()} + onSelectionChange={vi.fn()} + readOnly={false} + locale={opts.locale ?? 'en-US'} + /> + ); + } + render(); + /** The `party` definition the host's Save would persist after the last edit. */ + const saved = () => (onPatch.mock.calls.at(-1)![0].fields as Record).party; + return { onPatch, saved }; +} + +const toggle = () => screen.queryByRole('checkbox', { name: LABEL }); +const has = (o: object, key: string) => Object.prototype.hasOwnProperty.call(o, key); + +/** `FieldSchema`'s rejections of a saved definition, as `code@[path]`. */ +const rejections = (def: Def): string[] => { + const r = FieldSchema.safeParse({ name: 'party', ...def }); + return r.success ? [] : r.error.issues.map((i) => `${i.code}@[${i.path.join('.')}]`); +}; + +const LOOKUP: Def = { type: 'lookup', label: 'Vendor', reference: 'vendor' }; +const USER: Def = { type: 'user', label: 'Buyer' }; + +async function retypeTo(typeLabel: string) { + await userEvent.click(screen.getByRole('combobox', { name: t('designer.field.type', 'en-US') })); + const list = await screen.findByRole('listbox', { name: t('designer.field.type', 'en-US') }); + await userEvent.click(within(list).getByRole('option', { name: typeLabel })); +} + +describe('referenceNameVisible in the field inspector', () => { + it.each([ + ['lookup', LOOKUP], + ['user', USER], + ] as const)('a %s field offers the toggle, and checking it saves referenceNameVisible: true', async (_type, def) => { + const { saved } = renderField(def); + const box = toggle(); + expect(box).not.toBeNull(); + expect((box as HTMLInputElement).checked).toBe(false); + + await userEvent.click(box!); + + expect(saved().referenceNameVisible).toBe(true); + expect(rejections(saved())).toEqual([]); + expect((toggle() as HTMLInputElement).checked).toBe(true); + }); + + it('the help text is the toggle\'s accessible description', () => { + renderField(LOOKUP); + expect(screen.getByRole('checkbox', { name: LABEL, description: HELP })).toBeTruthy(); + }); + + it('clearing the toggle removes the key: the saved field carries no referenceNameVisible', async () => { + const { saved } = renderField({ ...LOOKUP, referenceNameVisible: true }); + expect((toggle() as HTMLInputElement).checked).toBe(true); + + await userEvent.click(toggle()!); + + expect(has(saved(), 'referenceNameVisible')).toBe(false); + expect(rejections(saved())).toEqual([]); + expect((toggle() as HTMLInputElement).checked).toBe(false); + }); + + it('a stored false reads unchecked, and checking it writes true', async () => { + const { saved } = renderField({ ...LOOKUP, referenceNameVisible: false }); + expect((toggle() as HTMLInputElement).checked).toBe(false); + + await userEvent.click(toggle()!); + + expect(saved().referenceNameVisible).toBe(true); + }); + + it.each([ + ['text', { type: 'text', label: 'Vendor name' }], + ['master_detail', { type: 'master_detail', label: 'Vendor', reference: 'vendor' }], + ] as const)('CONTROL — a %s field offers no toggle (the spec refuses the key there)', (_type, def) => { + renderField(def); + expect(toggle()).toBeNull(); + expect(screen.queryByTestId('field-reference-name-visible')).toBeNull(); + }); + + it('CONTROL — a user field offers none of the lookup picker controls (its target is always sys_user)', () => { + renderField(USER); + expect(toggle()).not.toBeNull(); + expect(screen.queryByText(t('designer.field.relatedObject', 'en-US'))).toBeNull(); + expect(screen.queryByText(t('designer.field.lookup.pickerConfig', 'en-US'))).toBeNull(); + expect(screen.queryByText(t('designer.field.lookup.displayField', 'en-US'))).toBeNull(); + expect(screen.queryByText(t('designer.field.lookup.selectableRecords', 'en-US'))).toBeNull(); + expect(screen.queryByText(t('designer.field.lookup.dependsOn', 'en-US'))).toBeNull(); + expect(screen.queryByTestId('field-related-list')).toBeNull(); + }); + + it('a retype away from lookup removes the key, so the save is not refused at [referenceNameVisible]', async () => { + const { saved } = renderField({ ...LOOKUP, referenceNameVisible: true }); + + await retypeTo('Text'); + + expect(saved().type).toBe('text'); + expect(has(saved(), 'referenceNameVisible')).toBe(false); + expect(rejections(saved()).filter((r) => r.endsWith('[referenceNameVisible]'))).toEqual([]); + expect(toggle()).toBeNull(); + }); + + it('a retype from lookup to user keeps the key (both types accept it)', async () => { + const { saved } = renderField({ ...LOOKUP, referenceNameVisible: true }); + + await retypeTo('User'); + + expect(saved().type).toBe('user'); + expect(saved().referenceNameVisible).toBe(true); + expect((toggle() as HTMLInputElement).checked).toBe(true); + }); + + it('zh: the toggle is labelled from the studio catalogue', () => { + renderField(LOOKUP, { locale: 'zh-CN' }); + expect(screen.getByRole('checkbox', { name: '不可读时显示名称' })).toBeTruthy(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index 4509f157c33..8f140917be7 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -487,6 +487,17 @@ function isLookup(type: string): boolean { return type === 'lookup' || type === 'master_detail' || type === 'tree'; } +/** + * The two types `FieldSchema` accepts `referenceNameVisible` on (objectstack#29171, + * ruling 6574398310 letter B): a `lookup`, and a `user` field (the `sys_user` + * reference). Every other type is refused the key at parse, an authored + * `false` included, `master_detail` and `tree` among them — which is why the + * toggle is offered here and the key is dropped by a retype to anything else. + */ +function offersReferenceNameVisible(type: string): boolean { + return type === 'lookup' || type === 'user'; +} + /** * objectui#11786 — what the object write guard holds this field for: its * options (a choice type with no option source) or its target (a relationship @@ -1079,7 +1090,10 @@ export function ObjectFieldInspector({ Array.isArray(def.options) && !takesOptionKind(nextType, def.multiple) && withoutOptionKinds(def.options).some((o, i) => o !== (def.options as unknown[])[i]); - if (keepsPicklist && !dropsKinds) { + // The spec refuses `referenceNameVisible` off `lookup` / `user`, `false` + // included, so a retype to any other type removes the key outright. + const dropsNameVisible = 'referenceNameVisible' in def && !offersReferenceNameVisible(nextType); + if (keepsPicklist && !dropsKinds && !dropsNameVisible) { patchDef(requireMaster ? { type: nextType, required: true } : { type: nextType }); return; } @@ -1087,11 +1101,28 @@ export function ObjectFieldInspector({ if (requireMaster) nextDef.required = true; if (!keepsPicklist) delete nextDef.picklist; if (dropsKinds) nextDef.options = withoutOptionKinds(def.options as unknown[]); + if (dropsNameVisible) delete nextDef.referenceNameVisible; const nextEntries = [...view.entries]; nextEntries[idx] = { ...entry, def: nextDef }; writeView({ shape: view.shape, entries: nextEntries }); }; + /** + * The declared name exposure, offered on `lookup` (beside its picker config) + * and on `user`. Checking writes `true`; clearing removes the key, so an + * unset field carries no `referenceNameVisible` at all. + */ + const nameVisibleToggle = ( + + (on ? patchDef({ referenceNameVisible: true }) : unsetDefKey('referenceNameVisible'))} + disabled={locked} + locale={locale} + /> + + ); + /** The names the object's other fields carry: what the API name box refuses. */ const takenNames = view.entries.filter((_, i) => i !== idx).map((e) => e.name); @@ -1452,7 +1483,7 @@ export function ObjectFieldInspector({ {/* Type-specific */} - {(isPicklist(type) || isLookup(type) || isComputed(type) || isNumeric(type) || isTexty(type) || offersValueDomain(type)) && ( + {(isPicklist(type) || isLookup(type) || offersReferenceNameVisible(type) || isComputed(type) || isNumeric(type) || isTexty(type) || offersValueDomain(type)) && (
{isPicklist(type) && ( @@ -1511,6 +1542,7 @@ export function ObjectFieldInspector({ locale={locale} /> + {type === 'lookup' && nameVisibleToggle} {(type === 'lookup' || type === 'master_detail') && ( )} + {/* A `user` field's target is always `sys_user`: no picker config, only this key. */} + {type === 'user' && nameVisibleToggle} {type === 'formula' && ( void; + disabled?: boolean; + locale?: string; +}) { + const id = React.useId(); + const helpId = `${id}-help`; + return ( +
+ +

+ {t('designer.field.lookup.referenceNameVisible.help', locale)} +

+
+ ); +} + /* ─────────────── Related list on the parent (relatedList*) ─────────────── */ /** The three states of `relatedList`, as the control names them. */ diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 4ff1c125c06..539e07b0447 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -416,6 +416,8 @@ const STUDIO_EN: Record = { 'designer.field.lookup.searchHostFields': "Search this object's fields…", 'designer.field.lookup.pageSize': 'Picker page size', 'designer.field.lookup.allowCreate': 'Allow quick-create', + 'designer.field.lookup.referenceNameVisible': 'Show Name When Unreadable', + 'designer.field.lookup.referenceNameVisible.help': "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.", 'designer.field.relatedList.heading': 'Related list on the parent record', 'designer.field.relatedList.shown': 'Show', 'designer.field.relatedList.hidden': 'Don\'t show', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 8d4b367582c..eaaf55f8006 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -344,6 +344,8 @@ const STUDIO_ZH: Record = { 'designer.field.lookup.searchHostFields': '搜索本对象的字段…', 'designer.field.lookup.pageSize': '选择器分页大小', 'designer.field.lookup.allowCreate': '允许快速创建', + 'designer.field.lookup.referenceNameVisible': '不可读时显示名称', + 'designer.field.lookup.referenceNameVisible.help': '允许此记录的读者看到被引用记录的名称(纯文本,不带链接),即使其无权打开该记录。该记录的其他内容一律不显示;名称字段上的字段级规则仍然生效(此时名称显示为 Record # 加 id)。未设置:此类引用显示为「受限记录」。', 'designer.field.relatedList.heading': '父记录上的关联列表', 'designer.field.relatedList.shown': '显示', 'designer.field.relatedList.hidden': '不显示', From 6ae9d0e1d6f4ab4c206f985f353f2b0885edcdb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:38:20 +0000 Subject: [PATCH 2/2] fix(app-shell): no rule above the name-visible toggle when a user field shows it alone Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSxKwWNhQ9e5AS942Y --- .../metadata-admin/inspectors/ObjectFieldInspector.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index 8f140917be7..7b29e00d8fc 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -1116,6 +1116,7 @@ export function ObjectFieldInspector({ (on ? patchDef({ referenceNameVisible: true }) : unsetDefKey('referenceNameVisible'))} disabled={locked} locale={locale} @@ -2528,17 +2529,20 @@ function ReferenceNameVisibleField({ value, onCommit, disabled, + divided, locale, }: { value: boolean; onCommit: (on: boolean) => void; disabled?: boolean; + /** Ruled off from the controls above it (a lookup's picker config); a `user` field shows it alone. */ + divided?: boolean; locale?: string; }) { const id = React.useId(); const helpId = `${id}-help`; return ( -
+