feat(workflows): enhance script handling and introduce custom step names
- Updated AGENTS.md to include a `name` field for scripts, allowing custom display titles in the editor and graph. - Enhanced script icon resolution to support `.jpeg` file extensions. - Modified workflow parsing to accommodate the new `name` property for steps, improving step identification. - Added new utility functions for handling custom step names and display names. - Introduced tests to validate the functionality of custom step names in workflow YAML parsing and step labeling. Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
@@ -5,7 +5,7 @@ export function scriptIconSrc(name) {
|
||||
return `/api/scripts/${encodeURIComponent(name)}/icon`;
|
||||
}
|
||||
|
||||
/** Same-name PNG/JPG next to the script, or a JS document icon. */
|
||||
/** Same-name PNG/JPG/JPEG next to the script, or a JS document icon. */
|
||||
export function ScriptIcon({ name, hasIcon, className = "size-10" }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
|
||||
@@ -42,6 +42,20 @@ import "@xyflow/react/dist/style.css";
|
||||
|
||||
const nodeTypes = { trigger: TriggerGraphNode, step: StepGraphNode };
|
||||
|
||||
function listedHasIcon(listed) {
|
||||
if (listed == null || typeof listed === "string") return undefined;
|
||||
return listed.hasIcon;
|
||||
}
|
||||
|
||||
function stepIconData(node, scriptsByName, profilesByName) {
|
||||
if (node.type !== "step" || node.data?.kind === "set") {
|
||||
return { iconScript: "", hasIcon: false };
|
||||
}
|
||||
const profile = node.data?.profile ? profilesByName.get(node.data.profile) : null;
|
||||
const iconScript = profile?.script || node.data?.script || "";
|
||||
return { iconScript, hasIcon: listedHasIcon(scriptsByName.get(iconScript)) };
|
||||
}
|
||||
|
||||
/** Two Control zoom-out steps from React Flow's default zoom (1). */
|
||||
const INITIAL_ZOOM = 1 / 1.2 ** 2;
|
||||
const FIT_VIEW = { padding: 0.2, maxZoom: INITIAL_ZOOM, duration: 200 };
|
||||
@@ -87,28 +101,6 @@ function GraphTabInner({
|
||||
const steps = doc?.scripts ?? [];
|
||||
const triggers = doc?.triggers ?? [];
|
||||
|
||||
const { edges } = useMemo(() => buildGraphElements(doc, positions), [doc, positions]);
|
||||
|
||||
useEffect(() => {
|
||||
const built = buildGraphElements(doc, positions);
|
||||
setNodes((prev) => {
|
||||
const selected = new Set(prev.filter((n) => n.selected).map((n) => n.id));
|
||||
return built.nodes.map((n) => ({
|
||||
...n,
|
||||
selected: selected.has(n.id),
|
||||
deletable: !disabled,
|
||||
draggable: !disabled,
|
||||
}));
|
||||
});
|
||||
}, [doc, disabled, positions]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = requestAnimationFrame(() => fitView(FIT_VIEW));
|
||||
return () => cancelAnimationFrame(t);
|
||||
}, [owner, file, fitView]);
|
||||
|
||||
const selected = parseGraphNodeId(selectedId);
|
||||
|
||||
const scriptsByName = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const s of scripts) {
|
||||
@@ -126,6 +118,29 @@ function GraphTabInner({
|
||||
return map;
|
||||
}, [profiles]);
|
||||
|
||||
const { edges } = useMemo(() => buildGraphElements(doc, positions), [doc, positions]);
|
||||
|
||||
useEffect(() => {
|
||||
const built = buildGraphElements(doc, positions);
|
||||
setNodes((prev) => {
|
||||
const selected = new Set(prev.filter((n) => n.selected).map((n) => n.id));
|
||||
return built.nodes.map((n) => ({
|
||||
...n,
|
||||
data: { ...n.data, ...stepIconData(n, scriptsByName, profilesByName) },
|
||||
selected: selected.has(n.id),
|
||||
deletable: !disabled,
|
||||
draggable: !disabled,
|
||||
}));
|
||||
});
|
||||
}, [doc, disabled, positions, scriptsByName, profilesByName]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = requestAnimationFrame(() => fitView(FIT_VIEW));
|
||||
return () => cancelAnimationFrame(t);
|
||||
}, [owner, file, fitView]);
|
||||
|
||||
const selected = parseGraphNodeId(selectedId);
|
||||
|
||||
const displayEdges = useMemo(
|
||||
() =>
|
||||
edges.map((e) => ({
|
||||
|
||||
@@ -18,7 +18,7 @@ import { LogViewer } from "../LogViewer.jsx";
|
||||
import { StatusBadge } from "../../lib/format";
|
||||
import { prettyJson } from "../../lib/script.js";
|
||||
import { seedTryDialog, stepTryLabel } from "../../lib/try-session.js";
|
||||
import { needsMode } from "../../lib/workflow-doc.js";
|
||||
import { needsMode, stepCustomName, stepDisplayName } from "../../lib/workflow-doc.js";
|
||||
import {
|
||||
stepPredecessors,
|
||||
stepSuccessors,
|
||||
@@ -117,7 +117,9 @@ function ScriptCardView({
|
||||
step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId);
|
||||
const preview = previewConfigValue(mergedConfig, meta?.previewConfigKey);
|
||||
const previewFull = configValueText(mergedConfig, meta?.previewConfigKey);
|
||||
const baseName = step.kind === "set" ? "set" : scriptName || "untitled";
|
||||
const scriptFallback = step.kind === "set" ? "set" : scriptName || "untitled";
|
||||
const customName = stepCustomName(step);
|
||||
const baseName = stepDisplayName(step, scriptFallback);
|
||||
const titleFull = previewFull ? `${baseName} (${previewFull})` : baseName;
|
||||
const setConfig =
|
||||
step.kind === "set" ? { expression: step.expression ?? "" } : null;
|
||||
@@ -180,10 +182,18 @@ function ScriptCardView({
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3
|
||||
className="card-title min-w-0 flex-1 text-base font-mono"
|
||||
className={`card-title min-w-0 flex-1 text-base ${customName ? "" : "font-mono"}`}
|
||||
title={titleFull}
|
||||
>
|
||||
<span className="min-w-0 truncate">{baseName}</span>
|
||||
{customName && scriptFallback && scriptFallback !== customName ? (
|
||||
<span
|
||||
className="badge badge-ghost badge-sm shrink-0 truncate font-mono font-normal"
|
||||
title={scriptFallback}
|
||||
>
|
||||
{scriptFallback}
|
||||
</span>
|
||||
) : null}
|
||||
{!expanded && preview ? (
|
||||
<span
|
||||
className="badge badge-secondary badge-sm shrink-0 truncate font-mono font-normal"
|
||||
@@ -275,6 +285,15 @@ function ScriptCardView({
|
||||
|
||||
{expanded ? (
|
||||
<>
|
||||
<label className="form-control">
|
||||
<span className="label py-0 text-sm">Name</span>
|
||||
<FormInput
|
||||
value={step.name ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...step, name: e.target.value })}
|
||||
placeholder={scriptFallback}
|
||||
/>
|
||||
</label>
|
||||
{step.kind === "set" ? (
|
||||
<div className="space-y-2">
|
||||
<FieldLabel
|
||||
@@ -469,9 +488,11 @@ function ConfigJsonEditor({ config, disabled, onChange }) {
|
||||
|
||||
function NeedsEditor({ step, otherSteps, disabled, onChange }) {
|
||||
const mode = needsMode(step.needs);
|
||||
const ids = otherSteps
|
||||
.filter((s) => s.id && s.uiId !== step.uiId)
|
||||
.map((s) => s.id);
|
||||
const others = otherSteps.filter((s) => s.id && s.uiId !== step.uiId);
|
||||
const ids = others.map((s) => s.id);
|
||||
const nameById = new Map(
|
||||
others.map((s) => [s.id, stepCustomName(s) || null]),
|
||||
);
|
||||
|
||||
function setMode(next) {
|
||||
if (next === "none") onChange({ ...step, needs: null });
|
||||
@@ -479,6 +500,11 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
|
||||
else onChange({ ...step, needs: {}, when: "" });
|
||||
}
|
||||
|
||||
function idLabel(id) {
|
||||
const named = nameById.get(id);
|
||||
return named ? `${id} · ${named}` : id;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<span className="label py-0 text-sm">needs</span>
|
||||
@@ -520,7 +546,7 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="font-mono text-xs">{id}</span>
|
||||
<span className="font-mono text-xs">{idLabel(id)}</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
@@ -531,6 +557,7 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
|
||||
<NeedsMap
|
||||
needs={step.needs && !Array.isArray(step.needs) ? step.needs : {}}
|
||||
ids={ids}
|
||||
idLabel={idLabel}
|
||||
disabled={disabled}
|
||||
onChange={(needs) => onChange({ ...step, needs })}
|
||||
/>
|
||||
@@ -539,7 +566,7 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NeedsMap({ needs, ids, disabled, onChange }) {
|
||||
function NeedsMap({ needs, ids, idLabel, disabled, onChange }) {
|
||||
const entries = Object.entries(needs ?? {});
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
@@ -564,11 +591,11 @@ function NeedsMap({ needs, ids, disabled, onChange }) {
|
||||
onChange={(e) => onChange({ ...needs, [alias]: e.target.value })}
|
||||
>
|
||||
{from && !ids.includes(from) ? (
|
||||
<option value={from}>{from}</option>
|
||||
<option value={from}>{idLabel ? idLabel(from) : from}</option>
|
||||
) : null}
|
||||
{ids.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
{idLabel ? idLabel(id) : id}
|
||||
</option>
|
||||
))}
|
||||
</FormSelect>
|
||||
@@ -874,8 +901,20 @@ function ScriptTryDialog({
|
||||
}
|
||||
>
|
||||
<div className="flex shrink-0 items-start gap-2">
|
||||
{!isSet && script ? (
|
||||
<ScriptIcon
|
||||
name={script}
|
||||
hasIcon={existing.data?.hasIcon}
|
||||
className="size-8 shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-bold font-mono">{isSet ? "set" : script}</h3>
|
||||
<h3 className={stepCustomName(step) ? "font-bold" : "font-bold font-mono"}>
|
||||
{stepDisplayName(step, isSet ? "set" : script)}
|
||||
</h3>
|
||||
{stepCustomName(step) && script ? (
|
||||
<p className="text-xs font-mono opacity-60">{isSet ? "set" : script}</p>
|
||||
) : null}
|
||||
<p className="text-sm opacity-70">
|
||||
{isSet
|
||||
? "Dry-run this set with editable data, context, and expression. Does not create an event. Use Apply to card to write the expression back to the step."
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import { ScriptIcon } from "../../ScriptIcon.jsx";
|
||||
|
||||
export function TriggerGraphNode({ data, selected }) {
|
||||
return (
|
||||
@@ -19,6 +20,7 @@ export function TriggerGraphNode({ data, selected }) {
|
||||
|
||||
export function StepGraphNode({ data, selected }) {
|
||||
const kindLabel = data.kind === "set" ? "set" : data.profile ? "profile" : "script";
|
||||
const iconScript = data.iconScript || "";
|
||||
return (
|
||||
<div
|
||||
className={`relative min-w-48 max-w-60 rounded-box border bg-base-100 px-3 py-2 shadow-sm ${
|
||||
@@ -36,28 +38,42 @@ export function StepGraphNode({ data, selected }) {
|
||||
className="nodrag nopan !h-3.5 !w-3.5 !bg-primary"
|
||||
isConnectable
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-[10px] uppercase tracking-wide opacity-60">{kindLabel}</p>
|
||||
{data.stepId ? (
|
||||
<span className="badge badge-ghost badge-xs font-mono">{data.stepId}</span>
|
||||
) : null}
|
||||
{data.when ? (
|
||||
<span className="badge badge-warning badge-xs" title={data.when}>
|
||||
when
|
||||
</span>
|
||||
) : null}
|
||||
{data.needsMode === "map" ? (
|
||||
<span className="badge badge-info badge-xs" title="Named needs — edit in inspector">
|
||||
map
|
||||
</span>
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
{data.kind !== "set" && iconScript ? (
|
||||
<ScriptIcon
|
||||
name={iconScript}
|
||||
hasIcon={data.hasIcon}
|
||||
className="size-7 shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-[10px] uppercase tracking-wide opacity-60">{kindLabel}</p>
|
||||
{data.stepId ? (
|
||||
<span className="badge badge-ghost badge-xs font-mono">{data.stepId}</span>
|
||||
) : null}
|
||||
{data.when ? (
|
||||
<span className="badge badge-warning badge-xs" title={data.when}>
|
||||
when
|
||||
</span>
|
||||
) : null}
|
||||
{data.needsMode === "map" ? (
|
||||
<span className="badge badge-info badge-xs" title="Named needs — edit in inspector">
|
||||
map
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p
|
||||
className={`text-sm leading-tight truncate ${data.named ? "font-semibold" : "font-mono"}`}
|
||||
title={data.label}
|
||||
>
|
||||
{data.label}
|
||||
</p>
|
||||
{data.missingNeeds?.length ? (
|
||||
<p className="text-error text-[11px] mt-0.5">Unknown needs: {data.missingNeeds.join(", ")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-mono text-sm leading-tight truncate" title={data.label}>
|
||||
{data.label}
|
||||
</p>
|
||||
{data.missingNeeds?.length ? (
|
||||
<p className="text-error text-[11px] mt-0.5">Unknown needs: {data.missingNeeds.join(", ")}</p>
|
||||
) : null}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
|
||||
Reference in New Issue
Block a user