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
14 changes: 14 additions & 0 deletions .changeset/11783-object-picker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/app-shell': patch
---

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).

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.

- **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.
- **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.
- **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.
- The trigger's *Object* input no longer suggests `crm_lead`, an object most apps do not have.

Nothing is added to the package's entry: no export, prop, type member or language-pack key.
14 changes: 14 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1137,6 +1137,13 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.inspector.reference.actionsOf': 'Actions of {connector}.',
'engine.inspector.reference.chooseConnector': 'Choose a Connector above to list its actions.',
'engine.inspector.reference.declarative': 'declarative',
// objectui#11783 — the shared object picker (inspectors/ObjectPicker.tsx).
'engine.inspector.objectPicker.groupPackage': 'This package',
'engine.inspector.objectPicker.groupOther': 'Other objects',
'engine.inspector.objectPicker.groupSystem': 'System objects',
'engine.inspector.objectPicker.showSystem': 'Show system objects ({count})',
'engine.inspector.objectPicker.hideSystem': 'Hide system objects',
'engine.inspector.objectPicker.noMatch': 'No object matches. Press Enter to keep what you typed.',
// Add affordances (used by OutlineStrip + custom previews)
'engine.inspector.add.widget': 'Add widget',
'engine.inspector.add.block': 'Add block',
Expand Down Expand Up @@ -4188,6 +4195,13 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.inspector.reference.actionsOf': '{connector} 的动作。',
'engine.inspector.reference.chooseConnector': '请先在上方选择连接器,才能列出其动作。',
'engine.inspector.reference.declarative': '声明式',
// objectui#11783 — the shared object picker (inspectors/ObjectPicker.tsx).
'engine.inspector.objectPicker.groupPackage': '本软件包',
'engine.inspector.objectPicker.groupOther': '其他对象',
'engine.inspector.objectPicker.groupSystem': '系统对象',
'engine.inspector.objectPicker.showSystem': '显示系统对象({count})',
'engine.inspector.objectPicker.hideSystem': '隐藏系统对象',
'engine.inspector.objectPicker.noMatch': '没有匹配的对象。按 Enter 保留输入的内容。',
// Add affordances
'engine.inspector.add.widget': '添加组件',
'engine.inspector.add.block': '添加区块',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11783 — the flow trigger's *Object* resolves the name the author
* COMMITS, never each prefix they type on the way to it.
*
* The whole inspector, from a real start node: `FlowNodeInspector` asks
* `useFlowScope` for the trigger's field vocabulary and the entry-condition
* builder reads the same object, both through the real `useObjectFields`,
* which reads it with `client.get('object', NAME)` — the `GET /meta/object/NAME`
* the card measured about twice per keystroke. Nothing is stubbed but the
* engine palette hook and the metadata client, so the count read here is the
* count of that request.
*
* The client double also answers the published list the old input read, so
* the reverse check runs the old input to its measurement.
*/

import * as React from 'react';
import { describe, it, expect, vi, afterEach, beforeEach, beforeAll } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

vi.mock('../previews/useFlowNodePalette', () => ({
useActionConfigSchemas: () => ({}),
useFlowNodePalette: () => [],
}));

const h = vi.hoisted(() => {
const NAME = 'repairs_repair_ticket';
const CATALOG: Array<Record<string, unknown>> = [
{ name: NAME, label: 'Repair Ticket', _packageId: 'com.example.repairs' },
{ name: 'sys_user', label: 'User', isSystem: true, _packageId: 'com.objectstack.plugin-auth' },
];
const get = vi.fn(async (_type: string, name: string) =>
name === NAME ? { name, fields: { status: { type: 'text', label: 'Status' } } } : null,
);
const previewList = vi.fn(async (_type: string) => CATALOG);
const client = {
get,
withPreviewDrafts: vi.fn((_on: boolean) => ({ list: previewList })),
list: vi.fn(async (type: string) => (type === 'object' ? CATALOG : [])),
listDrafts: vi.fn(async () => [] as unknown[]),
};
return { NAME, get, client };
});

