Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Commit 57dbe34

Browse files
fix(app-shell): one shared object picker for Studio's object-name inputs, resolving only the chosen name (objectui#11783) (#11840)
Fixes #11783 Clause-②: no ## What this changes Studio's object-name inputs now share one module-private picker, `inspectors/ObjectPicker.tsx`. - **Who renders it.** In `ObjectFieldInspector`: the Lookup's *Related object* and the summary's *Child object*. In `FlowReferenceField`: every flow reference of kind `object`, through one branch in `ReferenceCombobox`. That covers the trigger's *Object*, the time-relative *Sweep object*, the map node's *Item object*, the *Object* of the four record nodes and the legacy action node, and the screen's *Object form*. The trigger field reaches the picker through `FlowNodeConfigField` and `FlowReferenceField`. `FlowNodeInspector.tsx` is untouched. - **When a name leaves it.** Typing stays in the input. `onCommit` runs once, when the author chooses an option, presses Enter, or leaves the input after typing. Escape abandons the typing. Enter and blur commit exactly what was typed, matched or not, so a flow value that is an expression is accepted as before. - **What it offers.** One read per picker mount: `GET /api/v1/meta/object?preview=draft`. The groups come in this order: - this package's objects, where the package is Studio's `:packageId` route parameter, else the editor URL's `?package=`; - then the other objects; - then every object the server marks `isSystem`, in a collapsed *System* group. A search reaches that group without opening it, so `sys_user` stays choosable for a Lookup. Each option shows its label and its name. An entry served with `_draft` carries the existing `engine.inspector.draftSuffix` marker. "System" is read from the served `isSystem` only: no name prefix and no list. - **`crm_lead`.** The placeholder is removed from the trigger *Object* field, with no replacement and no locale key. - **i18n.** Six new `engine.inspector.objectPicker.*` rows, EN and ZH, in `metadata-admin/i18n.ts`. Loading and load failure reuse `engine.form.loadingOptions` and `engine.form.optionsLoadFailedTitle`. Nothing is added to the package's entry: no export, published prop, `@object-ui/types` member or `packages/i18n` key. One module-internal optional prop, `ariaLabel` on `ReferenceCombobox`, is added. It carries the flow field's label to the picker, so the input has an accessible name (the old datalist input had none). `ReferenceCombobox` is not exported from the package. ## Why the preview list, and not the list the inputs read before This departs from the suggested route on measurement: - **What the draft list lacks.** `GET /meta/_drafts?type=object` answers header rows with no label. Labelling a draft-only object from it would take one more read per draft. - **What the preview list carries.** `?preview=draft` serves the draft-only object in the same single answer, with its own label, its `_packageId` and `_draft: true`. - **Callers without draft access.** A caller the server does not admit to pending drafts gets the published list. That is the server's documented behaviour (`mayReadPendingDrafts` in objectstack's runtime meta domain); this run did not measure it. - **One consequence, also measured.** A *published* object with a pending draft is served `_draft: true` with its draft label, so it also carries the draft marker. ## Measurements **Setup.** Real Chromium at 1440×900, with this branch's console on a dev-only Vite harness that is not committed. The backend is `examples/app-showcase` from objectstack `bafb58bb`, run with `objectstack dev --seed-admin --fresh`. The data is a writable package `com.example.repairs` holding a draft-only object `repairs_repair_ticket` and a draft flow. The author types `repairs_repair_ticket` one key at a time into each input. Both runs start from the same drafts. | | before, `16cda0b` | after | |---|---|---| | Lookup *Related object*: per-prefix `GET /meta/object/PREFIX` while typing | 20 | 0 | | Lookup: object reads on commit | (one per keystroke) | 1, for the committed name | | Flow trigger *Object*: per-prefix `GET /meta/object/PREFIX` while typing | 41 | 0 | | Flow: object reads on commit | (one or two per keystroke) | 3, all for the committed name | | What the input offers | datalist of 109 / 108 entries, 83 named `sys_*`, the draft as a bare name | 2 package + 27 other, then a collapsed *System* group; the draft as *Repair Ticket* (draft) | | Trigger placeholder | `crm_lead` | none | All 3 reads on the flow commit are for the committed name. The inspector holds more than one reader of the trigger object: `useFlowScope` in `FlowNodeInspector` and the entry-condition `ConditionBuilder` are two of them, and the third was not identified. All of them are outside this card's files. **H2, the served list.** `GET /api/v1/meta/object` returned 107 entries. All 107 carry `isSystem` (80 `true`) and a `label`, and every entry carries `_packageId`. A runtime-authored object carries no `isSystem` key, published or draft. Its absence is the spec's declared default, `false`. Three platform objects are served `isSystem: false`: `sys_inbox_message`, `sys_file` and `sys_upload_session`. They are offered up front, because the mark decides, not the prefix. **H3, the object-name inputs in the Studio inspectors.** - Moved onto the picker: the Lookup and summary inputs above, and every flow reference of kind `object`. Each was a free-text datalist that committed per keystroke. - Left as they are, because they are not the same kind: - the hook's *Objects*, a multi-select checkbox list; - the action's *Object* and the page block's object, both strict selects (each falls back to a text field only when the list is empty); - the view variant's, dataset's and app-nav's object pickers, all `InspectorComboField` popovers that commit on choice. None of these fetches per keystroke. All of them still list `isSystem` objects without grouping (see Acceptance notes). **H4, the draft object.** `repairs_repair_ticket` is offered in *This package*, labelled *Repair Ticket* from its draft and marked as a draft. It is choosable from both inputs. ## Tests New pins: - `ObjectPicker.test.tsx` checks what the picker offers and in which order. It checks that the served `isSystem` decides membership in *System*: `sys_file` is offered up front and `audit_trail` is collapsed. It checks that opening *System* or searching reaches those objects, and that the draft is labelled and marked. It checks that typing commits nothing, that Enter and blur commit what was typed (an expression included), that Escape abandons, and that the catalog is read once. - `ObjectFieldInspector.objectPicker-11783.test.tsx` runs the real inspector with the real `useObjectFields`. Typing the name makes 0 object reads, and the commit makes 1, for that name. `sys_user` is choosable. - `FlowReferenceField.objectPicker-11783.test.tsx` runs the real `FlowNodeInspector`, with the real `useFlowScope` and `useObjectFields`. Typing makes 0 object reads, and the commit reads only the committed name. An expression value is accepted as before (a control). The field has no `crm_lead` placeholder. Updated: `ObjectFieldInspector.draft-locale.test.tsx` reads the localized draft marker on the picker. `ObjectFieldInspector.test.tsx`'s summary case commits by leaving the input. Gates, run locally on HEAD `8b94ce9`: - every test file importing a touched module, 102 files, run from the root with `pnpm exec vitest run`: exit 0, `Test Files 102 passed (102)`, `Tests 1206 passed | 1 skipped (1207)` - `pnpm --filter @object-ui/app-shell type-check`, which runs `tsc --noEmit && tsc -p tsconfig.test.json` (`--listFilesOnly` confirms the new test files are in that project): exit 0 - `check:control-bytes` · `check:new-line-citations` · `check:changeset-claims` · `check:pending-changeset-literals`: exit 0 - the app-shell locale checks `check:i18n-designer-parity` and `check:i18n-dead-keys` (designer-table leg), plus `check:i18n-keys`: exit 0 - added for this diff (new `vi.mock` doubles, a new source file, a new `react-router-dom` import): `check:vi-mock-specifiers` · `check:vi-mock-inherit` · `check:vi-mock-override-shape` · `check:unreferenced-sources` · `check:phantom-deps` · `check:test-path-roots` · `check-changeset-presence.mjs`: exit 0 - `eslint --no-inline-config` over the changed files: 0 errors. `ObjectPicker.tsx` has no warnings, and `FlowReferenceField.tsx` carries the same 10 warnings it had at `16cda0b`. The repo-wide `pnpm lint` and the full test farm are left to CI. **Reverse check.** This ran on the committed fix, `3389363`. The three source files were reverted to `16cda0b`, the two inspector-level pin files were run, and the files were restored from `HEAD`. The restore was proven by blob hashes equal to `HEAD` and an empty `git diff HEAD`. Predicted and observed, 4 red and 2 green: - the Lookup typing pin received 21 reads (20 prefixes and 1 full name) where 0 were expected; - the flow typing pin received 42 reads (40 prefixes and 2 full names); - the `sys_user` pin and the placeholder pin went red; - the two controls, the saved Lookup reference and the accepted expression value, stayed green. ## Acceptance notes Noted here, not filed: - **Other flow placeholders.** `contract` (the four record nodes and the legacy action node), `contracts` (sweep object) and `crm_account` (screen object form) name objects the showcase does not serve. `showcase_task` (map item object) exists only in the showcase. The view inspector's `engine.inspector.view.objectPlaceholder` reads `e.g. crm_lead`. This is the same class as `crm_lead`, but outside this claim's lines. - **Other object pickers.** The hook, action, page-block, view-variant, dataset and app-nav pickers still list `isSystem` objects first-class and ungrouped. The action's and page block's empty-list text-field fallback commits per keystroke, which reaches `useObjectFields`. - **Draft objects in the rail.** Studio's Data rail lists a draft-only object by bare name, without its draft's label. - **Platform objects unmarked.** `sys_inbox_message`, `sys_file` and `sys_upload_session` are served `isSystem: false`. Whether they should carry the mark is the platform's decision. - **Payload.** The object list read is a full-body list, about 670 KB on the showcase. The inputs read a list of the same size before this change. --- _Generated by [Claude Code](https://claude.ai/code/session_01DrKzdPdyLLBW3qpZ4vtk7z)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1e1f09e commit 57dbe34

