Skip to content

Commit 83e2e2e

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(studio): reference pickers for Action target (flow/page/view) (#1835)
The Action editor's target was a free-text field for flow/modal/form types, so admins had to know and type exact metadata names. Resolve it from the matching metadata type instead: - useMetaOptions: generic name+label loader for any metadata type (generalises useObjectOptions). - ActionDefaultInspector: flow -> flow picker, modal -> page picker, form -> view picker; url/api stay free text. Out-of-catalog values are preserved as a '(not found)' option so nothing is silently dropped. Verified live: a flow-type action's 'Flow name' is now a dropdown of real flows with the current target resolved to its label. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e80b0af commit 83e2e2e

2 files changed

Lines changed: 98 additions & 4 deletions

File tree

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

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
} from './_shared';
4848
import { useObjectOptions } from '../previews/useObjectOptions';
4949
import { useObjectFields } from '../previews/useObjectFields';
50+
import { useMetaOptions } from '../previews/useMetaOptions';
5051
import { ConditionBuilder } from './ConditionBuilder';
5152
import { IconPickerWidget } from '../widgets';
5253

@@ -126,6 +127,38 @@ const TARGET_FIELD: Record<string, { label: string; placeholder: string; hint: s
126127
api: { label: 'API endpoint', placeholder: '/api/v1/…', hint: 'Endpoint called with the request body below.' },
127128
};
128129

130+
/** Action types whose `target` names another metadata record — render a picker
131+
* of real names instead of free text (flow → flow, modal → page, form → view). */
132+
const TARGET_META_TYPE: Record<string, string> = { flow: 'flow', modal: 'page', form: 'view' };
133+
134+
/** Target binding: a reference picker for flow/modal/form (names come from the
135+
* matching metadata type), else a free-text input (url/api). An out-of-catalog
136+
* value is preserved as a synthesized option so it is never silently dropped. */
137+
function ActionTargetField({ type, value, onCommit, cfg, readOnly }: {
138+
type: string;
139+
value: string;
140+
onCommit: (v: string) => void;
141+
cfg: { label: string; placeholder: string; hint: string };
142+
readOnly?: boolean;
143+
}) {
144+
const metaType = TARGET_META_TYPE[type] ?? null;
145+
const { options } = useMetaOptions(metaType);
146+
const usePicker = !!metaType && options.length > 0;
147+
const opts = usePicker && value && !options.some((o) => o.value === value)
148+
? [{ value, label: `${value} (not found)` }, ...options]
149+
: options;
150+
return (
151+
<div className="space-y-1">
152+
{usePicker ? (
153+
<InspectorSelectField label={`${cfg.label} *`} value={value || undefined} options={opts} onCommit={onCommit} disabled={readOnly} />
154+
) : (
155+
<InspectorTextField label={`${cfg.label} *`} value={value} onCommit={onCommit} placeholder={cfg.placeholder} disabled={readOnly} mono />
156+
)}
157+
<div className="text-[11px] text-muted-foreground/70">{cfg.hint}</div>
158+
</div>
159+
);
160+
}
161+
129162
/** Keys this inspector edits with its own controls — hidden from the fallback. */
130163
const CURATED_FIELDS = [
131164
'name', 'label', 'objectName', 'icon', 'variant', 'component',
@@ -295,10 +328,13 @@ export function ActionDefaultInspector({
295328
<InspectorSelectField label="Method" value={str('method') || 'POST'} options={METHOD_OPTS} onCommit={(v) => onPatch({ method: v })} disabled={readOnly} />
296329
)}
297330
{targetCfg && (
298-
<div className="space-y-1">
299-
<InspectorTextField label={`${targetCfg.label} *`} value={str('target')} onCommit={(v) => onPatch({ target: v })} placeholder={targetCfg.placeholder} disabled={readOnly} mono />
300-
<div className="text-[11px] text-muted-foreground/70">{targetCfg.hint}</div>
301-
</div>
331+
<ActionTargetField
332+
type={type}
333+
value={str('target')}
334+
onCommit={(v) => onPatch({ target: v })}
335+
cfg={targetCfg}
336+
readOnly={readOnly}
337+
/>
302338
)}
303339
</>
304340
)}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* useMetaOptions — name+label options for ANY metadata type (generalises
5+
* {@link useObjectOptions}). Powers reference pickers that point at a metadata
6+
* record by name (e.g. an Action's `target` → a flow / page / view). Async;
7+
* degrades to an empty list so callers fall back to a free-text input. Pass
8+
* `null` to skip fetching.
9+
*/
10+
import * as React from 'react';
11+
import { useMetadataClient } from '../useMetadata';
12+
13+
export interface MetaOption {
14+
value: string;
15+
label: string;
16+
}
17+
18+
export function useMetaOptions(type: string | null): { options: MetaOption[]; loading: boolean } {
19+
const client = useMetadataClient();
20+
const [options, setOptions] = React.useState<MetaOption[]>([]);
21+
const [loading, setLoading] = React.useState(!!type);
22+
23+
React.useEffect(() => {
24+
if (!type) {
25+
setOptions([]);
26+
setLoading(false);
27+
return;
28+
}
29+
let cancelled = false;
30+
setLoading(true);
31+
client
32+
.list<{ name?: string; label?: string }>(type)
33+
.then((items) => {
34+
if (cancelled) return;
35+
const mapped = (items ?? [])
36+
.map((raw) => (raw && typeof raw === 'object' && 'item' in raw ? (raw as any).item : raw))
37+
.filter((i: any) => typeof i?.name === 'string' && i.name)
38+
.map((i: any) => ({
39+
value: i.name as string,
40+
label: i.label ? `${i.label} (${i.name})` : (i.name as string),
41+
}))
42+
.sort((a: MetaOption, b: MetaOption) => a.value.localeCompare(b.value));
43+
setOptions(mapped);
44+
setLoading(false);
45+
})
46+
.catch(() => {
47+
if (!cancelled) {
48+
setOptions([]);
49+
setLoading(false);
50+
}
51+
});
52+
return () => {
53+
cancelled = true;
54+
};
55+
}, [client, type]);
56+
57+
return { options, loading };
58+
}

0 commit comments

Comments
 (0)