vi.mock('../useMetadata', () => ({ useMetadataClient: () => h.client }));

import { FlowNodeInspector } from './FlowNodeInspector';

afterEach(cleanup);
beforeEach(() => h.get.mockClear());
beforeAll(() => {
for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) {
if (!Element.prototype[m]) {
// @ts-expect-error test shim
Element.prototype[m] = m === 'hasPointerCapture' ? () => false : () => {};
}
}
});

type Node = { id: string; type: string; label?: string; config?: Record<string, unknown> };

/** The inspector over a record-triggered flow that takes its own patches. */
function Harness({ onPatch }: { onPatch: (p: Record<string, unknown>) => void }) {
const [draft, setDraft] = React.useState<Record<string, unknown>>({
nodes: [{ id: 'start', type: 'start', label: 'Start', config: { triggerType: 'record-after-create' } }],
edges: [],
});
return (
<FlowNodeInspector
type="flow"
name="repairs_ticket_flow"
draft={draft}
selection={{ kind: 'node', id: 'start' }}
onPatch={(p) => {
onPatch(p);
setDraft((d) => ({ ...d, ...p }));
}}
onClearSelection={vi.fn()}
readOnly={false}
locale="en-US"
/>
);
}

/**
* The input under the trigger's visible *Object* label, located by the label's
* container so the reverse check finds the old datalist input too.
*/
function triggerObjectInput(): HTMLInputElement {
return screen.getByText('Object', { selector: 'label' }).parentElement!.querySelector('input')!;
}

/** The trigger object each patch wrote, in order. */
function committedObjects(onPatch: ReturnType<typeof vi.fn>): unknown[] {
return onPatch.mock.calls
.map(([p]) => (p as { nodes?: Node[] }).nodes?.find((n) => n.id === 'start')?.config?.objectName)
.filter((v) => v !== undefined);
}

const objectGets = () => h.get.mock.calls.filter(([type]) => type === 'object').map(([, name]) => name);