11 files changed

Lines changed: 1011 additions & 127 deletions

‎.changeset/11783-object-picker.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
Studio's object-name inputs now share one object picker that offers the objects it already holds, grouped and labelled, and resolves a name only once it is chosen (objectui#11783).
6+
7+
A Lookup field's *Related object* and a summary field's child object, and every flow input that names an object (the trigger's *Object*, a record node's object, a screen's object form, a map node's item object, a time-relative sweep's object), used to save the draft on every keystroke. Each save made Studio look up the typed prefix, so typing `repairs_repair_ticket` into the trigger's *Object* sent about forty `GET /api/v1/meta/object/PREFIX` requests, each answered 404. The suggestions listed every served object by bare name, the platform's internal objects first, and a draft object showed no label.
8+
9+
- **One lookup, for the chosen name.** Typing stays in the input. The name is saved when the author chooses a suggestion, presses Enter or leaves the input, and only that name is looked up. Escape abandons the typing.
10+
- **Any value is still accepted.** Enter and leaving the input keep exactly what was typed, matched or not, so a flow value that is an expression still saves as before, and the server judges an unknown object name as it always did.
11+
- **Grouped and labelled suggestions.** The objects of the package being edited come first, then the other objects, each shown with its label and its name. The platform's own objects, the ones it marks `isSystem`, sit in a collapsed *System* group. A search still reaches them, so a Lookup to `sys_user` stays one search away. A draft object of the package is offered with the label its draft declares and marked as a draft.
12+
- The trigger's *Object* input no longer suggests `crm_lead`, an object most apps do not have.
13+
14+
Nothing is added to the package's entry: no export, prop, type member or language-pack key.

‎packages/app-shell/src/views/metadata-admin/i18n.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1142,6 +1142,13 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
11421142
'engine.inspector.reference.actionsOf': 'Actions of {connector}.',
11431143
'engine.inspector.reference.chooseConnector': 'Choose a Connector above to list its actions.',
11441144
'engine.inspector.reference.declarative': 'declarative',
1145+
// objectui#11783 — the shared object picker (inspectors/ObjectPicker.tsx).
1146+
'engine.inspector.objectPicker.groupPackage': 'This package',
1147+
'engine.inspector.objectPicker.groupOther': 'Other objects',
1148+
'engine.inspector.objectPicker.groupSystem': 'System objects',
1149+
'engine.inspector.objectPicker.showSystem': 'Show system objects ({count})',
1150+
'engine.inspector.objectPicker.hideSystem': 'Hide system objects',
1151+
'engine.inspector.objectPicker.noMatch': 'No object matches. Press Enter to keep what you typed.',
11451152
// Add affordances (used by OutlineStrip + custom previews)
11461153
'engine.inspector.add.widget': 'Add widget',
11471154
'engine.inspector.add.block': 'Add block',
@@ -4196,6 +4203,13 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
41964203
'engine.inspector.reference.actionsOf': '{connector} 的动作。',
41974204
'engine.inspector.reference.chooseConnector': '请先在上方选择连接器,才能列出其动作。',
41984205
'engine.inspector.reference.declarative': '声明式',
4206+
// objectui#11783 — the shared object picker (inspectors/ObjectPicker.tsx).
4207+
'engine.inspector.objectPicker.groupPackage': '本软件包',
4208+
'engine.inspector.objectPicker.groupOther': '其他对象',
4209+
'engine.inspector.objectPicker.groupSystem': '系统对象',
4210+
'engine.inspector.objectPicker.showSystem': '显示系统对象({count})',
4211+
'engine.inspector.objectPicker.hideSystem': '隐藏系统对象',
4212+
'engine.inspector.objectPicker.noMatch': '没有匹配的对象。按 Enter 保留输入的内容。',
41994213
// Add affordances
42004214
'engine.inspector.add.widget': '添加组件',
42014215
'engine.inspector.add.block': '添加区块',
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#11783 — the flow trigger's *Object* resolves the name the author
5+
* COMMITS, never each prefix they type on the way to it.
6+
*
7+
* The whole inspector, from a real start node: `FlowNodeInspector` asks
8+
* `useFlowScope` for the trigger's field vocabulary and the entry-condition
9+
* builder reads the same object, both through the real `useObjectFields`,
10+
* which reads it with `client.get('object', NAME)` — the `GET /meta/object/NAME`
11+
* the card measured about twice per keystroke. Nothing is stubbed but the
12+
* engine palette hook and the metadata client, so the count read here is the
13+
* count of that request.
14+
*
15+
* The client double also answers the published list the old input read, so
16+
* the reverse check runs the old input to its measurement.
17+
*/
18+
19+
import * as React from 'react';
20+
import { describe, it, expect, vi, afterEach, beforeEach, beforeAll } from 'vitest';
21+
import { render, screen, cleanup, waitFor } from '@testing-library/react';
22+
import userEvent from '@testing-library/user-event';
23+
24+
vi.mock('../previews/useFlowNodePalette', () => ({
25+
useActionConfigSchemas: () => ({}),
26+
useFlowNodePalette: () => [],
27+
}));
28+
29+
const h = vi.hoisted(() => {
30+
const NAME = 'repairs_repair_ticket';
31+
const CATALOG: Array<Record<string, unknown>> = [
32+
{ name: NAME, label: 'Repair Ticket', _packageId: 'com.example.repairs' },
33+
{ name: 'sys_user', label: 'User', isSystem: true, _packageId: 'com.objectstack.plugin-auth' },
34+
];
35+
const get = vi.fn(async (_type: string, name: string) =>
36+
name === NAME ? { name, fields: { status: { type: 'text', label: 'Status' } } } : null,
37+
);
38+
const previewList = vi.fn(async (_type: string) => CATALOG);
39+
const client = {
40+
get,
41+
withPreviewDrafts: vi.fn((_on: boolean) => ({ list: previewList })),
42+
list: vi.fn(async (type: string) => (type === 'object' ? CATALOG : [])),
43+
listDrafts: vi.fn(async () => [] as unknown[]),
44+
};
45+
return { NAME, get, client };
46+
});
47+
48+
vi.mock('../useMetadata', () => ({ useMetadataClient: () => h.client }));
49+
50+
import { FlowNodeInspector } from './FlowNodeInspector';
51+
52+
afterEach(cleanup);
53+
beforeEach(() => h.get.mockClear());
54+
beforeAll(() => {
55+
for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) {
56+
if (!Element.prototype[m]) {
57+
// @ts-expect-error test shim
58+
Element.prototype[m] = m === 'hasPointerCapture' ? () => false : () => {};
59+
}
60+
}
61+
});
62+
63+
type Node = { id: string; type: string; label?: string; config?: Record<string, unknown> };
64+
65+
/** The inspector over a record-triggered flow that takes its own patches. */
66+
function Harness({ onPatch }: { onPatch: (p: Record<string, unknown>) => void }) {
67+
const [draft, setDraft] = React.useState<Record<string, unknown>>({
68+
nodes: [{ id: 'start', type: 'start', label: 'Start', config: { triggerType: 'record-after-create' } }],
69+
edges: [],
70+
});
71+
return (
72+
<FlowNodeInspector
73+
type="flow"
74+
name="repairs_ticket_flow"
75+
draft={draft}
76+
selection={{ kind: 'node', id: 'start' }}
77+
onPatch={(p) => {
78+
onPatch(p);
79+
setDraft((d) => ({ ...d, ...p }));
80+
}}
81+
onClearSelection={vi.fn()}
82+
readOnly={false}
83+
locale="en-US"
84+
/>
85+
);
86+
}
87+
88+
/**
89+
* The input under the trigger's visible *Object* label, located by the label's
90+
* container so the reverse check finds the old datalist input too.
91+
*/
92+
function triggerObjectInput(): HTMLInputElement {
93+
return screen.getByText('Object', { selector: 'label' }).parentElement!.querySelector('input')!;
94+
}
95+
96+
/** The trigger object each patch wrote, in order. */
97+
function committedObjects(onPatch: ReturnType<typeof vi.fn>): unknown[] {
98+
return onPatch.mock.calls
99+
.map(([p]) => (p as { nodes?: Node[] }).nodes?.find((n) => n.id === 'start')?.config?.objectName)
100+
.filter((v) => v !== undefined);
101+
}
102+
103+
const objectGets = () => h.get.mock.calls.filter(([type]) => type === 'object').map(([, name]) => name);
104+
105+
describe('Flow trigger Object — resolves the committed name only (objectui#11783)', () => {
106+
it('typing a name issues no object read; committing it reads that name only', async () => {
107+
const onPatch = vi.fn();
108+
render(<Harness onPatch={onPatch} />);
109+
const input = triggerObjectInput();
110+
111+
await userEvent.click(input);
112+
await userEvent.type(input, h.NAME);
113+
expect(objectGets()).toEqual([]);
114+
expect(committedObjects(onPatch)).toEqual([]);
115+
116+
await userEvent.keyboard('{Enter}');
117+
await waitFor(() => expect(objectGets().length).toBeGreaterThan(0));
118+
// Each reader of the trigger object resolves it once — for the committed name.
119+
expect(new Set(objectGets())).toEqual(new Set([h.NAME]));
120+
expect(committedObjects(onPatch)).toEqual([h.NAME]);
121+
});
122+
123+
// Control: true before this card and after it — the accepted values did not change.
124+
it('still accepts a value that names no object (an expression), exactly as typed', async () => {
125+
const onPatch = vi.fn();
126+
render(<Harness onPatch={onPatch} />);
127+
const input = triggerObjectInput();
128+
await userEvent.click(input);
129+
// `{{` is user-event's escape for one literal `{`.
130+
await userEvent.type(input, '{{{{trigger.object}}');
131+
await userEvent.keyboard('{Enter}');
132+
expect(committedObjects(onPatch).at(-1)).toBe('{{trigger.object}}');
133+
});
134+
135+
it('names no example object of its own (no `crm_lead` placeholder)', () => {
136+
render(<Harness onPatch={vi.fn()} />);
137+
expect(triggerObjectInput().getAttribute('placeholder') ?? '').not.toContain('crm_lead');
138+
});
139+
});

