Skip to content

Commit 03be565

Browse files
fix(app-shell): New automation never saves a typed prefix as the trigger object (#30148)
Refs #30038 (items 1, 2, 4) Item 3 (the closure refusal on 问题 reads in English) does not land here: the text is produced server-side by `packages/lint/src/validate-flow-trigger-readiness.ts:513`, outside this card's `ui/**` lane. It is listed in the report for the seat to carry. The card stays open for it. Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:新建自动化对话框里的「对象」选择框只接受真实对象;输入前缀按 Enter,只剩一个匹配就选它,否则提示「未选择对象」,不再把半截文字存成触发对象。 - 为什么改:运营人员像用搜索框一样按 Enter,结果流程绑到不存在的对象上,事后才在「问题」里看到英文报错。 - 风险与回滚:只在新建自动化对话框这两处开启(新的可选开关 `objectsOnly`);其他用到该选择框的地方(流程引用可填表达式)行为不变。回滚即撤销本 PR。 - 先看:`ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectPicker.tsx`(`commitTyped` 一段)、`StudioDesignSurface.tsx`(两处 `objectsOnly`)、`ObjectPicker.test.tsx` 末尾新增的 describe。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/inspectors/ObjectPicker.test.tsx packages/app-shell/src/views/studio-design/StudioDesignSurface.createTaken-26917.test.tsx`,预期 25 个测试全部通过。 - 需要你做的:无,席位落地。 ## What changes - `ObjectPicker` gains an opt-in prop `objectsOnly` (default off). With it, Enter and blur never commit the typed text as itself. They commit the pickable object named exactly so, else the only pickable option left in the filtered list, else no object (an empty commit), and the picker shows a `role="alert"` line: "No object chosen: "TEXT" is not an object. Choose one from the list." Options outside the package closure stay unpickable, as before. Before the catalog is read, nothing can be resolved and the typed text is committed as before. - The New automation dialog (`StudioDesignSurface.tsx`) turns `objectsOnly` on for both trigger-object pickers: the record preset and the Advanced trigger form. With a record preset and no object chosen, Create keeps the dialog open with the existing "Choose the object it watches." line. - Every other `ObjectPicker` use is unchanged. `FlowReferenceField.tsx` (an object-kind flow reference, where an expression is legal) passes no `objectsOnly` and keeps free text. - zh/en strings in `ui/packages/i18n/src/namespaces/studio/{en,zh}.ts`: `engine.inspector.objectPicker.notChosen` and `engine.inspector.objectPicker.noMatchObjectsOnly`. The default empty-list line ("Press Enter to keep what you typed") would mislead at an objects-only door. ## Tests - `ObjectPicker.test.tsx`, new top-level describe "objectsOnly": a one-match prefix + Enter commits the match; a several-match prefix + Enter commits nothing and shows the refusal; a non-object name clears a chosen value on Enter and on blur; a full name commits as itself; and, without `objectsOnly`, the same one-match prefix is committed as typed (item 2). - `StudioDesignSurface.createTaken-26917.test.tsx`, new top-level describe (the shared New-automation harness): with the "updated" record preset, prefix `tick` + Enter + Create saves a Start node with `objectName: 'acme_ticket'`; prefix `acme` (two matches) + Enter + Create saves nothing, shows the refusal and "Choose the object it watches.", and the dialog stays open (item 1 at the door). <details><summary>Run on the final head</summary> ``` pnpm --dir ui exec vitest run --maxWorkers=2 packages/app-shell/src/views/metadata-admin/inspectors/ObjectPicker.test.tsx packages/app-shell/src/views/studio-design/StudioDesignSurface.createTaken-26917.test.tsx Test Files 2 passed (2) Tests 25 passed (25) os-verify-lock: VERDICT command-exit 0 pnpm exec turbo run type-check --filter=./ui/packages/app-shell Tasks: 68 successful, 68 total (@object-ui/app-shell:type-check ran tsc --noEmit and tsc -p tsconfig.test.json) os-verify-lock: VERDICT command-exit 0 ``` </details> <details><summary>Ablation (one-shot, through scripts/ablation-replace.mjs, restored to HEAD after each leg)</summary> - Leg A, `ObjectPicker.tsx`: `if (!objectsOnly || catalog.status !== 'loaded') {` replaced with `if (true) {` (objectsOnly ignored). Mutation landed (anchor 1 to 0, blob e2118e0 to 53ef6e70092f). Result: 5 failed, 20 passed. The three objectsOnly picker cases and both door cases went red; the full-name case and the free-text (item 2) case stayed green, as they should. Restored: blob == HEAD, `git diff HEAD` empty. - Leg B, `StudioDesignSurface.tsx`: the record preset's `objectsOnly` replaced with `objectsOnly={false}`. Mutation landed (blob fc99d66 to b3f8845a7e8e). Result: 2 failed, 8 passed, both door cases red. Restored: blob == HEAD. The first attempt at this leg used a replacement already inside the anchor; the tool refused it as a no-op before running anything, and the anchor was changed. </details> ## Acceptance notes - Item 3 is not here. The refusal is the `message` of a `packages/lint` finding (`validate-flow-trigger-readiness.ts:513`, rule `FLOW_TRIGGER_UNKNOWN_OBJECT`). It is English prose with `objectName` and the package id written into it, and no structured parameters a ui renderer could localize from. Localizing it means a producer-side change (structured params or a code the Console keys on), outside this lane. - With this fix, the reported route (prefix + Enter + Save as draft) no longer creates a flow bound to a non-object, so this reader no longer reaches that refusal from this door. ## Verification Local gates, derived with `node scripts/pm/dispatch-gates.mjs --commands` from the merge base and reconciled with `--ran`. Exit codes are in the report. CI runs the full set. --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYpFBMr917PmVkG9up7)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d3edfb5 commit 03be565

7 files changed

Lines changed: 189 additions & 3 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(app-shell): New automation no longer saves a typed prefix as the trigger object (#30038)
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
In Studio's New automation dialog, typing part of an object name and pressing Enter committed the typed text itself, so the flow was created bound to an object that does not exist. The trigger-object picker there now takes objects only: Enter or leaving the field commits the object the text names exactly, else the only match left in the list, else no object, and the picker says no object is chosen. A record preset then keeps the dialog open until an object is picked.
12+
13+
Every other object picker keeps its free text, because a flow reference may hold an expression.

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectPicker.test.tsx‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,70 @@ describe('ObjectPicker — when a name leaves it (objectui#11783)', () => {
186186
expect(h.previewList).toHaveBeenCalledWith('object');
187187
});
188188
});
189+
190+
describe('ObjectPicker — objectsOnly: a door that takes no expression (objectstack#30038)', () => {
191+
const refusal = () => document.querySelector('[data-object-refused]');
192+
193+
it('Enter on a typed prefix with one match left commits that match, not the prefix', async () => {
194+
const { input, onCommit } = renderPicker({ objectsOnly: true });
195+
await openList(input);
196+
await userEvent.type(input, 'techn');
197+
expect(offered()).toEqual(['repairs_technician']);
198+
await userEvent.keyboard('{Enter}');
199+
expect(onCommit).toHaveBeenCalledTimes(1);
200+
expect(onCommit).toHaveBeenCalledWith('repairs_technician');
201+
expect(refusal()).toBeNull();
202+
});
203+
204+
it('Enter on a prefix that leaves several matches commits no object and says none is chosen', async () => {
205+
const { input, onCommit } = renderPicker({ objectsOnly: true });
206+
await openList(input);
207+
await userEvent.type(input, 'repairs');
208+
expect(offered()).toHaveLength(2);
209+
await userEvent.keyboard('{Enter}');
210+
// The value was empty and stays empty: nothing to commit.
211+
expect(onCommit).not.toHaveBeenCalled();
212+
expect(refusal()).toHaveTextContent(
213+
t('engine.inspector.objectPicker.notChosen', 'en-US').replace('{text}', 'repairs'),
214+
);
215+
});
216+
217+
it('a typed name that is no object clears a chosen one, on Enter and on blur alike', async () => {
218+
const enter = renderPicker({ objectsOnly: true, value: 'showcase_account' });
219+
await openList(enter.input);
220+
await userEvent.clear(enter.input);
221+
await userEvent.type(enter.input, 'crm_custom_eval');
222+
await userEvent.keyboard('{Enter}');
223+
expect(enter.onCommit).toHaveBeenCalledTimes(1);
224+
expect(enter.onCommit).toHaveBeenCalledWith('');
225+
cleanup();
226+
227+
const blur = renderPicker({ objectsOnly: true, value: 'showcase_account' });
228+
await openList(blur.input);
229+
await userEvent.clear(blur.input);
230+
await userEvent.type(blur.input, 'crm_custom_eval');
231+
await userEvent.tab();
232+
expect(blur.onCommit).toHaveBeenCalledTimes(1);
233+
expect(blur.onCommit).toHaveBeenCalledWith('');
234+
expect(refusal()).not.toBeNull();
235+
});
236+
237+
it('a name typed out in full is committed as itself', async () => {
238+
const { input, onCommit } = renderPicker({ objectsOnly: true });
239+
await openList(input);
240+
await userEvent.type(input, 'sys_file');
241+
await userEvent.keyboard('{Enter}');
242+
expect(onCommit).toHaveBeenCalledWith('sys_file');
243+
});
244+
245+
it('without objectsOnly, the same one-match prefix is committed as typed (a flow reference may hold an expression)', async () => {
246+
const { input, onCommit } = renderPicker();
247+
await openList(input);
248+
await userEvent.type(input, 'techn');
249+
expect(offered()).toEqual(['repairs_technician']);
250+
await userEvent.keyboard('{Enter}');
251+
expect(onCommit).toHaveBeenCalledTimes(1);
252+
expect(onCommit).toHaveBeenCalledWith('techn');
253+
expect(refusal()).toBeNull();
254+
});
255+
});

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

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@
2525
* - **The value is still free text.** Enter and blur commit exactly what was
2626
* typed, matched or not: a flow reference may hold an expression, and the
2727
* server judges an unknown name as it always did. Escape abandons the typing.
28+
* - **Unless the door takes objects only (`objectsOnly`).** Where no
29+
* expression is legal — the new-automation dialog's trigger object — what
30+
* was typed is never committed as itself: Enter and blur commit the object
31+
* it names exactly, else the only pickable match left, else no object at
32+
* all, and the picker says no object is chosen. A typed prefix with one
33+
* match shown is that match, the way a search box reads it.
2834
* - **The options are the names already held,** from one list read made when
2935
* the picker mounts ({@link useObjectCatalog}), shown with their labels and
3036
* in three groups: this package's objects, then the other objects, then the
@@ -206,6 +212,13 @@ export interface ObjectPickerProps {
206212
placeholder?: string;
207213
/** The designer locale; defaults to the one the designer context carries. */
208214
locale?: SupportedLocale;
215+
/**
216+
* Commit only a name the catalog offers, never typed text as itself. For a
217+
* door where no expression is legal; off by default, so every flow
218+
* reference keeps its free text. While the catalog is not read, nothing can
219+
* be resolved and the typed text is committed as before.
220+
*/
221+
objectsOnly?: boolean;
209222
}
210223

