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
11 changes: 11 additions & 0 deletions .changeset/29629-studio-reference-name-visible.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> = [{ 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<string, unknown>;

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<string, unknown> = {
name: 'po',
label: 'Purchase order',
fields: { title: { type: 'text', label: 'Title' }, party: def },
};
function Host() {
const [draft, setDraft] = React.useState(initial);
return (
<ObjectFieldInspector
type="object"
name="po"
draft={draft}
selection={{ kind: 'field', id: 'party' }}
onPatch={(patch: Record<string, unknown>) => {
onPatch(patch);
setDraft((d) => ({ ...d, ...patch }));
}}
onClearSelection={vi.fn()}
onSelectionChange={vi.fn()}
readOnly={false}
locale={opts.locale ?? 'en-US'}
/>
);
}
render(<Host />);
/** The `party` definition the host's Save would persist after the last edit. */
const saved = () => (onPatch.mock.calls.at(-1)![0].fields as Record<string, Def>).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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1079,19 +1090,40 @@ 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;
}
const nextDef: Record<string, unknown> = { ...def, type: nextType };
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 = (
<DisplayPatchLock>
<ReferenceNameVisibleField
value={def.referenceNameVisible === true}
divided={type === 'lookup'}
onCommit={(on) => (on ? patchDef({ referenceNameVisible: true }) : unsetDefKey('referenceNameVisible'))}
disabled={locked}
locale={locale}
/>
</DisplayPatchLock>
);

/** 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);

Expand Down Expand Up @@ -1452,7 +1484,7 @@ export function ObjectFieldInspector({
</Section>

{/* 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)) && (
<Section title={tFormat('designer.field.section.options', locale, { type: typeMetaLabel ?? type })}>
{isPicklist(type) && (
<DisplayPatchLock>
Expand Down Expand Up @@ -1511,6 +1543,7 @@ export function ObjectFieldInspector({
locale={locale}
/>
</DisplayPatchLock>
{type === 'lookup' && nameVisibleToggle}
{(type === 'lookup' || type === 'master_detail') && (
<DisplayPatchLock>
<RelatedListFields
Expand All @@ -1525,6 +1558,8 @@ export function ObjectFieldInspector({
)}
</>
)}
{/* A `user` field's target is always `sys_user`: no picker config, only this key. */}
{type === 'user' && nameVisibleToggle}
{type === 'formula' && (
<DisplayPatchLock>
<CelPredicateField
Expand Down Expand Up @@ -2480,6 +2515,53 @@ function LookupConfigFields({
);
}

/* ─────────────── Declared name exposure (referenceNameVisible) ─────────────── */

/**
* The `referenceNameVisible` checkbox (objectstack#29171, ruling 6574398310
* letter B): a reader of this record may see the referenced record's name, as
* plain text with no link, when they cannot open that record. Its label and
* help text are the generic `field` form's row for the same key, so the two
* authoring surfaces describe it in one wording. The help text is the input's
* description, not a separate note.
*/
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 (
<div className={divided ? 'space-y-1 border-t pt-2.5' : 'space-y-1'} data-testid="field-reference-name-visible">
<label htmlFor={id} className="flex items-center gap-2 text-xs cursor-pointer select-none">
<input
id={id}
type="checkbox"
checked={value}
disabled={disabled}
aria-describedby={helpId}
onChange={(e) => onCommit(e.target.checked)}
className="h-3.5 w-3.5"
/>
<span>{t('designer.field.lookup.referenceNameVisible', locale)}</span>
</label>
<p id={helpId} className="text-[10px] leading-snug text-muted-foreground">
{t('designer.field.lookup.referenceNameVisible.help', locale)}
</p>
</div>
);
}

/* ─────────────── Related list on the parent (relatedList*) ─────────────── */

/** The three states of `relatedList`, as the control names them. */
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,8 @@ const STUDIO_EN: Record<string, string> = {
'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',
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,8 @@ const STUDIO_ZH: Record<string, string> = {
'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': '不显示',
Expand Down
Loading