‎packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
* a strict select over a closed enum, `manager` is auto-resolved (disabled
2121
* cell), and `queue` warns that the runtime doesn't resolve it.
2222
*
23+
* An `object` reference renders the shared {@link ObjectPicker} instead
24+
* (objectui#11783): still suggest-but-allow-anything, but the value is committed
25+
* once, on a choice, Enter or blur, rather than on every keystroke.
26+
*
2327
* Two layers:
2428
* • {@link ReferenceCombobox} — the bare control, given an already-resolved
2529
* concrete kind. Reused by the `objectList` repeater for per-row reference
@@ -48,6 +52,7 @@ import { useMetadataClient } from '../useMetadata.js';
4852
import { useObjectFields } from '../previews/useObjectFields.js';
4953
import { t, tFormat, useMetadataLocale, type SupportedLocale } from '../i18n.js';
5054
import { flagUnknownValue, RequiredMarker } from './_shared.js';
55+
import { ObjectPicker } from './ObjectPicker.js';
5156

5257
/** Context the reference picker needs to resolve dynamic option sources. */
5358
export interface FlowReferenceContext {
@@ -76,9 +81,10 @@ interface Option {
7681
* so the picker queried `GET /api/v1/meta/user` (which lists no `sys_user`
7782
* rows), came back empty, and silently degraded to a free-text id box
7883
* (objectstack-ai/objectstack#3508). They live in {@link KIND_TO_RECORD_LOOKUP} instead.
84+
* `object` is absent too: it renders the shared {@link ObjectPicker}, which
85+
* reads its own catalog (objectui#11783).
7986
*/
8087
const KIND_TO_META_TYPE: Partial<Record<ReferenceKind, string>> = {
81-
object: 'object',
8288
flow: 'flow',
8389
connector: 'connector',
8490
'email-template': 'email_template',
@@ -626,6 +632,11 @@ export interface ReferenceComboboxProps {
626632
context?: FlowReferenceContext;
627633
/** Show the "Fields of X." / unresolved hint under the control (default true). */
628634
showHint?: boolean;
635+
/**
636+
* The control's accessible name, for a caller that draws the visible label
637+
* itself (the inspector field wrapper below). Read by the object picker.
638+
*/
639+
ariaLabel?: string;
629640
}
630641

631642
/**
@@ -637,7 +648,7 @@ export interface ReferenceComboboxProps {
637648
* (objectstack-ai/objectstack#3508). Hooks are called unconditionally (kind-gated args) so the
638649
* component is safe to use in a repeater where the kind changes per row.
639650
*/
640-
export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect, disabled, placeholder, context, showHint = true }: ReferenceComboboxProps) {
651+
export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect, disabled, placeholder, context, showHint = true, ariaLabel }: ReferenceComboboxProps) {
641652
const listId = React.useId();
642653
// No `locale` prop here: the out-of-enum tier flag reads the designer's
643654
// locale itself, as the sibling `FlowObjectListField` does (objectui#10448).
@@ -659,7 +670,7 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect,
659670
// instances, ADR-0096), NOT the generic declared-metadata list — see the hook.
660671
const { options: connectorListOptions } = useConnectorListOptions(kind === 'connector', locale);
661672

662-
// Flat metadata-list kinds (object / flow / role / user / team / …).
673+
// Flat metadata-list kinds (flow / connector / email-template).
663674
const listType =
664675
kind && kind !== 'object-field' && kind !== 'node' && kind !== 'connector-action' && kind !== 'connector'
665676
? KIND_TO_META_TYPE[kind]
@@ -790,6 +801,24 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect,
790801
);
791802
}
792803

804+
// An object name (objectui#11783) → the shared picker: the catalog grouped
805+
// and labelled, the platform's own objects collapsed under System, and the
806+
// value committed ONCE — on a choice, Enter or blur — so nothing that
807+
// resolves the trigger's object refetches per keystroke. Text that names no
808+
// object (an expression, a template) is still committed as typed.
809+
if (kind === 'object') {
810+
return (
811+
<ObjectPicker
812+
ariaLabel={ariaLabel}
813+
value={value != null ? String(value) : ''}
814+
onCommit={commitSelection}
815+
disabled={disabled}
816+
placeholder={placeholder}
817+
locale={locale}
818+
/>
819+
);
820+
}
821+
793822
return (
794823
<div className="w-full space-y-1">
795824
<Input
@@ -859,6 +888,7 @@ export function FlowReferenceField({ field, value, onCommit, disabled, context,
859888
disabled={disabled}
860889
placeholder={field.placeholder}
861890
context={context}
891+
ariaLabel={field.label}
862892
/>
863893
</div>
864894
);

‎packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.draft-locale.test.tsx‎

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,19 @@
33
import * as React from 'react';
44
import { describe, it, expect, vi, afterEach } from 'vitest';
55
import { render, cleanup, waitFor } from '@testing-library/react';
6+
import userEvent from '@testing-library/user-event';
67

78
// A draft object AND a published one, so the assertions can tell the two
8-
// label shapes apart. The sibling test file stubs both surfaces empty; this
9-
// one needs a draft to exist at all.
9+
// apart. The picker reads the draft-overlaid list (objectui#11783), where a
10+
// draft is served with its `_draft` mark; this one has no label of its own.
1011
vi.mock('../useMetadata', () => ({
1112
useMetadataClient: () => ({
12-
list: vi.fn().mockResolvedValue([{ name: 'crm_account', label: 'Account' }]),
13-
listDrafts: vi.fn().mockResolvedValue([{ name: 'crm_quote' }]),
13+
withPreviewDrafts: () => ({
14+
list: vi.fn().mockResolvedValue([
15+
{ name: 'crm_account', label: 'Account' },
16+
{ name: 'crm_quote', _draft: true },
17+
]),
18+
}),
1419
}),
1520
}));
1621

@@ -22,7 +27,7 @@ import { ObjectFieldInspector } from './ObjectFieldInspector';
2227
// The inspector's `locale` prop is the closed `'en-US' | 'zh-CN'` union, not a
2328
// bare string — the loop below iterates exactly those two, so annotating the
2429
// helper keeps the array's element type from widening back to `string`.
25-
import type { SupportedLocale } from '../i18n';
30+
import { t, type SupportedLocale } from '../i18n';
2631

2732
afterEach(cleanup);
2833

@@ -43,46 +48,48 @@ function renderLookup(locale: SupportedLocale) {
4348
);
4449
}
4550

46-
async function draftOptionLabel(container: HTMLElement): Promise<string> {
47-
const opt = await waitFor(() => {
48-
const found = [...container.querySelectorAll('option')].find(
49-
(o) => o.getAttribute('value') === 'crm_quote',
50-
);
51+
/** Open the Related-object picker (its label read in `locale`) and wait for the named option. */
52+
async function optionFor(container: HTMLElement, name: string, locale: SupportedLocale): Promise<HTMLElement> {
53+
const text = t('designer.field.relatedObject', locale);
54+
const label = [...container.querySelectorAll('label')].find((l) => l.textContent === text)!;
55+
await userEvent.click(label.parentElement!.querySelector('input')!);
56+
return waitFor(() => {
57+
const found = container.querySelector(`[data-object-name="${name}"]`);
5158
expect(found).toBeTruthy();
52-
return found!;
59+
return found as HTMLElement;
5360
});
54-
return opt.textContent ?? '';
61+
}
62+
63+
async function draftMarker(container: HTMLElement, locale: SupportedLocale): Promise<string> {
64+
const opt = await optionFor(container, 'crm_quote', locale);
65+
return opt.querySelector('[data-draft-marker]')?.textContent ?? '';
5566
}
5667

5768
const CJK = /[一-鿿]/;
5869

59-
describe('ObjectFieldInspector — draft-object suffix is localized', () => {
60-
it('renders no CJK in the draft label under an English locale', async () => {
70+
describe('ObjectFieldInspector — draft-object marker is localized', () => {
71+
it('renders no CJK in the draft marker under an English locale', async () => {
6172
const { container } = renderLookup('en-US');
62-
const label = await draftOptionLabel(container);
73+
const marker = await draftMarker(container, 'en-US');
6374

64-
expect(label).toBe('crm_quote (draft)');
75+
expect(marker).toBe('(draft)');
6576
// The regression this pins: the suffix used to be a bare `(草稿)`
6677
// literal, so an English user saw `crm_quote (草稿)`.
67-
expect(label).not.toMatch(CJK);
78+
expect(marker).not.toMatch(CJK);
6879
});
6980

70-
it('still renders the Chinese suffix under a zh locale', async () => {
81+
it('still renders the Chinese marker under a zh locale', async () => {
7182
const { container } = renderLookup('zh-CN');
72-
expect(await draftOptionLabel(container)).toBe('crm_quote (草稿)');
83+
expect(await draftMarker(container, 'zh-CN')).toBe('(草稿)');
7384
});
7485

75-
it('leaves published objects unsuffixed in both locales', async () => {
86+
it('leaves published objects unmarked in both locales', async () => {
7687
for (const locale of ['en-US', 'zh-CN'] as const) {
7788
const { container, unmount } = renderLookup(locale);
78-
const opt = await waitFor(() => {
79-
const found = [...container.querySelectorAll('option')].find(
80-
(o) => o.getAttribute('value') === 'crm_account',
81-
);
82-
expect(found).toBeTruthy();
83-
return found!;
84-
});
85-
expect(opt.textContent).toBe('Account (crm_account)');
89+
const opt = await optionFor(container, 'crm_account', locale);
90+
expect(opt.querySelector('[data-draft-marker]')).toBeNull();
91+
expect(opt).toHaveTextContent('Account');
92+
expect(opt).toHaveTextContent('crm_account');
8693
unmount();
8794
}
8895
});

0 commit comments

Comments
 (0)