describe('Flow trigger Object — resolves the committed name only (objectui#11783)', () => {
it('typing a name issues no object read; committing it reads that name only', async () => {
const onPatch = vi.fn();
render(<Harness onPatch={onPatch} />);
const input = triggerObjectInput();

await userEvent.click(input);
await userEvent.type(input, h.NAME);
expect(objectGets()).toEqual([]);
expect(committedObjects(onPatch)).toEqual([]);

await userEvent.keyboard('{Enter}');
await waitFor(() => expect(objectGets().length).toBeGreaterThan(0));
// Each reader of the trigger object resolves it once — for the committed name.
expect(new Set(objectGets())).toEqual(new Set([h.NAME]));
expect(committedObjects(onPatch)).toEqual([h.NAME]);
});

// Control: true before this card and after it — the accepted values did not change.
it('still accepts a value that names no object (an expression), exactly as typed', async () => {
const onPatch = vi.fn();
render(<Harness onPatch={onPatch} />);
const input = triggerObjectInput();
await userEvent.click(input);
// `{{` is user-event's escape for one literal `{`.
await userEvent.type(input, '{{{{trigger.object}}');
await userEvent.keyboard('{Enter}');
expect(committedObjects(onPatch).at(-1)).toBe('{{trigger.object}}');
});

it('names no example object of its own (no `crm_lead` placeholder)', () => {
render(<Harness onPatch={vi.fn()} />);
expect(triggerObjectInput().getAttribute('placeholder') ?? '').not.toContain('crm_lead');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
* a strict select over a closed enum, `manager` is auto-resolved (disabled
* cell), and `queue` warns that the runtime doesn't resolve it.
*
* An `object` reference renders the shared {@link ObjectPicker} instead
* (objectui#11783): still suggest-but-allow-anything, but the value is committed
* once, on a choice, Enter or blur, rather than on every keystroke.
*
* Two layers:
* • {@link ReferenceCombobox} — the bare control, given an already-resolved
* concrete kind. Reused by the `objectList` repeater for per-row reference
Expand Down Expand Up @@ -48,6 +52,7 @@ import { useMetadataClient } from '../useMetadata.js';
import { useObjectFields } from '../previews/useObjectFields.js';
import { t, tFormat, useMetadataLocale, type SupportedLocale } from '../i18n.js';
import { flagUnknownValue, RequiredMarker } from './_shared.js';
import { ObjectPicker } from './ObjectPicker.js';

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

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

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

// An object name (objectui#11783) → the shared picker: the catalog grouped
// and labelled, the platform's own objects collapsed under System, and the
// value committed ONCE — on a choice, Enter or blur — so nothing that
// resolves the trigger's object refetches per keystroke. Text that names no
// object (an expression, a template) is still committed as typed.
if (kind === 'object') {
return (
<ObjectPicker
ariaLabel={ariaLabel}
value={value != null ? String(value) : ''}
onCommit={commitSelection}
disabled={disabled}
placeholder={placeholder}
locale={locale}
/>
);
}

return (
<div className="w-full space-y-1">
<Input
Expand Down Expand Up @@ -859,6 +888,7 @@ export function FlowReferenceField({ field, value, onCommit, disabled, context,
disabled={disabled}
placeholder={field.placeholder}
context={context}
ariaLabel={field.label}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@
import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

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

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

afterEach(cleanup);

Expand All @@ -43,46 +48,48 @@ function renderLookup(locale: SupportedLocale) {
);
}

async function draftOptionLabel(container: HTMLElement): Promise<string> {
const opt = await waitFor(() => {
const found = [...container.querySelectorAll('option')].find(
(o) => o.getAttribute('value') === 'crm_quote',
);
/** Open the Related-object picker (its label read in `locale`) and wait for the named option. */
async function optionFor(container: HTMLElement, name: string, locale: SupportedLocale): Promise<HTMLElement> {
const text = t('designer.field.relatedObject', locale);
const label = [...container.querySelectorAll('label')].find((l) => l.textContent === text)!;
await userEvent.click(label.parentElement!.querySelector('input')!);
return waitFor(() => {
const found = container.querySelector(`[data-object-name="${name}"]`);
expect(found).toBeTruthy();
return found!;
return found as HTMLElement;
});
return opt.textContent ?? '';
}

async function draftMarker(container: HTMLElement, locale: SupportedLocale): Promise<string> {
const opt = await optionFor(container, 'crm_quote', locale);
return opt.querySelector('[data-draft-marker]')?.textContent ?? '';
}

const CJK = /[一-鿿]/;

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

expect(label).toBe('crm_quote (draft)');
expect(marker).toBe('(draft)');
// The regression this pins: the suffix used to be a bare `(草稿)`
// literal, so an English user saw `crm_quote (草稿)`.
expect(label).not.toMatch(CJK);
expect(marker).not.toMatch(CJK);
});

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

it('leaves published objects unsuffixed in both locales', async () => {
it('leaves published objects unmarked in both locales', async () => {
for (const locale of ['en-US', 'zh-CN'] as const) {
const { container, unmount } = renderLookup(locale);
const opt = await waitFor(() => {
const found = [...container.querySelectorAll('option')].find(
(o) => o.getAttribute('value') === 'crm_account',
);
expect(found).toBeTruthy();
return found!;
});
expect(opt.textContent).toBe('Account (crm_account)');
const opt = await optionFor(container, 'crm_account', locale);
expect(opt.querySelector('[data-draft-marker]')).toBeNull();
expect(opt).toHaveTextContent('Account');
expect(opt).toHaveTextContent('crm_account');
unmount();
}
});
Expand Down
Loading
Loading