diff --git a/.changeset/11783-object-picker.md b/.changeset/11783-object-picker.md new file mode 100644 index 0000000000..554622b12a --- /dev/null +++ b/.changeset/11783-object-picker.md @@ -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. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 2f453c3e97..aa38f166f0 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1137,6 +1137,13 @@ const ENGINE_STRINGS_EN: Record = { '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', @@ -4188,6 +4195,13 @@ const ENGINE_STRINGS_ZH: Record = { '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': '添加区块', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.objectPicker-11783.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.objectPicker-11783.test.tsx new file mode 100644 index 0000000000..4da2ced3f3 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.objectPicker-11783.test.tsx @@ -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> = [ + { 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 }; + +/** The inspector over a record-triggered flow that takes its own patches. */ +function Harness({ onPatch }: { onPatch: (p: Record) => void }) { + const [draft, setDraft] = React.useState>({ + nodes: [{ id: 'start', type: 'start', label: 'Start', config: { triggerType: 'record-after-create' } }], + edges: [], + }); + return ( + { + 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): 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(); + 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(); + 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(); + expect(triggerObjectInput().getAttribute('placeholder') ?? '').not.toContain('crm_lead'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx index 836d990daf..a32bf8ac6c 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx @@ -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 @@ -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 { @@ -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> = { - object: 'object', flow: 'flow', connector: 'connector', 'email-template': 'email_template', @@ -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; } /** @@ -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). @@ -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] @@ -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 ( + + ); + } + return (
); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.draft-locale.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.draft-locale.test.tsx index 15be36413d..40ded7cf5f 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.draft-locale.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.draft-locale.test.tsx @@ -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 }, + ]), + }), }), })); @@ -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); @@ -43,46 +48,48 @@ function renderLookup(locale: SupportedLocale) { ); } -async function draftOptionLabel(container: HTMLElement): Promise { - 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 { + 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 { + 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(); } }); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.objectPicker-11783.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.objectPicker-11783.test.tsx new file mode 100644 index 0000000000..ed81c073ff --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.objectPicker-11783.test.tsx @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11783 — the Lookup's *Related object* resolves the name the author + * COMMITS, never each prefix they type on the way to it. + * + * The chain is the real one: the inspector, its `LookupConfigFields` and the + * real `useObjectFields`, which reads the related object with + * `client.get('object', NAME)` — the `GET /meta/object/NAME` the card measured + * once per keystroke. Only the metadata client is a double, so the count read + * here is the count of that request. + * + * The double also answers the reads the inspector made before this card (the + * published list and the draft headers), so the reverse check runs the old + * input to its measurement rather than to a missing method. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const h = vi.hoisted(() => { + const NAME = 'repairs_repair_ticket'; + const CATALOG: Array> = [ + { name: NAME, label: 'Repair Ticket', _packageId: 'com.example.repairs', _draft: true }, + { 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: { subject: { type: 'text', label: 'Subject' } } } : 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.filter((o) => !o._draft) : [])), + listDrafts: vi.fn(async () => [] as unknown[]), + }; + return { NAME, get, client }; +}); + +vi.mock('../useMetadata', () => ({ useMetadataClient: () => h.client })); + +import { ObjectFieldInspector } from './ObjectFieldInspector'; +import { readFields } from '../previews/object-fields-io'; + +afterEach(cleanup); +beforeEach(() => h.get.mockClear()); + +/** The inspector over a draft that takes its own patches, as the editor host does. */ +function Harness({ onPatch }: { onPatch: (p: Record) => void }) { + const [draft, setDraft] = React.useState>({ + name: 'repairs_work_order', + fields: { technician: { type: 'lookup', label: 'Technician' } }, + }); + return ( + { + onPatch(p); + setDraft((d) => ({ ...d, ...p })); + }} + onClearSelection={vi.fn()} + onSelectionChange={vi.fn()} + readOnly={false} + locale="en-US" + /> + ); +} + +/** The `reference` a patch writes onto the lookup field, if it writes one. */ +function referenceIn(patch: Record): unknown { + return readFields(patch.fields).entries.find((e) => e.name === 'technician')?.def.reference; +} + +/** + * The input under the visible *Related object* label. Located by the label's + * container, not by accessible name, so the reverse check finds the old + * datalist input too (its label was never associated with it). + */ +function relatedObjectInput(): HTMLInputElement { + return screen.getByText('Related object', { selector: 'label' }).parentElement!.querySelector('input')!; +} + +const objectGets = () => h.get.mock.calls.filter(([type]) => type === 'object').map(([, name]) => name); + +describe('Lookup Related object — resolves the committed name only (objectui#11783)', () => { + it('typing a name issues no object read; committing it issues one, for that name', async () => { + const onPatch = vi.fn(); + render(); + const input = relatedObjectInput(); + + await userEvent.click(input); + await userEvent.type(input, h.NAME); + expect(objectGets()).toEqual([]); + expect(onPatch.mock.calls.filter(([p]) => referenceIn(p) !== undefined)).toEqual([]); + + await userEvent.keyboard('{Enter}'); + await waitFor(() => expect(objectGets()).toEqual([h.NAME])); + expect(onPatch.mock.calls.map(([p]) => referenceIn(p)).filter((r) => r !== undefined)).toEqual([h.NAME]); + }); + + // Control: true before this card and after it — the saved value did not change. + it('the committed name saves exactly as typed', async () => { + const onPatch = vi.fn(); + render(); + const input = relatedObjectInput(); + await userEvent.click(input); + await userEvent.type(input, h.NAME); + await userEvent.keyboard('{Enter}'); + const refs = onPatch.mock.calls.map(([p]) => referenceIn(p)).filter((r) => r !== undefined); + expect(refs.at(-1)).toBe(h.NAME); + }); + + it('a platform object stays choosable for a Lookup (sys_user, through a search)', async () => { + const onPatch = vi.fn(); + render(); + const input = relatedObjectInput(); + await userEvent.click(input); + await userEvent.type(input, 'sys_us'); + const option = await waitFor(() => { + const o = document.querySelector('[data-object-name="sys_user"]'); + expect(o).not.toBeNull(); + return o as HTMLElement; + }); + await userEvent.click(option); + const refs = onPatch.mock.calls.map(([p]) => referenceIn(p)).filter((r) => r !== undefined); + expect(refs).toEqual(['sys_user']); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx index f23104cc29..949829a7ef 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.test.tsx @@ -555,7 +555,11 @@ describe('ObjectFieldInspector — summary roll-up editor', () => { it('commits the child object into summaryOperations', () => { const { onPatch } = renderField({ total: { type: 'summary' } }, 'total'); - fireEvent.change(controlFor('Child object'), { target: { value: 'crm_order' } }); + // objectui#11783 — the object picker commits on blur or Enter, never on a + // keystroke, so the typed name is committed by leaving the input. + const input = controlFor('Child object'); + fireEvent.change(input, { target: { value: 'crm_order' } }); + fireEvent.blur(input); expect(onPatch.mock.calls.at(-1)![0].fields.total.summaryOperations).toEqual({ object: 'crm_order', }); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index b50a16ca66..88e9cb5777 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -58,6 +58,7 @@ import { import { Button, Input, Label, Badge } from '@object-ui/components'; import { Plus, X, ArrowUp, ArrowDown, Copy, AlertTriangle } from 'lucide-react'; import { InspectorComboField, type InspectorComboOption } from './InspectorComboField.js'; +import { ObjectPicker } from './ObjectPicker.js'; import { useObjectFields } from '../previews/useObjectFields.js'; import { readFields, @@ -74,7 +75,7 @@ import { } from '../previews/field-types.js'; import { CelPredicateField } from '../CelPredicateField.js'; import type { CelLintIssue } from '../celAuthoring.js'; -import { t, tFormat } from '../i18n.js'; +import { t, tFormat, type SupportedLocale } from '../i18n.js'; import { usePickerLoad, type LoadState } from '../loadState.js'; @@ -489,7 +490,6 @@ export function ObjectFieldInspector({ ? ((draft as any).fieldGroups as Array<{ key?: string; label?: string }>) : []; - const objectOptions = useObjectOptions(locale); // objectui#10202 — the picklists the runtime serves, for the "use picklist" // picker. A `LoadState`, so a failed read is never shown as "no picklists". const picklistRoster = usePicklistRoster(); @@ -1008,10 +1008,10 @@ export function ObjectFieldInspector({ patchDef({ reference: v || undefined })} disabled={readOnly} placeholder={tr('designer.field.objectNamePlaceholder')} + locale={locale} /> @@ -1385,43 +1384,6 @@ function TextareaField({ ); } -function ObjectPicker({ - label, - value, - options, - onCommit, - disabled, - placeholder, -}: { - label: string; - value: string; - options: Array<{ value: string; label: string }>; - onCommit: (v: string) => void; - disabled?: boolean; - placeholder?: string; -}) { - // List may be empty (still loading or no objects). Allow free-text fallback. - const listId = React.useId(); - return ( -
- - onCommit(e.target.value)} - disabled={disabled} - className="h-8 text-sm font-mono" - placeholder={placeholder ?? 'object_name'} - /> - - {options.map((o) => ( - - ))} - -
- ); -} - /** The i18n key naming each refusal, one static literal per reason. */ const MALFORMED_REASON_KEY: Record = { 'not-an-object': 'designer.field.optMalformed.notAnObject', @@ -1982,15 +1944,13 @@ const summaryValueToText = (v: unknown): string => (Array.isArray(v) ? v.join(', function SummaryConfigFields({ def, patchDef, - objectOptions, readOnly, locale, }: { def: Record; patchDef: (patch: Record) => void; - objectOptions: Array<{ value: string; label: string }>; readOnly?: boolean; - locale?: string; + locale?: SupportedLocale; }) { const tr = (key: string) => t(key, locale); const ops = readSummaryOps(def); @@ -2041,10 +2001,10 @@ function SummaryConfigFields({ patchOps({ object: v || undefined })} disabled={readOnly} placeholder={tr('designer.field.objectNamePlaceholder')} + locale={locale} /> ); } - -/* ─────────────── Hook: load object list for lookup picker ─────────────── */ - -function useObjectOptions(locale?: string): Array<{ value: string; label: string }> { - const client: MetadataClient = useMetadataClient(); - const [opts, setOpts] = React.useState>([]); - - React.useEffect(() => { - let cancelled = false; - Promise.all([ - client.list<{ name?: string; label?: string }>('object'), - // Draft objects are not yet published, so `list('object')` can't see - // them. Include them so a lookup can target a SIBLING object being - // designed in the same authoring pass (before the package's first - // publish) instead of forcing the author to type an API name blind. - client.listDrafts({ type: 'object' }).catch(() => [] as Array<{ name?: string }>), - ]) - .then(([published, drafts]) => { - if (cancelled) return; - const byName = new Map(); - for (const i of published ?? []) { - if (typeof i?.name === 'string' && i.name && !byName.has(i.name)) { - byName.set(i.name, { - value: i.name, - label: i.label ? `${i.label} (${i.name})` : i.name, - }); - } - } - for (const d of drafts ?? []) { - const name = (d as { name?: string }).name; - if (typeof name === 'string' && name && !byName.has(name)) { - byName.set(name, { - value: name, - label: `${name} ${t('engine.inspector.draftSuffix', locale)}`, - }); - } - } - setOpts([...byName.values()].sort((a, b) => a.value.localeCompare(b.value))); - }) - .catch(() => { - // Empty list — picker falls back to free-text. No banner needed. - }); - return () => { - cancelled = true; - }; - }, [client, locale]); - - return opts; -} diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectPicker.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectPicker.test.tsx new file mode 100644 index 0000000000..9f8d735deb --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectPicker.test.tsx @@ -0,0 +1,179 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11783 — the shared object picker: what it offers, in which order, + * and when it lets a name out. + * + * The catalog double answers the one read the picker makes — the + * draft-overlaid object list — with entries shaped as the platform serves + * them: `isSystem` on code-package objects, no `isSystem` key at all on a + * runtime-authored one, `_packageId` on each, `_draft` on a draft. + * + * The rows are chosen so the PLATFORM'S MARK is what is tested, not a name: + * `sys_file` carries a platform-looking name and is served `isSystem: false` + * (it stays up front), while `audit_trail` carries a plain name and is served + * `isSystem: true` (it is collapsed under System). + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +const h = vi.hoisted(() => { + const CATALOG: Array> = [ + { name: 'showcase_account', label: 'Account', isSystem: false, _packageId: 'com.example.showcase' }, + { name: 'sys_file', label: 'File', isSystem: false, _packageId: 'com.objectstack.service.storage' }, + { name: 'sys_user', label: 'User', isSystem: true, _packageId: 'com.objectstack.plugin-auth' }, + { name: 'audit_trail', label: 'Audit Trail', isSystem: true, _packageId: 'com.objectstack.audit' }, + // Runtime-authored, so served without `isSystem` — published, and a draft. + { name: 'repairs_technician', label: 'Technician', _packageId: 'com.example.repairs' }, + { name: 'repairs_repair_ticket', label: 'Repair Ticket', _packageId: 'com.example.repairs', _draft: true }, + ]; + const previewList = vi.fn(async (_type: string) => CATALOG); + const client = { withPreviewDrafts: vi.fn((_on: boolean) => ({ list: previewList })) }; + return { CATALOG, previewList, client }; +}); + +vi.mock('../useMetadata', () => ({ useMetadataClient: () => h.client })); + +import { ObjectPicker, type ObjectPickerProps } from './ObjectPicker'; +import { t } from '../i18n'; + +afterEach(cleanup); +beforeEach(() => { + h.previewList.mockClear(); + h.client.withPreviewDrafts.mockClear(); +}); + +/** Mount under Studio's route, so `:packageId` names the package being edited. */ +function renderPicker(props: Partial = {}, path = '/studio/com.example.repairs/data') { + const onCommit = vi.fn(); + const picker = ; + render( + + + + + + , + ); + return { onCommit, input: screen.getByRole('combobox', { name: 'Related object' }) }; +} + +/** The object names the open list offers, top to bottom. */ +function offered(): string[] { + return screen + .queryAllByRole('option') + .map((o) => o.getAttribute('data-object-name')) + .filter((n): n is string => !!n); +} + +async function openList(input: HTMLElement) { + await userEvent.click(input); + await waitFor(() => expect(offered().length).toBeGreaterThan(0)); +} + +describe('ObjectPicker — what it offers (objectui#11783)', () => { + it('offers this package first, then the other objects, and no isSystem object up front', async () => { + const { input } = renderPicker(); + await openList(input); + // This package (by label), then the rest (by label). `sys_file` is offered: + // the platform does not mark it system. `sys_user` / `audit_trail` are not. + expect(offered()).toEqual(['repairs_repair_ticket', 'repairs_technician', 'showcase_account', 'sys_file']); + // The package's objects sit in their own group, ahead of the others. + const groups = screen.getAllByRole('group'); + expect(groups[0]).toContainElement(document.querySelector('[data-object-name="repairs_technician"]') as HTMLElement); + expect(groups[1]).toContainElement(document.querySelector('[data-object-name="showcase_account"]') as HTMLElement); + }); + + it('offers the package draft object, labelled from its draft and marked as a draft', async () => { + const { input } = renderPicker(); + await openList(input); + const draft = document.querySelector('[data-object-name="repairs_repair_ticket"]') as HTMLElement; + expect(draft).toHaveTextContent('Repair Ticket'); + expect(draft).toHaveTextContent(t('engine.inspector.draftSuffix', 'en-US')); + const published = document.querySelector('[data-object-name="repairs_technician"]') as HTMLElement; + expect(published).not.toHaveTextContent(t('engine.inspector.draftSuffix', 'en-US')); + }); + + it('opening the collapsed System group offers the isSystem objects', async () => { + const { input } = renderPicker(); + await openList(input); + await userEvent.click(document.querySelector('[data-system-toggle]') as HTMLElement); + expect(offered()).toEqual(expect.arrayContaining(['sys_user', 'audit_trail'])); + }); + + it('a search reaches a System object without opening the group, and choosing it commits it', async () => { + const { input, onCommit } = renderPicker(); + await openList(input); + await userEvent.type(input, 'user'); + expect(offered()).toEqual(['sys_user']); + expect(onCommit).not.toHaveBeenCalled(); + await userEvent.click(document.querySelector('[data-object-name="sys_user"]') as HTMLElement); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith('sys_user'); + }); + + it('with no package in scope, no object is grouped as this package', async () => { + const { input } = renderPicker({}, '/setup/metadata/object/x'); + await openList(input); + expect(screen.getAllByRole('group')).toHaveLength(2); // the objects, then System + expect(offered()).toEqual(['showcase_account', 'sys_file', 'repairs_repair_ticket', 'repairs_technician']); + }); +}); + +describe('ObjectPicker — when a name leaves it (objectui#11783)', () => { + it('typing commits nothing; Enter commits the typed name once', async () => { + const { input, onCommit } = renderPicker(); + await userEvent.click(input); + await userEvent.type(input, 'repairs_repair_ticket'); + expect(onCommit).not.toHaveBeenCalled(); + await userEvent.keyboard('{Enter}'); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith('repairs_repair_ticket'); + }); + + it('Enter keeps a value that names no object, exactly as typed', async () => { + const { input, onCommit } = renderPicker(); + await userEvent.click(input); + // `{{` is user-event's escape for one literal `{`. + await userEvent.type(input, '{{{{trigger.object}}'); + await userEvent.keyboard('{Enter}'); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith('{{trigger.object}}'); + }); + + it('blur commits what was typed; a focus that typed nothing commits nothing', async () => { + const { input, onCommit } = renderPicker({ value: 'showcase_account' }); + await userEvent.click(input); + await userEvent.tab(); + expect(onCommit).not.toHaveBeenCalled(); + await userEvent.click(input); + await userEvent.clear(input); + await userEvent.type(input, 'sys_file'); + await userEvent.tab(); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith('sys_file'); + }); + + it('Escape abandons the typing', async () => { + const { input, onCommit } = renderPicker({ value: 'showcase_account' }); + await userEvent.click(input); + await userEvent.type(input, '_x'); + await userEvent.keyboard('{Escape}'); + expect(input).toHaveValue('showcase_account'); + await userEvent.tab(); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('reads the catalog once, from the draft-overlaid list, and never while typing', async () => { + const { input } = renderPicker(); + await openList(input); + await userEvent.type(input, 'repairs_repair_ticket'); + expect(h.client.withPreviewDrafts).toHaveBeenCalledWith(true); + expect(h.previewList).toHaveBeenCalledTimes(1); + expect(h.previewList).toHaveBeenCalledWith('object'); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ObjectPicker.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectPicker.tsx new file mode 100644 index 0000000000..1aaa1d9f9f --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ObjectPicker.tsx @@ -0,0 +1,454 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ObjectPicker — the one object-name input the Studio inspectors share + * (objectui#11783): a Lookup's *Related object*, a summary's child object, and + * every flow reference whose kind is `object` (the trigger's *Object*, a CRUD + * node's object, a screen's object form, …). + * + * ## Why it exists + * + * Both object-name inputs it replaces were a native `` that + * committed the draft on every keystroke. The committed name is what the + * inspectors downstream resolve (`useObjectFields` keys on the Lookup's + * `reference`; `useFlowScope` and the entry-condition builder key on the + * trigger's `objectName`), so typing one name sent one + * `GET /meta/object/PREFIX` per keystroke, each a 404. And the datalist listed + * every served object by bare name, the platform's own plumbing first. + * + * ## What it does instead + * + * - **Typing is local.** The text the author types lives here until it is + * COMMITTED: an option is chosen, Enter is pressed, or the input loses focus. + * Only then does `onCommit` run, so the draft — and everything that resolves + * the name — sees one value, once. + * - **The value is still free text.** Enter and blur commit exactly what was + * typed, matched or not: a flow reference may hold an expression, and the + * server judges an unknown name as it always did. Escape abandons the typing. + * - **The options are the names already held,** from one list read made when + * the picker mounts ({@link useObjectCatalog}), shown with their labels and + * in three groups: this package's objects, then the other objects, then the + * platform's own objects in a *System* group that starts collapsed. A search + * reaches System objects without opening it, because a Lookup to `sys_user` + * is a real author need. + * + * "System" is the platform's own mark — the served object's `isSystem` + * (`ObjectSchema` in `@objectstack/spec`) — never a name prefix and never a + * list kept here. + * + * Module-private: imported by its sibling inspectors, exported from no barrel. + */ + +import * as React from 'react'; +import { useParams } from 'react-router-dom'; +import { cn, Input, Label } from '@object-ui/components'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { useMetadataClient } from '../useMetadata.js'; +import { usePickerLoad } from '../loadState.js'; +import { t, tFormat, useMetadataLocale, type SupportedLocale } from '../i18n.js'; + +/** One object the picker can offer, read off one served list entry. */ +export interface ObjectCatalogEntry { + name: string; + /** The authored label; absent when the object declares none. */ + label?: string; + /** The served `isSystem` mark. */ + isSystem: boolean; + /** The owning package (`_packageId`), when the entry names one. */ + packageId?: string; + /** Served from its pending draft (`_draft`), not from the published version. */ + draft: boolean; +} + +/** + * Read one entry of `GET /meta/object?preview=draft`. + * + * `isSystem` is `true` only when the entry says so. A runtime-authored body — + * published or draft — is served as stored and carries no `isSystem` key, and + * the spec declares the key's default as `false`, so an absent key is the + * declared default, not a guess. + */ +function readCatalogEntry(raw: unknown): ObjectCatalogEntry | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + if (typeof r.name !== 'string' || !r.name) return null; + const label = typeof r.label === 'string' && r.label.trim() ? r.label : undefined; + const packageId = typeof r._packageId === 'string' && r._packageId ? r._packageId : undefined; + return { + name: r.name, + ...(label ? { label } : {}), + isSystem: r.isSystem === true, + ...(packageId ? { packageId } : {}), + draft: r._draft === true, + }; +} + +/** + * The object catalog every picker offers: ONE read, made when the picker + * mounts and never again while the author types. + * + * It reads the draft-overlaid list (`?preview=draft`) rather than the published + * one plus the draft headers, because that one answer carries what the picker + * shows for a draft-only object of this package — its own label from its + * draft, its package and its `_draft` mark — where the draft headers carry the + * name alone. A caller the server does not admit to pending drafts is answered + * the published list, which is the list it could pick from anyway. + */ +function useObjectCatalog() { + const client = useMetadataClient(); + // The read is asked ONCE per mount. The client is reached through a ref so + // that a fresh client object (a discarded memo, a test double minted per + // call) neither re-asks it nor keys anything on its identity (AGENTS.md #10). + const clientRef = React.useRef(client); + React.useEffect(() => { + clientRef.current = client; + }); + const load = React.useCallback( + () => + clientRef.current + .withPreviewDrafts(true) + .list('object') + .then((items) => { + const byName = new Map(); + for (const raw of items) { + const entry = readCatalogEntry(raw); + if (entry && !byName.has(entry.name)) byName.set(entry.name, entry); + } + return [...byName.values()]; + }), + [], + ); + return usePickerLoad(load); +} + +/** + * The package the author is editing in: Studio's `/studio/:packageId/...` + * route, else the `?package=` an editor URL carries (ADR-0048), where `all` is + * the list's "every package" scope and names none. + */ +function useEditingPackageId(): string | undefined { + const { packageId } = useParams<{ packageId?: string }>(); + if (packageId) return packageId; + try { + const p = new URLSearchParams(window.location.search).get('package'); + return p && p !== 'all' ? p : undefined; + } catch { + return undefined; + } +} + +type GroupKey = 'package' | 'other' | 'system'; + +/** The catalog before it answered: one stable empty list, not a fresh one per render. */ +const NO_ENTRIES: ObjectCatalogEntry[] = []; + +type Row = + | { kind: 'option'; id: string; entry: ObjectCatalogEntry } + | { kind: 'system-toggle'; id: string }; + +interface Section { + key: GroupKey; + /** The group's accessible name. */ + name: string; + /** Whether the name is also drawn as a visible heading. */ + headed: boolean; + rows: Row[]; +} + +function displayName(e: ObjectCatalogEntry): string { + return e.label ?? e.name; +} + +function byDisplayName(a: ObjectCatalogEntry, b: ObjectCatalogEntry): number { + return displayName(a).localeCompare(displayName(b)) || a.name.localeCompare(b.name); +} + +export interface ObjectPickerProps { + /** + * The visible label this picker renders and associates with its input. Omit + * it when the caller draws its own label, and pass `ariaLabel` instead. + */ + label?: string; + /** The input's accessible name when the picker draws no label of its own. */ + ariaLabel?: string; + /** The committed name. */ + value: string; + /** Runs once per commit — a choice, Enter, or blur — never per keystroke. */ + onCommit: (name: string) => void; + disabled?: boolean; + placeholder?: string; + /** The designer locale; defaults to the one the designer context carries. */ + locale?: SupportedLocale; +} + +export function ObjectPicker({ + label, + ariaLabel, + value, + onCommit, + disabled, + placeholder, + locale: localeProp, +}: ObjectPickerProps) { + const contextLocale = useMetadataLocale(); + const locale = localeProp ?? contextLocale; + const catalog = useObjectCatalog(); + const editingPackageId = useEditingPackageId(); + + const inputId = React.useId(); + const listId = React.useId(); + const optionIdBase = React.useId(); + + const [focused, setFocused] = React.useState(false); + const [open, setOpen] = React.useState(false); + // What the author is typing. Shown instead of `value` only while the input + // has focus; nothing outside this component sees it until a commit. + const [text, setText] = React.useState(value); + // Whether the author typed since the input took focus: the list filters on + // the text only then, so focusing a filled input still offers every object. + const [typed, setTyped] = React.useState(false); + const [active, setActive] = React.useState(-1); + const [systemOpen, setSystemOpen] = React.useState(false); + + const entries = catalog.status === 'loaded' ? catalog.data : NO_ENTRIES; + const query = typed ? text.trim().toLowerCase() : ''; + + const sections = React.useMemo(() => { + const matches = (e: ObjectCatalogEntry) => + !query || e.name.toLowerCase().includes(query) || (e.label?.toLowerCase().includes(query) ?? false); + const groupOf = (e: ObjectCatalogEntry): GroupKey => + e.isSystem ? 'system' : editingPackageId && e.packageId === editingPackageId ? 'package' : 'other'; + const pick = (g: GroupKey) => entries.filter((e) => groupOf(e) === g).sort(byDisplayName); + + const optionRow = (entry: ObjectCatalogEntry): Row => ({ kind: 'option', id: '', entry }); + const own = pick('package').filter(matches).map(optionRow); + const other = pick('other').filter(matches).map(optionRow); + const systemAll = pick('system'); + const packageName = t('engine.inspector.objectPicker.groupPackage', locale); + const otherName = t('engine.inspector.objectPicker.groupOther', locale); + const systemName = t('engine.inspector.objectPicker.groupSystem', locale); + const out: Section[] = []; + if (own.length) out.push({ key: 'package', name: packageName, headed: true, rows: own }); + // With no package group above it, the other objects are simply the list, + // and draw no heading. + if (other.length) out.push({ key: 'other', name: otherName, headed: own.length > 0, rows: other }); + if (query) { + const found = systemAll.filter(matches).map(optionRow); + if (found.length) out.push({ key: 'system', name: systemName, headed: true, rows: found }); + } else if (systemAll.length) { + out.push({ + key: 'system', + name: systemName, + headed: false, + rows: [{ kind: 'system-toggle', id: '' }, ...(systemOpen ? systemAll.map(optionRow) : [])], + }); + } + let i = 0; + for (const s of out) for (const r of s.rows) r.id = `${optionIdBase}-${i++}`; + return out; + }, [entries, query, editingPackageId, systemOpen, locale, optionIdBase]); + + const rows = React.useMemo(() => sections.flatMap((s) => s.rows), [sections]); + const systemCount = React.useMemo(() => entries.filter((e) => e.isSystem).length, [entries]); + const activeRow = active >= 0 ? rows[active] : undefined; + + const activeId = activeRow?.id; + React.useEffect(() => { + if (!activeId) return; + document.getElementById(activeId)?.scrollIntoView?.({ block: 'nearest' }); + }, [activeId]); + + const close = () => { + setOpen(false); + setActive(-1); + setTyped(false); + }; + + /** The one door to `onCommit`; an unchanged value is not a commit. */ + const commit = (next: string) => { + setText(next); + close(); + if (next !== value) onCommit(next); + }; + + const choose = (row: Row) => { + if (row.kind === 'system-toggle') { + setSystemOpen((o) => !o); + return; + } + commit(row.entry.name); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (!open) setOpen(true); + setActive((a) => Math.min(a + 1, rows.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActive((a) => Math.max(a - 1, -1)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (open && activeRow) choose(activeRow); + else if (typed) commit(text); + else close(); + } else if (e.key === 'Escape' && open) { + // Handled here, so the inspector does not read it as "clear selection". + e.preventDefault(); + e.stopPropagation(); + setText(value); + close(); + } + }; + + const shown = focused ? text : value; + const listLabel = label ?? ariaLabel; + + return ( +
+ {label && ( + + )} +
+ { + setFocused(true); + setText(value); + setTyped(false); + setActive(-1); + setOpen(true); + }} + onChange={(e) => { + setText(e.target.value); + setTyped(true); + setActive(-1); + setOpen(true); + }} + onBlur={() => { + setFocused(false); + // Only what the author typed is committed: a focus that typed + // nothing must not write back a value that moved underneath it. + if (typed) commit(text); + else close(); + }} + onKeyDown={onKeyDown} + /> + {open && catalog.status !== 'error' && ( +
e.preventDefault()} + className="absolute left-0 right-0 top-full z-50 mt-1 max-h-72 overflow-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md" + > + {catalog.status === 'loading' && ( +

{t('engine.form.loadingOptions', locale)}

+ )} + {catalog.status === 'loaded' && rows.length === 0 && ( +

{t('engine.inspector.objectPicker.noMatch', locale)}

+ )} + {sections.map((s) => ( +
+ {s.headed && ( +
+ {s.name} +
+ )} + {s.rows.map((row) => { + const isActive = activeRow?.id === row.id; + if (row.kind === 'system-toggle') { + const Chevron = systemOpen ? ChevronDown : ChevronRight; + return ( +
choose(row)} + className={cn( + 'flex cursor-pointer items-center gap-1 rounded-sm px-2 py-1 text-[11px] font-medium text-muted-foreground', + isActive && 'bg-accent text-accent-foreground', + )} + > + + {systemOpen + ? t('engine.inspector.objectPicker.hideSystem', locale) + : tFormat('engine.inspector.objectPicker.showSystem', locale, { count: systemCount })} +
+ ); + } + const { entry } = row; + return ( +
choose(row)} + className={cn( + 'flex cursor-pointer flex-col rounded-sm px-2 py-1', + isActive && 'bg-accent text-accent-foreground', + entry.name === value && 'font-medium', + )} + > + {/* Two lines, so neither the label nor the name is cut + short in a narrow inspector. Explicit spaces between + the parts: each is its own element, so without them + the option's accessible name reads as one run-on word. */} + + {displayName(entry)} + {entry.draft && ( + <> + {' '} + + {t('engine.inspector.draftSuffix', locale)} + + + )} + + {entry.label && ( + <> + {' '} + {entry.name} + + )} +
+ ); + })} +
+ ))} +
+ )} +
+ {catalog.status === 'error' && ( +

+ {t('engine.form.optionsLoadFailedTitle', locale)} + {catalog.message ? ( + <> + {' '} + {catalog.message} + + ) : null} +

+ )} +
+ ); +} diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts index ce82023935..31bf6b86c9 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts @@ -438,7 +438,6 @@ const FLOW_NODE_CONFIG: Record = { }), cfg('objectName', 'Object', 'reference', { ref: { kind: 'object' }, - placeholder: 'crm_lead', help: 'Target object for record / scheduled-scan triggers.', showWhen: { field: 'triggerType', equals: ['record-after-create', 'record-after-update', 'record-after-write', 'record-before-update', 'record-after-delete', 'schedule', 'api'] }, }),