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:
@@ -138,13 +138,14 @@ Injected: `log` (pino), `console`, `fetch`, `require`, `$axios`, `$kv`, `$finger
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
scripts:
|
scripts:
|
||||||
- script: plugin/my-plugin
|
- name: Notify to channel
|
||||||
|
script: plugin/my-plugin
|
||||||
config:
|
config:
|
||||||
url: http://10.8.0.6:3030/notes
|
url: http://10.8.0.6:3030/notes
|
||||||
token: $SECRET_joplin_api_token
|
token: $SECRET_joplin_api_token
|
||||||
```
|
```
|
||||||
|
|
||||||
Canonical ref is `plugin/<id>` (`.js` suffix is optional).
|
Optional `name` is the display title in the editor and graph (falls back to the script filename). Canonical ref is `plugin/<id>` (`.js` suffix is optional).
|
||||||
|
|
||||||
## Do not
|
## Do not
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function readScript(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Icon next to the script: `fetch-html.js` → `fetch-html.png` or `.jpg`.
|
* Icon next to the script: `fetch-html.js` → `fetch-html.png`, `.jpg`, or `.jpeg`.
|
||||||
* @returns {{ filePath: string, contentType: string } | null}
|
* @returns {{ filePath: string, contentType: string } | null}
|
||||||
*/
|
*/
|
||||||
export function resolveScriptIcon(name) {
|
export function resolveScriptIcon(name) {
|
||||||
@@ -59,6 +59,7 @@ export function resolveScriptIcon(name) {
|
|||||||
for (const { ext, contentType } of [
|
for (const { ext, contentType } of [
|
||||||
{ ext: "png", contentType: "image/png" },
|
{ ext: "png", contentType: "image/png" },
|
||||||
{ ext: "jpg", contentType: "image/jpeg" },
|
{ ext: "jpg", contentType: "image/jpeg" },
|
||||||
|
{ ext: "jpeg", contentType: "image/jpeg" },
|
||||||
]) {
|
]) {
|
||||||
const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`);
|
const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`);
|
||||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -9,9 +9,10 @@ export const SET_STEP_SCRIPT = "set";
|
|||||||
* kind: "script",
|
* kind: "script",
|
||||||
* script: string,
|
* script: string,
|
||||||
* profile: string | null,
|
* profile: string | null,
|
||||||
* config: unknown | null,
|
* config: unknown | null,
|
||||||
* expression?: undefined,
|
* expression?: undefined,
|
||||||
* id: string | null,
|
* name: string | null,
|
||||||
|
* id: string | null,
|
||||||
* needsKind: "none" | "list" | "map",
|
* needsKind: "none" | "list" | "map",
|
||||||
* needs: NeedEdge[],
|
* needs: NeedEdge[],
|
||||||
* when: string | null,
|
* when: string | null,
|
||||||
@@ -21,8 +22,9 @@ export const SET_STEP_SCRIPT = "set";
|
|||||||
* script: typeof SET_STEP_SCRIPT,
|
* script: typeof SET_STEP_SCRIPT,
|
||||||
* profile: null,
|
* profile: null,
|
||||||
* config: { expression: string },
|
* config: { expression: string },
|
||||||
* expression: string,
|
* expression: string,
|
||||||
* id: string | null,
|
* name: string | null,
|
||||||
|
* id: string | null,
|
||||||
* needsKind: "none" | "list" | "map",
|
* needsKind: "none" | "list" | "map",
|
||||||
* needs: NeedEdge[],
|
* needs: NeedEdge[],
|
||||||
* when: string | null,
|
* when: string | null,
|
||||||
@@ -80,6 +82,7 @@ export function parseScriptStep(step) {
|
|||||||
script: step,
|
script: step,
|
||||||
profile: null,
|
profile: null,
|
||||||
config: null,
|
config: null,
|
||||||
|
name: null,
|
||||||
id: null,
|
id: null,
|
||||||
needsKind: "none",
|
needsKind: "none",
|
||||||
needs: [],
|
needs: [],
|
||||||
@@ -119,6 +122,7 @@ export function parseScriptStep(step) {
|
|||||||
script: hasScript ? step.script : "",
|
script: hasScript ? step.script : "",
|
||||||
profile: hasProfile ? step.profile : null,
|
profile: hasProfile ? step.profile : null,
|
||||||
config: step.config ?? null,
|
config: step.config ?? null,
|
||||||
|
name: parseOptionalName(step.name),
|
||||||
id: parseOptionalId(step.id),
|
id: parseOptionalId(step.id),
|
||||||
needsKind,
|
needsKind,
|
||||||
needs,
|
needs,
|
||||||
@@ -271,6 +275,7 @@ function parseSetStep(step) {
|
|||||||
profile: null,
|
profile: null,
|
||||||
config: { expression },
|
config: { expression },
|
||||||
expression,
|
expression,
|
||||||
|
name: parseOptionalName(step.name),
|
||||||
id: parseOptionalId(step.id),
|
id: parseOptionalId(step.id),
|
||||||
needsKind,
|
needsKind,
|
||||||
needs,
|
needs,
|
||||||
@@ -291,6 +296,19 @@ function parseWhen(when) {
|
|||||||
return when;
|
return when;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} name
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
function parseOptionalName(name) {
|
||||||
|
if (name == null || name === "") return null;
|
||||||
|
if (typeof name !== "string") {
|
||||||
|
throw new Error(`Invalid step name: ${JSON.stringify(name)}`);
|
||||||
|
}
|
||||||
|
const trimmed = name.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {unknown} id
|
* @param {unknown} id
|
||||||
* @returns {string | null}
|
* @returns {string | null}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"test": "node --test src/lib/workflow-graph.test.js src/lib/try-session.test.js"
|
"test": "node --test src/lib/workflow-graph.test.js src/lib/try-session.test.js src/lib/workflow-doc.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export function scriptIconSrc(name) {
|
|||||||
return `/api/scripts/${encodeURIComponent(name)}/icon`;
|
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" }) {
|
export function ScriptIcon({ name, hasIcon, className = "size-10" }) {
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ import "@xyflow/react/dist/style.css";
|
|||||||
|
|
||||||
const nodeTypes = { trigger: TriggerGraphNode, step: StepGraphNode };
|
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). */
|
/** Two Control zoom-out steps from React Flow's default zoom (1). */
|
||||||
const INITIAL_ZOOM = 1 / 1.2 ** 2;
|
const INITIAL_ZOOM = 1 / 1.2 ** 2;
|
||||||
const FIT_VIEW = { padding: 0.2, maxZoom: INITIAL_ZOOM, duration: 200 };
|
const FIT_VIEW = { padding: 0.2, maxZoom: INITIAL_ZOOM, duration: 200 };
|
||||||
@@ -87,28 +101,6 @@ function GraphTabInner({
|
|||||||
const steps = doc?.scripts ?? [];
|
const steps = doc?.scripts ?? [];
|
||||||
const triggers = doc?.triggers ?? [];
|
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 scriptsByName = useMemo(() => {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const s of scripts) {
|
for (const s of scripts) {
|
||||||
@@ -126,6 +118,29 @@ function GraphTabInner({
|
|||||||
return map;
|
return map;
|
||||||
}, [profiles]);
|
}, [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(
|
const displayEdges = useMemo(
|
||||||
() =>
|
() =>
|
||||||
edges.map((e) => ({
|
edges.map((e) => ({
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { LogViewer } from "../LogViewer.jsx";
|
|||||||
import { StatusBadge } from "../../lib/format";
|
import { StatusBadge } from "../../lib/format";
|
||||||
import { prettyJson } from "../../lib/script.js";
|
import { prettyJson } from "../../lib/script.js";
|
||||||
import { seedTryDialog, stepTryLabel } from "../../lib/try-session.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 {
|
import {
|
||||||
stepPredecessors,
|
stepPredecessors,
|
||||||
stepSuccessors,
|
stepSuccessors,
|
||||||
@@ -117,7 +117,9 @@ function ScriptCardView({
|
|||||||
step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId);
|
step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId);
|
||||||
const preview = previewConfigValue(mergedConfig, meta?.previewConfigKey);
|
const preview = previewConfigValue(mergedConfig, meta?.previewConfigKey);
|
||||||
const previewFull = configValueText(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 titleFull = previewFull ? `${baseName} (${previewFull})` : baseName;
|
||||||
const setConfig =
|
const setConfig =
|
||||||
step.kind === "set" ? { expression: step.expression ?? "" } : null;
|
step.kind === "set" ? { expression: step.expression ?? "" } : null;
|
||||||
@@ -180,10 +182,18 @@ function ScriptCardView({
|
|||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<h3
|
<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}
|
title={titleFull}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate">{baseName}</span>
|
<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 ? (
|
{!expanded && preview ? (
|
||||||
<span
|
<span
|
||||||
className="badge badge-secondary badge-sm shrink-0 truncate font-mono font-normal"
|
className="badge badge-secondary badge-sm shrink-0 truncate font-mono font-normal"
|
||||||
@@ -275,6 +285,15 @@ function ScriptCardView({
|
|||||||
|
|
||||||
{expanded ? (
|
{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" ? (
|
{step.kind === "set" ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FieldLabel
|
<FieldLabel
|
||||||
@@ -469,9 +488,11 @@ function ConfigJsonEditor({ config, disabled, onChange }) {
|
|||||||
|
|
||||||
function NeedsEditor({ step, otherSteps, disabled, onChange }) {
|
function NeedsEditor({ step, otherSteps, disabled, onChange }) {
|
||||||
const mode = needsMode(step.needs);
|
const mode = needsMode(step.needs);
|
||||||
const ids = otherSteps
|
const others = otherSteps.filter((s) => s.id && s.uiId !== step.uiId);
|
||||||
.filter((s) => s.id && s.uiId !== step.uiId)
|
const ids = others.map((s) => s.id);
|
||||||
.map((s) => s.id);
|
const nameById = new Map(
|
||||||
|
others.map((s) => [s.id, stepCustomName(s) || null]),
|
||||||
|
);
|
||||||
|
|
||||||
function setMode(next) {
|
function setMode(next) {
|
||||||
if (next === "none") onChange({ ...step, needs: null });
|
if (next === "none") onChange({ ...step, needs: null });
|
||||||
@@ -479,6 +500,11 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
|
|||||||
else onChange({ ...step, needs: {}, when: "" });
|
else onChange({ ...step, needs: {}, when: "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function idLabel(id) {
|
||||||
|
const named = nameById.get(id);
|
||||||
|
return named ? `${id} · ${named}` : id;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="label py-0 text-sm">needs</span>
|
<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>
|
</label>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -531,6 +557,7 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
|
|||||||
<NeedsMap
|
<NeedsMap
|
||||||
needs={step.needs && !Array.isArray(step.needs) ? step.needs : {}}
|
needs={step.needs && !Array.isArray(step.needs) ? step.needs : {}}
|
||||||
ids={ids}
|
ids={ids}
|
||||||
|
idLabel={idLabel}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(needs) => onChange({ ...step, needs })}
|
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 ?? {});
|
const entries = Object.entries(needs ?? {});
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -564,11 +591,11 @@ function NeedsMap({ needs, ids, disabled, onChange }) {
|
|||||||
onChange={(e) => onChange({ ...needs, [alias]: e.target.value })}
|
onChange={(e) => onChange({ ...needs, [alias]: e.target.value })}
|
||||||
>
|
>
|
||||||
{from && !ids.includes(from) ? (
|
{from && !ids.includes(from) ? (
|
||||||
<option value={from}>{from}</option>
|
<option value={from}>{idLabel ? idLabel(from) : from}</option>
|
||||||
) : null}
|
) : null}
|
||||||
{ids.map((id) => (
|
{ids.map((id) => (
|
||||||
<option key={id} value={id}>
|
<option key={id} value={id}>
|
||||||
{id}
|
{idLabel ? idLabel(id) : id}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</FormSelect>
|
</FormSelect>
|
||||||
@@ -874,8 +901,20 @@ function ScriptTryDialog({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex shrink-0 items-start gap-2">
|
<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">
|
<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">
|
<p className="text-sm opacity-70">
|
||||||
{isSet
|
{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."
|
? "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 { Handle, Position } from "@xyflow/react";
|
||||||
|
import { ScriptIcon } from "../../ScriptIcon.jsx";
|
||||||
|
|
||||||
export function TriggerGraphNode({ data, selected }) {
|
export function TriggerGraphNode({ data, selected }) {
|
||||||
return (
|
return (
|
||||||
@@ -19,6 +20,7 @@ export function TriggerGraphNode({ data, selected }) {
|
|||||||
|
|
||||||
export function StepGraphNode({ data, selected }) {
|
export function StepGraphNode({ data, selected }) {
|
||||||
const kindLabel = data.kind === "set" ? "set" : data.profile ? "profile" : "script";
|
const kindLabel = data.kind === "set" ? "set" : data.profile ? "profile" : "script";
|
||||||
|
const iconScript = data.iconScript || "";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative min-w-48 max-w-60 rounded-box border bg-base-100 px-3 py-2 shadow-sm ${
|
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"
|
className="nodrag nopan !h-3.5 !w-3.5 !bg-primary"
|
||||||
isConnectable
|
isConnectable
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-start gap-2 min-w-0">
|
||||||
<p className="text-[10px] uppercase tracking-wide opacity-60">{kindLabel}</p>
|
{data.kind !== "set" && iconScript ? (
|
||||||
{data.stepId ? (
|
<ScriptIcon
|
||||||
<span className="badge badge-ghost badge-xs font-mono">{data.stepId}</span>
|
name={iconScript}
|
||||||
) : null}
|
hasIcon={data.hasIcon}
|
||||||
{data.when ? (
|
className="size-7 shrink-0"
|
||||||
<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}
|
) : 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>
|
</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
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { contextFromMeta } from "./script.js";
|
import { contextFromMeta } from "./script.js";
|
||||||
import { isDagDoc, isEmptyNeeds, needsMode } from "./workflow-doc.js";
|
import { isDagDoc, isEmptyNeeds, needsMode, stepDisplayName } from "./workflow-doc.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{
|
* @typedef {{
|
||||||
@@ -89,16 +89,16 @@ export function pruneTrySession(session, steps) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Human-readable step label for seed hints.
|
* Human-readable step label for seed hints.
|
||||||
* @param {{ index?: number, kind?: string, script?: string, profile?: string, id?: string }} step
|
* @param {{ index?: number, kind?: string, script?: string, profile?: string, id?: string, name?: string }} step
|
||||||
* @param {number} [index]
|
* @param {number} [index]
|
||||||
*/
|
*/
|
||||||
export function stepTryLabel(step, index) {
|
export function stepTryLabel(step, index) {
|
||||||
const n = index != null ? index + 1 : (step.index ?? 0) + 1;
|
const n = index != null ? index + 1 : (step.index ?? 0) + 1;
|
||||||
const id = typeof step.id === "string" && step.id.trim() ? step.id.trim() : null;
|
const id = typeof step.id === "string" && step.id.trim() ? step.id.trim() : null;
|
||||||
const name =
|
const name = stepDisplayName(
|
||||||
step.kind === "set"
|
step,
|
||||||
? "set"
|
step.kind === "set" ? "set" : step.profile || step.script || "step",
|
||||||
: step.profile || step.script || "step";
|
);
|
||||||
if (id) return `step ${n} / ${id} (${name})`;
|
if (id) return `step ${n} / ${id} (${name})`;
|
||||||
return `step ${n} / ${name}`;
|
return `step ${n} / ${name}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
pruneTrySession,
|
pruneTrySession,
|
||||||
recordTrySuccess,
|
recordTrySuccess,
|
||||||
seedTryDialog,
|
seedTryDialog,
|
||||||
|
stepTryLabel,
|
||||||
} from "./try-session.js";
|
} from "./try-session.js";
|
||||||
|
|
||||||
function step(uiId, extra = {}) {
|
function step(uiId, extra = {}) {
|
||||||
@@ -166,3 +167,17 @@ test("pruneTrySession drops removed steps", () => {
|
|||||||
assert.equal(pruned.byStep.b, undefined);
|
assert.equal(pruned.byStep.b, undefined);
|
||||||
assert.equal(pruned.lastTriedUiId, null);
|
assert.equal(pruned.lastTriedUiId, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("stepTryLabel uses custom name", () => {
|
||||||
|
assert.equal(
|
||||||
|
stepTryLabel({ kind: "script", script: "ntfy.js", name: "Notify to channel" }, 0),
|
||||||
|
"step 1 / Notify to channel",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
stepTryLabel(
|
||||||
|
{ kind: "script", script: "ntfy.js", id: "notify", name: "Notify to channel" },
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"step 3 / notify (Notify to channel)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -105,6 +105,19 @@ export function isDagDoc(doc) {
|
|||||||
return (doc?.scripts ?? []).some((s) => !isEmptyNeeds(s.needs));
|
return (doc?.scripts ?? []).some((s) => !isEmptyNeeds(s.needs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stepCustomName(step) {
|
||||||
|
return typeof step?.name === "string" ? step.name.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepDisplayName(step, fallback) {
|
||||||
|
const custom = stepCustomName(step);
|
||||||
|
if (custom) return custom;
|
||||||
|
if (fallback) return fallback;
|
||||||
|
if (step?.kind === "set") return "set";
|
||||||
|
if (step?.profile) return `profile ${step.profile}`;
|
||||||
|
return step?.script || "untitled";
|
||||||
|
}
|
||||||
|
|
||||||
export function newScriptStep(script, config = {}) {
|
export function newScriptStep(script, config = {}) {
|
||||||
return {
|
return {
|
||||||
uiId: nextUiId("step"),
|
uiId: nextUiId("step"),
|
||||||
@@ -112,6 +125,7 @@ export function newScriptStep(script, config = {}) {
|
|||||||
script,
|
script,
|
||||||
profile: "",
|
profile: "",
|
||||||
config: config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {},
|
config: config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {},
|
||||||
|
name: "",
|
||||||
id: "",
|
id: "",
|
||||||
when: "",
|
when: "",
|
||||||
needs: null,
|
needs: null,
|
||||||
@@ -125,6 +139,7 @@ export function newProfileStep(profileName, script = "") {
|
|||||||
script,
|
script,
|
||||||
profile: profileName,
|
profile: profileName,
|
||||||
config: {},
|
config: {},
|
||||||
|
name: "",
|
||||||
id: "",
|
id: "",
|
||||||
when: "",
|
when: "",
|
||||||
needs: null,
|
needs: null,
|
||||||
@@ -137,6 +152,7 @@ export function newSetStep() {
|
|||||||
kind: "set",
|
kind: "set",
|
||||||
script: "set",
|
script: "set",
|
||||||
expression: "",
|
expression: "",
|
||||||
|
name: "",
|
||||||
id: "",
|
id: "",
|
||||||
when: "",
|
when: "",
|
||||||
needs: null,
|
needs: null,
|
||||||
@@ -200,6 +216,10 @@ function readOnFailureWorkflow(raw) {
|
|||||||
return typeof raw?.onFailureWorkflow === "string" ? raw.onFailureWorkflow : "";
|
return typeof raw?.onFailureWorkflow === "string" ? raw.onFailureWorkflow : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readStepName(raw) {
|
||||||
|
return typeof raw === "string" ? raw : "";
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeStep(step) {
|
function normalizeStep(step) {
|
||||||
const uiId = nextUiId("step");
|
const uiId = nextUiId("step");
|
||||||
if (typeof step === "string") {
|
if (typeof step === "string") {
|
||||||
@@ -209,6 +229,7 @@ function normalizeStep(step) {
|
|||||||
script: step,
|
script: step,
|
||||||
profile: "",
|
profile: "",
|
||||||
config: {},
|
config: {},
|
||||||
|
name: "",
|
||||||
id: "",
|
id: "",
|
||||||
when: "",
|
when: "",
|
||||||
needs: null,
|
needs: null,
|
||||||
@@ -221,6 +242,7 @@ function normalizeStep(step) {
|
|||||||
script: "",
|
script: "",
|
||||||
profile: "",
|
profile: "",
|
||||||
config: {},
|
config: {},
|
||||||
|
name: "",
|
||||||
id: "",
|
id: "",
|
||||||
when: "",
|
when: "",
|
||||||
needs: null,
|
needs: null,
|
||||||
@@ -233,12 +255,13 @@ function normalizeStep(step) {
|
|||||||
kind: "set",
|
kind: "set",
|
||||||
script: "set",
|
script: "set",
|
||||||
expression: typeof spec.expression === "string" ? spec.expression : "",
|
expression: typeof spec.expression === "string" ? spec.expression : "",
|
||||||
|
name: readStepName(step.name),
|
||||||
id: typeof step.id === "string" ? step.id : "",
|
id: typeof step.id === "string" ? step.id : "",
|
||||||
when: typeof step.when === "string" ? step.when : "",
|
when: typeof step.when === "string" ? step.when : "",
|
||||||
needs: step.needs ?? null,
|
needs: step.needs ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const known = new Set(["script", "profile", "config", "id", "when", "needs", "set"]);
|
const known = new Set(["script", "profile", "config", "name", "id", "when", "needs", "set"]);
|
||||||
/** @type {Record<string, unknown>} */
|
/** @type {Record<string, unknown>} */
|
||||||
const extra = {};
|
const extra = {};
|
||||||
for (const [key, value] of Object.entries(step)) {
|
for (const [key, value] of Object.entries(step)) {
|
||||||
@@ -254,6 +277,7 @@ function normalizeStep(step) {
|
|||||||
script: typeof step.script === "string" ? step.script : "",
|
script: typeof step.script === "string" ? step.script : "",
|
||||||
profile: typeof step.profile === "string" ? step.profile : "",
|
profile: typeof step.profile === "string" ? step.profile : "",
|
||||||
config,
|
config,
|
||||||
|
name: readStepName(step.name),
|
||||||
id: typeof step.id === "string" ? step.id : "",
|
id: typeof step.id === "string" ? step.id : "",
|
||||||
when: typeof step.when === "string" ? step.when : "",
|
when: typeof step.when === "string" ? step.when : "",
|
||||||
needs: step.needs ?? null,
|
needs: step.needs ?? null,
|
||||||
@@ -306,10 +330,16 @@ function normalizeTrigger(raw) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dumpStepName(step, out) {
|
||||||
|
const name = stepCustomName(step);
|
||||||
|
if (name) out.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
function dumpStep(step) {
|
function dumpStep(step) {
|
||||||
if (step.kind === "set") {
|
if (step.kind === "set") {
|
||||||
/** @type {Record<string, unknown>} */
|
/** @type {Record<string, unknown>} */
|
||||||
const out = {};
|
const out = {};
|
||||||
|
dumpStepName(step, out);
|
||||||
if (step.id) out.id = step.id;
|
if (step.id) out.id = step.id;
|
||||||
out.set = {
|
out.set = {
|
||||||
expression: step.expression ?? "",
|
expression: step.expression ?? "",
|
||||||
@@ -320,6 +350,7 @@ function dumpStep(step) {
|
|||||||
}
|
}
|
||||||
/** @type {Record<string, unknown>} */
|
/** @type {Record<string, unknown>} */
|
||||||
const out = {};
|
const out = {};
|
||||||
|
dumpStepName(step, out);
|
||||||
if (step.id) out.id = step.id;
|
if (step.id) out.id = step.id;
|
||||||
if (step.profile) {
|
if (step.profile) {
|
||||||
out.profile = step.profile;
|
out.profile = step.profile;
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
parseWorkflowYaml,
|
||||||
|
stringifyWorkflowDoc,
|
||||||
|
stepCustomName,
|
||||||
|
stepDisplayName,
|
||||||
|
} from "./workflow-doc.js";
|
||||||
|
|
||||||
|
test("step name round-trips in yaml", () => {
|
||||||
|
const { doc, parseError } = parseWorkflowYaml(`
|
||||||
|
name: demo
|
||||||
|
scripts:
|
||||||
|
- name: Notify to channel
|
||||||
|
script: ntfy.js
|
||||||
|
config:
|
||||||
|
url: https://ntfy.sh/demo
|
||||||
|
- name: Transform
|
||||||
|
set:
|
||||||
|
expression: data
|
||||||
|
`);
|
||||||
|
assert.equal(parseError, null);
|
||||||
|
assert.equal(doc.scripts[0].name, "Notify to channel");
|
||||||
|
assert.equal(doc.scripts[0].script, "ntfy.js");
|
||||||
|
assert.equal(doc.scripts[0].extra, undefined);
|
||||||
|
assert.equal(doc.scripts[1].name, "Transform");
|
||||||
|
assert.equal(doc.scripts[1].kind, "set");
|
||||||
|
|
||||||
|
const yaml = stringifyWorkflowDoc(doc);
|
||||||
|
assert.match(yaml, /name: Notify to channel/);
|
||||||
|
assert.match(yaml, /script: ntfy.js/);
|
||||||
|
assert.match(yaml, /name: Transform/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty step name is omitted from yaml", () => {
|
||||||
|
const { doc } = parseWorkflowYaml(`
|
||||||
|
scripts:
|
||||||
|
- name: " "
|
||||||
|
script: ntfy.js
|
||||||
|
`);
|
||||||
|
assert.equal(doc.scripts[0].name, " ");
|
||||||
|
assert.equal(stepCustomName(doc.scripts[0]), "");
|
||||||
|
const yaml = stringifyWorkflowDoc(doc);
|
||||||
|
assert.doesNotMatch(yaml, /^\s*name:/m);
|
||||||
|
assert.match(yaml, /script: ntfy.js/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stepDisplayName prefers custom name", () => {
|
||||||
|
assert.equal(
|
||||||
|
stepDisplayName({ kind: "script", script: "ntfy.js", name: "Notify to channel" }),
|
||||||
|
"Notify to channel",
|
||||||
|
);
|
||||||
|
assert.equal(stepDisplayName({ kind: "script", script: "ntfy.js" }), "ntfy.js");
|
||||||
|
assert.equal(stepDisplayName({ kind: "set" }), "set");
|
||||||
|
assert.equal(
|
||||||
|
stepDisplayName({ kind: "script", script: "ntfy.js" }, "fallback.js"),
|
||||||
|
"fallback.js",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isDagDoc, isEmptyNeeds, needsMode, nextStepId } from "./workflow-doc.js";
|
import { isDagDoc, isEmptyNeeds, needsMode, nextStepId, stepCustomName } from "./workflow-doc.js";
|
||||||
|
|
||||||
export const STEP_NODE_PREFIX = "step:";
|
export const STEP_NODE_PREFIX = "step:";
|
||||||
export const TRIGGER_NODE_PREFIX = "trig:";
|
export const TRIGGER_NODE_PREFIX = "trig:";
|
||||||
@@ -44,6 +44,8 @@ export function triggerSummary(trigger, owner) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function stepLabel(step) {
|
export function stepLabel(step) {
|
||||||
|
const custom = stepCustomName(step);
|
||||||
|
if (custom) return custom;
|
||||||
if (step?.kind === "set") return step.id ? `${step.id}: set` : "set";
|
if (step?.kind === "set") return step.id ? `${step.id}: set` : "set";
|
||||||
const target = step?.profile ? `profile ${step.profile}` : step?.script || "untitled";
|
const target = step?.profile ? `profile ${step.profile}` : step?.script || "untitled";
|
||||||
return step?.id ? `${step.id}: ${target}` : target;
|
return step?.id ? `${step.id}: ${target}` : target;
|
||||||
@@ -254,6 +256,7 @@ export function buildGraphElements(doc, positions = {}) {
|
|||||||
data: {
|
data: {
|
||||||
layoutKey,
|
layoutKey,
|
||||||
label: stepLabel(s),
|
label: stepLabel(s),
|
||||||
|
named: Boolean(stepCustomName(s)),
|
||||||
kind: s.kind,
|
kind: s.kind,
|
||||||
stepId: s.id || "",
|
stepId: s.id || "",
|
||||||
when: s.when || "",
|
when: s.when || "",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
canConnectSteps,
|
canConnectSteps,
|
||||||
enteringDagWouldStripWhen,
|
enteringDagWouldStripWhen,
|
||||||
removeStepEdge,
|
removeStepEdge,
|
||||||
|
stepLabel,
|
||||||
stepPredecessors,
|
stepPredecessors,
|
||||||
stepSuccessors,
|
stepSuccessors,
|
||||||
wouldCreateCycle,
|
wouldCreateCycle,
|
||||||
@@ -113,3 +114,15 @@ test("stepSuccessors / stepPredecessors DAG multi", () => {
|
|||||||
assert.deepEqual(stepPredecessors(scripts, "d"), ["a"]);
|
assert.deepEqual(stepPredecessors(scripts, "d"), ["a"]);
|
||||||
assert.deepEqual(stepPredecessors(scripts, "a"), []);
|
assert.deepEqual(stepPredecessors(scripts, "a"), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("stepLabel uses custom name", () => {
|
||||||
|
assert.equal(
|
||||||
|
stepLabel({ kind: "script", script: "ntfy.js", name: "Notify to channel" }),
|
||||||
|
"Notify to channel",
|
||||||
|
);
|
||||||
|
assert.equal(stepLabel({ kind: "script", script: "ntfy.js", id: "n" }), "n: ntfy.js");
|
||||||
|
assert.equal(
|
||||||
|
stepLabel({ kind: "set", id: "xform", name: "Shape payload" }),
|
||||||
|
"Shape payload",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user