211224
export function ObjectPicker({
@@ -216,6 +229,7 @@ export function ObjectPicker({
216229
disabled,
217230
placeholder,
218231
locale: localeProp,
232+
objectsOnly = false,
219233
}: ObjectPickerProps) {
220234
const contextLocale = useMetadataLocale();
221235
const locale = localeProp ?? contextLocale;
@@ -240,6 +254,8 @@ export function ObjectPicker({
240254
const [systemOpen, setSystemOpen] = React.useState(false);
241255
const [outsideOpen, setOutsideOpen] = React.useState(false);
242256
const [declaring, setDeclaring] = React.useState(false);
257+
// objectsOnly — the text last refused, so the picker can say no object is chosen.
258+
const [refused, setRefused] = React.useState<string | null>(null);
243259

244260
const entries = catalog.status === 'loaded' ? catalog.data : NO_ENTRIES;
245261
const query = typed ? text.trim().toLowerCase() : '';
@@ -334,6 +350,28 @@ export function ObjectPicker({
334350
if (next !== value) onCommit(next);
335351
};
336352

353+
/**
354+
* Commit what the author typed. Free text by default; with `objectsOnly`,
355+
* the object it names: the pickable option named exactly so, else the only
356+
* pickable option left, else none — an empty commit and a refusal shown.
357+
*/
358+
const commitTyped = () => {
359+
if (!objectsOnly || catalog.status !== 'loaded') {
360+
commit(text);
361+
return;
362+
}
363+
const wanted = text.trim();
364+
if (!wanted) {
365+
setRefused(null);
366+
commit('');
367+
return;
368+
}
369+
const pickable = rows.flatMap((r) => (r.kind === 'option' && !r.disabled ? [r.entry.name] : []));
370+
const name = pickable.includes(wanted) ? wanted : pickable.length === 1 ? pickable[0] : null;
371+
setRefused(name === null ? wanted : null);
372+
commit(name ?? '');
373+
};
374+
337375
const choose = (row: Row) => {
338376
if (row.kind === 'system-toggle') {
339377
setSystemOpen((o) => !o);
@@ -351,6 +389,7 @@ export function ObjectPicker({
351389
}
352390
// Outside the closure: listed so the author learns why, never committed.
353391
if (row.disabled) return;
392+
setRefused(null);
354393
commit(row.entry.name);
355394
};
356395

@@ -365,7 +404,7 @@ export function ObjectPicker({
365404
} else if (e.key === 'Enter') {
366405
e.preventDefault();
367406
if (open && activeRow) choose(activeRow);
368-
else if (typed) commit(text);
407+
else if (typed) commitTyped();
369408
else close();
370409
} else if (e.key === 'Escape' && open) {
371410
// Handled here, so the inspector does not read it as "clear selection".
@@ -415,14 +454,15 @@ export function ObjectPicker({
415454
onChange={(e) => {
416455
setText(e.target.value);
417456
setTyped(true);
457+
setRefused(null);
418458
setActive(-1);
419459
setOpen(true);
420460
}}
421461
onBlur={() => {
422462
setFocused(false);
423463
// Only what the author typed is committed: a focus that typed
424464
// nothing must not write back a value that moved underneath it.
425-
if (typed) commit(text);
465+
if (typed) commitTyped();
426466
else close();
427467
}}
428468
onKeyDown={onKeyDown}
@@ -440,7 +480,9 @@ export function ObjectPicker({
440480
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">{t('engine.form.loadingOptions', locale)}</p>
441481
)}
442482
{catalog.status === 'loaded' && rows.length === 0 && (
443-
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">{t('engine.inspector.objectPicker.noMatch', locale)}</p>
483+
<p className="px-2 py-1.5 text-[11px] text-muted-foreground">
484+
{t(objectsOnly ? 'engine.inspector.objectPicker.noMatchObjectsOnly' : 'engine.inspector.objectPicker.noMatch', locale)}
485+
</p>
444486
)}
445487
{sections.map((s) => (
446488
<div key={s.key} role="group" aria-label={s.name}>
@@ -586,6 +628,11 @@ export function ObjectPicker({
586628
{value}
587629
</p>
588630
)}
631+
{refused !== null && (
632+
<p role="alert" data-object-refused="" className="text-[11px] leading-snug text-amber-600 dark:text-amber-300">
633+
{tFormat('engine.inspector.objectPicker.notChosen', locale, { text: refused })}
634+
</p>
635+
)}
589636
{declaring && closure && (
590637
<PackageFormDialog
591638
mode="edit"

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.createTaken-26917.test.tsx‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,3 +380,52 @@ describe('objectstack#26917 — Create app over a taken identifier', () => {
380380
]);
381381
});
382382
});
383+
384+
describe('objectstack#30038 — Automations: New automation never saves a typed prefix as the trigger object', () => {
385+
async function openRecordPreset() {
386+
publish('flow', PKG, FLOW_ALPHA);
387+
publish('object', PKG, OBJ_ACCOUNT);
388+
publish('object', PKG, OBJ_TICKET);
389+
render(
390+
<MemoryRouter initialEntries={[`/studio/${PKG}/automations`]}>
391+
<AutomationsPillar packageId={PKG} />
392+
</MemoryRouter>,
393+
);
394+
await screen.findByTestId('auto-rail-row-alpha_intake', undefined, { timeout: 8000 });
395+
fireEvent.click(screen.getByTitle(en('engine.studio.auto.newTitle')));
396+
const dialog = await screen.findByRole('dialog');
397+
fireEvent.click(within(dialog).getByTestId('create-flow-preset-record_updated'));
398+
const picker = within(dialog).getByRole('combobox');
399+
fireEvent.focus(picker);
400+
await waitFor(() => expect(dialog.querySelector('[data-object-name="acme_ticket"]')).not.toBeNull());
401+
return { dialog, picker };
402+
}
403+
404+
/** The saved flow's Start node config. */
405+
const savedStart = () => {
406+
const body = save.mock.calls[0]![2] as { nodes: Array<{ type: string; config?: Record<string, unknown> }> };
407+
return body.nodes.find((n) => n.type === 'start')?.config;
408+
};
409+
410+
it('Enter on a prefix with one match shown saves that object', async () => {
411+
const { dialog, picker } = await openRecordPreset();
412+
fireEvent.change(picker, { target: { value: 'tick' } });
413+
fireEvent.keyDown(picker, { key: 'Enter' });
414+
submitCreate(dialog, 'Close stale');
415+
416+
await waitFor(() => expect(save).toHaveBeenCalledTimes(1));
417+
expect(savedStart()).toMatchObject({ triggerType: 'record-after-update', objectName: 'acme_ticket' });
418+
});
419+
420+
it('Enter on a prefix with several matches: no object chosen, nothing saved, the dialog stays open', async () => {
421+
const { dialog, picker } = await openRecordPreset();
422+
fireEvent.change(picker, { target: { value: 'acme' } });
423+
fireEvent.keyDown(picker, { key: 'Enter' });
424+
expect(dialog.querySelector('[data-object-refused]')).not.toBeNull();
425+
submitCreate(dialog, 'Close stale');
426+
427+
expect(await within(dialog).findByText(en('engine.studio.auto.preset.needsObject'))).toBeInTheDocument();
428+
expect(save).not.toHaveBeenCalled();
429+
expect(screen.getByRole('dialog')).toBe(dialog);
430+
});
431+
});

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9182,9 +9182,12 @@ export function AutomationsPillar({
91829182
</label>
91839183
{newChoice === p && startTrigger.object?.showWhen?.equals.includes(p.triggerType) && (
91849184
<div className="ml-6 mt-1.5">
9185+
{/* objectstack#30038 — no expression is legal here: a typed
9186+
prefix is never saved as the trigger object. */}
91859187
<ObjectPicker
91869188
label={startTrigger.object.label}
91879189
value={newTriggerObject}
9190+
objectsOnly
91889191
onCommit={(v) => {
91899192
setNewTriggerObject(v);
91909193
setNewNeeds(null);
@@ -9239,6 +9242,7 @@ export function AutomationsPillar({
92399242
<ObjectPicker
92409243
label={startTrigger.object.label}
92419244
value={newTriggerObject}
9245+
objectsOnly
92429246
onCommit={setNewTriggerObject}
92439247
locale={locale}
92449248
/>

‎ui/packages/i18n/src/namespaces/studio/en.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1582,6 +1582,9 @@ const STUDIO_EN: Record<string, string> = {
15821582
'engine.inspector.objectPicker.showSystem': 'Show system objects ({count})',
15831583
'engine.inspector.objectPicker.hideSystem': 'Hide system objects',
15841584
'engine.inspector.objectPicker.noMatch': 'No object matches. Press Enter to keep what you typed.',
1585+
// A picker that takes objects only (the new-automation trigger object).
1586+
'engine.inspector.objectPicker.noMatchObjectsOnly': 'No object matches. Choose one from the list.',
1587+
'engine.inspector.objectPicker.notChosen': 'No object chosen: \u201c{text}\u201d is not an object. Choose one from the list.',
15851588
// objectstack#28240 — objects outside the edited package's closure.
15861589
'engine.inspector.objectPicker.groupOutside': 'Outside this package\u2019s dependencies',
15871590
'engine.inspector.objectPicker.showOutside': 'Show objects outside this package\u2019s dependencies ({count})',

‎ui/packages/i18n/src/namespaces/studio/zh.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,6 +1419,9 @@ const STUDIO_ZH: Record<string, string> = {
14191419
'engine.inspector.objectPicker.showSystem': '显示系统对象({count})',
14201420
'engine.inspector.objectPicker.hideSystem': '隐藏系统对象',
14211421
'engine.inspector.objectPicker.noMatch': '没有匹配的对象。按 Enter 保留输入的内容。',
1422+
// 只接受对象的选择器(新建自动化的触发对象)。
1423+
'engine.inspector.objectPicker.noMatchObjectsOnly': '没有匹配的对象。请从列表中选择一个。',
1424+
'engine.inspector.objectPicker.notChosen': '未选择对象:\u201c{text}\u201d不是对象。请从列表中选择一个。',
14221425
// objectstack#28240 — objects outside the edited package's closure.
14231426
'engine.inspector.objectPicker.groupOutside': '不在本软件包的依赖范围内',
14241427
'engine.inspector.objectPicker.showOutside': '显示不在本软件包依赖范围内的对象({count})',

0 commit comments

Comments
 (0)