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
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from './_shared';
import { useObjectOptions } from '../previews/useObjectOptions';
import { useObjectFields } from '../previews/useObjectFields';
import { useMetaOptions } from '../previews/useMetaOptions';
import { ConditionBuilder } from './ConditionBuilder';
import { IconPickerWidget } from '../widgets';

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

/** Action types whose `target` names another metadata record — render a picker
* of real names instead of free text (flow → flow, modal → page, form → view). */
const TARGET_META_TYPE: Record<string, string> = { flow: 'flow', modal: 'page', form: 'view' };

/** Target binding: a reference picker for flow/modal/form (names come from the
* matching metadata type), else a free-text input (url/api). An out-of-catalog
* value is preserved as a synthesized option so it is never silently dropped. */
function ActionTargetField({ type, value, onCommit, cfg, readOnly }: {
type: string;
value: string;
onCommit: (v: string) => void;
cfg: { label: string; placeholder: string; hint: string };
readOnly?: boolean;
}) {
const metaType = TARGET_META_TYPE[type] ?? null;
const { options } = useMetaOptions(metaType);
const usePicker = !!metaType && options.length > 0;
const opts = usePicker && value && !options.some((o) => o.value === value)
? [{ value, label: `${value} (not found)` }, ...options]
: options;
return (
<div className="space-y-1">
{usePicker ? (
<InspectorSelectField label={`${cfg.label} *`} value={value || undefined} options={opts} onCommit={onCommit} disabled={readOnly} />
) : (
<InspectorTextField label={`${cfg.label} *`} value={value} onCommit={onCommit} placeholder={cfg.placeholder} disabled={readOnly} mono />
)}
<div className="text-[11px] text-muted-foreground/70">{cfg.hint}</div>
</div>
);
}

/** Keys this inspector edits with its own controls — hidden from the fallback. */
const CURATED_FIELDS = [
'name', 'label', 'objectName', 'icon', 'variant', 'component',
Expand Down Expand Up @@ -295,10 +328,13 @@ export function ActionDefaultInspector({
<InspectorSelectField label="Method" value={str('method') || 'POST'} options={METHOD_OPTS} onCommit={(v) => onPatch({ method: v })} disabled={readOnly} />
)}
{targetCfg && (
<div className="space-y-1">
<InspectorTextField label={`${targetCfg.label} *`} value={str('target')} onCommit={(v) => onPatch({ target: v })} placeholder={targetCfg.placeholder} disabled={readOnly} mono />
<div className="text-[11px] text-muted-foreground/70">{targetCfg.hint}</div>
</div>
<ActionTargetField
type={type}
value={str('target')}
onCommit={(v) => onPatch({ target: v })}
cfg={targetCfg}
readOnly={readOnly}
/>
)}
</>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* useMetaOptions — name+label options for ANY metadata type (generalises
* {@link useObjectOptions}). Powers reference pickers that point at a metadata
* record by name (e.g. an Action's `target` → a flow / page / view). Async;
* degrades to an empty list so callers fall back to a free-text input. Pass
* `null` to skip fetching.
*/
import * as React from 'react';
import { useMetadataClient } from '../useMetadata';

export interface MetaOption {
value: string;
label: string;
}

export function useMetaOptions(type: string | null): { options: MetaOption[]; loading: boolean } {
const client = useMetadataClient();
const [options, setOptions] = React.useState<MetaOption[]>([]);
const [loading, setLoading] = React.useState(!!type);

React.useEffect(() => {
if (!type) {
setOptions([]);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
client
.list<{ name?: string; label?: string }>(type)
.then((items) => {
if (cancelled) return;
const mapped = (items ?? [])
.map((raw) => (raw && typeof raw === 'object' && 'item' in raw ? (raw as any).item : raw))
.filter((i: any) => typeof i?.name === 'string' && i.name)
.map((i: any) => ({
value: i.name as string,
label: i.label ? `${i.label} (${i.name})` : (i.name as string),
}))
.sort((a: MetaOption, b: MetaOption) => a.value.localeCompare(b.value));
setOptions(mapped);
setLoading(false);
})
.catch(() => {
if (!cancelled) {
setOptions([]);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [client, type]);

return { options, loading };
}
Loading