feat(workflows): add Gmail sending and testing workflows
- Introduced a new `send-gmail` workflow for sending emails via Gmail SMTP, with configurable parameters for recipients, subject, and body content. - Added a `test-send-gmail` workflow to facilitate testing the email sending functionality with sample data. - Implemented a `track` workflow to monitor Gojek trips, enhancing the server's capabilities for tracking and notifications. - Updated the workflow management interface to include new workflows and improved organization of existing scripts. Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
@@ -114,13 +114,14 @@ async function fetchHttp(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fetchHttp.meta = {
|
fetchHttp.meta = {
|
||||||
description: "Fetch a URL and store the response body as-is (JSON stays an object)",
|
description: "Fetch a URL and store the response body.",
|
||||||
config: {
|
config: {
|
||||||
url: { type: "string", required: true, description: "Request URL" },
|
url: { type: "string", required: true, description: "Request URL" },
|
||||||
method: {
|
method: {
|
||||||
type: "string",
|
type: "string",
|
||||||
default: "GET",
|
default: "GET",
|
||||||
description: "HTTP method (GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS)",
|
enum: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
|
description: "HTTP method",
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -134,7 +135,12 @@ fetchHttp.meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
method: { type: "string", required: false, description: "Fallback when config.method is omitted" },
|
method: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
enum: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||||
|
description: "Fallback when config.method is omitted",
|
||||||
|
},
|
||||||
headers: {
|
headers: {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: false,
|
required: false,
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ scripts:
|
|||||||
- comic-monkeyuser-to-ntfy.yaml
|
- comic-monkeyuser-to-ntfy.yaml
|
||||||
- time-and-comic-to-ntfy.yaml
|
- time-and-comic-to-ntfy.yaml
|
||||||
- rss-selfhst-to-ntfy.yaml
|
- rss-selfhst-to-ntfy.yaml
|
||||||
|
- send-gmail.yaml
|
||||||
|
- test-send-gmail.yaml
|
||||||
|
- track.yaml
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: send-gmail
|
||||||
|
description: |
|
||||||
|
Send email via Gmail SMTP. Configure user/from and the named secret,
|
||||||
|
then call from other workflows with trigger-workflow.js (name: send-gmail).
|
||||||
|
|
||||||
|
Input (data):
|
||||||
|
to optional recipient(s); defaults to your Gmail below
|
||||||
|
subject required
|
||||||
|
text plain body (aliases: body, message)
|
||||||
|
html optional HTML body
|
||||||
|
from, cc, bcc, replyTo, priority, headers optional
|
||||||
|
scripts:
|
||||||
|
- script: jsonata.js
|
||||||
|
config:
|
||||||
|
expression: |
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"to": $exists(to) ? to : "nasyarobby@gmail.com",
|
||||||
|
"from": from,
|
||||||
|
"subject": subject,
|
||||||
|
"text": $exists(text) ? text : ($exists(body) ? body : message),
|
||||||
|
"html": html,
|
||||||
|
"cc": cc,
|
||||||
|
"bcc": bcc,
|
||||||
|
"replyTo": replyTo,
|
||||||
|
"priority": priority,
|
||||||
|
"headers": headers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
- script: send-email.js
|
||||||
|
config:
|
||||||
|
service: Gmail
|
||||||
|
user: elevent16th@gmail.com
|
||||||
|
from: elevent16th@gmail.com
|
||||||
|
passwordSecret: gmail_app_password
|
||||||
|
triggers:
|
||||||
|
- type: workflow
|
||||||
|
- type: HTTP
|
||||||
|
method: POST
|
||||||
|
path: /send-gmail
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: test-send-gmail
|
||||||
|
description: |
|
||||||
|
Kick send-gmail with sample data. Edit the payload below, then Run
|
||||||
|
(or POST /u/default/test-send-gmail).
|
||||||
|
scripts:
|
||||||
|
- script: trigger-workflow.js
|
||||||
|
config:
|
||||||
|
name: send-gmail
|
||||||
|
expression: |
|
||||||
|
{
|
||||||
|
"subject": "scrunner test",
|
||||||
|
"text": "Hello from test-send-gmail",
|
||||||
|
"html": "<p>Hello from <strong>test-send-gmail</strong></p>"
|
||||||
|
}
|
||||||
|
triggers:
|
||||||
|
- type: HTTP
|
||||||
|
method: POST
|
||||||
|
path: /test-send-gmail
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: Track Gojek Trip
|
||||||
|
scripts:
|
||||||
|
- script: fetch-http.js
|
||||||
|
config:
|
||||||
|
url: https://api.gojekapi.com/live/track/491c3132c2cc7043
|
||||||
|
method: GET
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
Origin: https://track.gojek.com
|
||||||
|
- script: fingerprint.js
|
||||||
|
config:
|
||||||
|
key: gojek-track-status
|
||||||
|
jsonata: data.httpResponse.status
|
||||||
|
- script: jsonata.js
|
||||||
|
config:
|
||||||
|
expression: |
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"title": httpResponse.customer_name,
|
||||||
|
"message": httpResponse.customer_name & " : " & httpResponse.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
- script: ntfy.js
|
||||||
|
config:
|
||||||
|
url: https://n.0dev.web.id/system
|
||||||
|
triggers:
|
||||||
|
- type: cron
|
||||||
|
schedule: "* * * * *"
|
||||||
|
- type: HTTP
|
||||||
|
method: GET
|
||||||
|
path: /new
|
||||||
@@ -9,9 +9,13 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@tanstack/react-query": "^5.84.0",
|
"@tanstack/react-query": "^5.84.0",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
"cronstrue": "^3.24.0",
|
||||||
"mermaid": "^11.9.0",
|
"mermaid": "^11.9.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ function FieldTable({ title, fields }) {
|
|||||||
const notes = [
|
const notes = [
|
||||||
field.required ? "required" : null,
|
field.required ? "required" : null,
|
||||||
field.default !== undefined ? `default ${JSON.stringify(field.default)}` : null,
|
field.default !== undefined ? `default ${JSON.stringify(field.default)}` : null,
|
||||||
|
Array.isArray(field.enum)
|
||||||
|
? `enum ${field.enum.map((x) => (x && typeof x === "object" ? x.value : x)).join(", ")}`
|
||||||
|
: Array.isArray(field.options)
|
||||||
|
? `enum ${field.options.map((x) => (x && typeof x === "object" ? x.value : x)).join(", ")}`
|
||||||
|
: null,
|
||||||
field.description,
|
field.description,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useScripts } from "../../api/hooks.js";
|
||||||
|
|
||||||
|
export function AddScriptDialog({ open, onClose, onPick }) {
|
||||||
|
const { data: scripts = [], isLoading } = useScripts();
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const term = q.trim().toLowerCase();
|
||||||
|
const list = scripts.map((s) => ({
|
||||||
|
name: typeof s === "string" ? s : s.name,
|
||||||
|
description: typeof s === "string" ? "" : s.meta?.description ?? "",
|
||||||
|
meta: typeof s === "string" ? null : s.meta,
|
||||||
|
metaError: typeof s === "string" ? null : s.metaError,
|
||||||
|
}));
|
||||||
|
if (!term) return list;
|
||||||
|
return list.filter(
|
||||||
|
(s) =>
|
||||||
|
s.name.toLowerCase().includes(term) ||
|
||||||
|
s.description.toLowerCase().includes(term),
|
||||||
|
);
|
||||||
|
}, [scripts, q]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog className="modal modal-open">
|
||||||
|
<div className="modal-box max-w-lg">
|
||||||
|
<h3 className="font-bold text-lg">Add script</h3>
|
||||||
|
<input
|
||||||
|
className="input input-sm w-full mt-3"
|
||||||
|
placeholder="Search scripts"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<ul className="menu mt-3 max-h-80 overflow-auto rounded-box border border-base-300 p-0">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onPick({ kind: "set" });
|
||||||
|
setQ("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-semibold">Set (JSONata)</span>
|
||||||
|
<span className="text-xs opacity-70">Assign a JSONata result to a variable</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{isLoading ? (
|
||||||
|
<li className="p-4">
|
||||||
|
<span className="loading loading-spinner loading-sm" />
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
filtered.map((s) => (
|
||||||
|
<li key={s.name}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onPick({ kind: "script", name: s.name, meta: s.meta });
|
||||||
|
setQ("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-mono">{s.name}</span>
|
||||||
|
<span className="text-xs opacity-70 line-clamp-2">
|
||||||
|
{s.metaError ? s.metaError : s.description || "No description"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<div className="modal-action">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => {
|
||||||
|
setQ("");
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" className="modal-backdrop">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setQ("");
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
||||||
|
import { triggerDestinations } from "../../lib/workflow-doc.js";
|
||||||
|
import { prettyJson } from "../../lib/script.js";
|
||||||
|
import { FieldLabel } from "./FieldHelp.jsx";
|
||||||
|
|
||||||
|
const MULTILINE_KEYS = new Set(["expression", "jsonata"]);
|
||||||
|
|
||||||
|
function fieldSpec(meta, key) {
|
||||||
|
const spec = meta?.config?.[key];
|
||||||
|
if (spec && typeof spec === "object") return spec;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Support meta `enum` or `options`: string[], or { value, label }[]. */
|
||||||
|
function enumOptions(spec) {
|
||||||
|
const raw = spec?.enum ?? spec?.options;
|
||||||
|
if (!Array.isArray(raw) || raw.length === 0) return null;
|
||||||
|
return raw
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") {
|
||||||
|
return { value: item, label: String(item) };
|
||||||
|
}
|
||||||
|
if (item && typeof item === "object" && item.value != null) {
|
||||||
|
return { value: item.value, label: item.label != null ? String(item.label) : String(item.value) };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableText({ value, onCommit, className = "", placeholder = "…", disabled }) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(value ?? "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(value ?? "");
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`min-h-8 w-full truncate rounded-btn px-2 text-left hover:bg-base-200 ${className}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
>
|
||||||
|
{(value ?? "") === "" ? (
|
||||||
|
<span className="opacity-40">{placeholder}</span>
|
||||||
|
) : (
|
||||||
|
value
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={`input input-sm w-full ${className}`}
|
||||||
|
value={draft}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
onCommit(draft);
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") e.currentTarget.blur();
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setDraft(value ?? "");
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ValueEditor({ value, onChange, spec, script, fieldKey, workflows, owner, excludeFile, disabled }) {
|
||||||
|
const type = spec?.type ?? (value != null && typeof value === "object" ? "object" : "string");
|
||||||
|
|
||||||
|
if (script === "trigger-workflow.js" && fieldKey === "name") {
|
||||||
|
return (
|
||||||
|
<WorkflowNamePicker
|
||||||
|
value={typeof value === "string" ? value : ""}
|
||||||
|
onChange={onChange}
|
||||||
|
workflows={workflows}
|
||||||
|
owner={owner}
|
||||||
|
excludeFile={excludeFile}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = enumOptions(spec);
|
||||||
|
if (options) {
|
||||||
|
const fallback = spec?.default ?? (spec?.required ? options[0].value : "");
|
||||||
|
const current = value === undefined || value === null ? fallback : value;
|
||||||
|
const known = options.some((o) => o.value === current);
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="select select-sm w-full"
|
||||||
|
value={current === undefined || current === null ? "" : String(current)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
if (next === "") {
|
||||||
|
onChange(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match = options.find((o) => String(o.value) === next);
|
||||||
|
onChange(match ? match.value : next);
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{spec?.required ? null : <option value="">(default)</option>}
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={String(o.value)} value={String(o.value)}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{known || current === "" || current == null ? null : (
|
||||||
|
<option value={String(current)}>{String(current)}</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "boolean") {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="toggle toggle-sm"
|
||||||
|
checked={Boolean(value)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "number") {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input input-sm w-full"
|
||||||
|
value={value ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value;
|
||||||
|
if (raw === "") {
|
||||||
|
onChange(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Number(raw);
|
||||||
|
onChange(Number.isNaN(n) ? raw : n);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "object" || (value != null && typeof value === "object" && !Array.isArray(value) && type !== "any")) {
|
||||||
|
const obj = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||||
|
return <ObjectFields value={obj} onChange={onChange} disabled={disabled} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "any") {
|
||||||
|
const text =
|
||||||
|
typeof value === "string" ? value : value === undefined ? "" : prettyJson(value) || "";
|
||||||
|
return (
|
||||||
|
<JsonOrTextArea
|
||||||
|
text={text}
|
||||||
|
onCommit={(next) => {
|
||||||
|
if (next.trim() === "") {
|
||||||
|
onChange(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
onChange(JSON.parse(next));
|
||||||
|
} catch {
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const str = value == null ? "" : String(value);
|
||||||
|
const multiline = MULTILINE_KEYS.has(fieldKey) || str.includes("\n");
|
||||||
|
if (multiline) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-sm w-full min-h-24 font-mono text-xs"
|
||||||
|
value={str}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <EditableText value={str} onCommit={onChange} className="font-mono text-sm" disabled={disabled} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function JsonOrTextArea({ text, onCommit, disabled }) {
|
||||||
|
const [draft, setDraft] = useState(text);
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(text);
|
||||||
|
}, [text]);
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-sm w-full min-h-24 font-mono text-xs"
|
||||||
|
value={draft}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={() => onCommit(draft)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ObjectFields({ value, onChange }) {
|
||||||
|
const entries = Object.entries(value ?? {});
|
||||||
|
|
||||||
|
function setKey(oldKey, nextKey) {
|
||||||
|
if (!nextKey || nextKey === oldKey) return;
|
||||||
|
const next = {};
|
||||||
|
for (const [k, v] of Object.entries(value ?? {})) {
|
||||||
|
next[k === oldKey ? nextKey : k] = v;
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVal(key, val) {
|
||||||
|
onChange({ ...value, [key]: val });
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(key) {
|
||||||
|
const next = { ...value };
|
||||||
|
delete next[key];
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
let key = "key";
|
||||||
|
let i = 1;
|
||||||
|
while (key in (value ?? {})) {
|
||||||
|
key = `key${i}`;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
onChange({ ...value, [key]: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 rounded-box border border-base-300 p-2">
|
||||||
|
{entries.map(([k, v]) => (
|
||||||
|
<div key={k} className="flex items-center gap-1">
|
||||||
|
<div className="w-36 shrink-0">
|
||||||
|
<EditableText value={k} onCommit={(nk) => setKey(k, nk)} className="font-mono text-xs" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{v != null && typeof v === "object" ? (
|
||||||
|
<ObjectFields
|
||||||
|
value={v && !Array.isArray(v) ? v : {}}
|
||||||
|
onChange={(nv) => setVal(k, nv)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EditableText
|
||||||
|
value={v == null ? "" : String(v)}
|
||||||
|
onCommit={(nv) => setVal(k, nv)}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs btn-square text-error"
|
||||||
|
aria-label={`Remove ${k}`}
|
||||||
|
onClick={() => remove(k)}
|
||||||
|
>
|
||||||
|
<LuTrash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn btn-ghost btn-xs" onClick={add}>
|
||||||
|
<LuPlus className="size-3.5" />
|
||||||
|
Add field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkflowNamePicker({ value, onChange, workflows, owner, excludeFile, disabled }) {
|
||||||
|
const dest = triggerDestinations(workflows, { owner, excludeFile });
|
||||||
|
const names = dest.map((w) => w.name);
|
||||||
|
const known = names.includes(value);
|
||||||
|
const [custom, setCustom] = useState(!known && Boolean(value));
|
||||||
|
const selectValue = custom || (!known && value) ? "__custom__" : value || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<select
|
||||||
|
className="select select-sm w-full"
|
||||||
|
value={selectValue}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (v === "__custom__") {
|
||||||
|
setCustom(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCustom(false);
|
||||||
|
onChange(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select workflow</option>
|
||||||
|
{dest.map((w) => (
|
||||||
|
<option key={`${w.owner}/${w.file}`} value={w.name} disabled={w.enabled === false}>
|
||||||
|
{w.name}
|
||||||
|
{w.file !== w.name ? ` (${w.file})` : ""}
|
||||||
|
{w.enabled === false ? " — disabled" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="__custom__">Custom…</option>
|
||||||
|
</select>
|
||||||
|
{selectValue === "__custom__" ? (
|
||||||
|
<input
|
||||||
|
className="input input-sm w-full font-mono"
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="workflow name"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigFields({
|
||||||
|
script,
|
||||||
|
config,
|
||||||
|
meta,
|
||||||
|
onChange,
|
||||||
|
workflows,
|
||||||
|
owner,
|
||||||
|
excludeFile,
|
||||||
|
disabled,
|
||||||
|
}) {
|
||||||
|
const cfg = config && typeof config === "object" && !Array.isArray(config) ? config : {};
|
||||||
|
const metaKeys = Object.keys(meta?.config ?? {});
|
||||||
|
const extraKeys = Object.keys(cfg).filter((k) => !metaKeys.includes(k));
|
||||||
|
|
||||||
|
function setField(key, value) {
|
||||||
|
const spec = fieldSpec(meta, key);
|
||||||
|
const next = { ...cfg };
|
||||||
|
const required = Boolean(spec.required);
|
||||||
|
if (value === undefined || (value === "" && !required)) {
|
||||||
|
delete next[key];
|
||||||
|
} else {
|
||||||
|
next[key] = value;
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameExtra(oldKey, nextKey) {
|
||||||
|
if (!nextKey || nextKey === oldKey) return;
|
||||||
|
const next = {};
|
||||||
|
for (const [k, v] of Object.entries(cfg)) {
|
||||||
|
next[k === oldKey ? nextKey : k] = v;
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addExtra() {
|
||||||
|
let key = "key";
|
||||||
|
let i = 1;
|
||||||
|
while (key in cfg) {
|
||||||
|
key = `key${i}`;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
onChange({ ...cfg, [key]: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{metaKeys.map((key) => {
|
||||||
|
const spec = fieldSpec(meta, key);
|
||||||
|
return (
|
||||||
|
<div key={key} className="space-y-1">
|
||||||
|
<FieldLabel name={key} required={Boolean(spec.required)} description={spec.description} />
|
||||||
|
<ValueEditor
|
||||||
|
value={cfg[key]}
|
||||||
|
onChange={(v) => setField(key, v)}
|
||||||
|
spec={spec}
|
||||||
|
script={script}
|
||||||
|
fieldKey={key}
|
||||||
|
workflows={workflows}
|
||||||
|
owner={owner}
|
||||||
|
excludeFile={excludeFile}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{extraKeys.map((key) => (
|
||||||
|
<div key={key} className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<EditableText
|
||||||
|
value={key}
|
||||||
|
onCommit={(nk) => renameExtra(key, nk)}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs btn-square text-error"
|
||||||
|
aria-label={`Remove ${key}`}
|
||||||
|
onClick={() => {
|
||||||
|
const next = { ...cfg };
|
||||||
|
delete next[key];
|
||||||
|
onChange(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuTrash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ValueEditor
|
||||||
|
value={cfg[key]}
|
||||||
|
onChange={(v) => setField(key, v)}
|
||||||
|
spec={{ type: cfg[key] != null && typeof cfg[key] === "object" ? "object" : "string" }}
|
||||||
|
script={script}
|
||||||
|
fieldKey={key}
|
||||||
|
workflows={workflows}
|
||||||
|
owner={owner}
|
||||||
|
excludeFile={excludeFile}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn btn-ghost btn-xs" disabled={disabled} onClick={addExtra}>
|
||||||
|
<LuPlus className="size-3.5" />
|
||||||
|
Add field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { LuCircleHelp } from "react-icons/lu";
|
||||||
|
|
||||||
|
export function FieldHelp({ description }) {
|
||||||
|
if (!description) return null;
|
||||||
|
const long = String(description).length > 80;
|
||||||
|
return (
|
||||||
|
<div className="tooltip tooltip-top" {...(long ? {} : { "data-tip": description })}>
|
||||||
|
{long ? (
|
||||||
|
<div className="tooltip-content">
|
||||||
|
<p className="max-w-xs text-left text-xs whitespace-pre-wrap">{description}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button type="button" className="btn btn-ghost btn-xs btn-circle" aria-label={description}>
|
||||||
|
<LuCircleHelp className="size-3.5 opacity-60" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldLabel({ name, required, description, children }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-mono text-sm">{name}</span>
|
||||||
|
{required ? <span className="text-error text-xs">*</span> : null}
|
||||||
|
<FieldHelp description={description} />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { LuGripVertical, LuMaximize2, LuMinimize2, LuPencil, LuPlay, LuTrash2 } from "react-icons/lu";
|
||||||
|
import { useDryRunScript, useScript } from "../../api/hooks.js";
|
||||||
|
import { errorMessage } from "../../api/client.js";
|
||||||
|
import { CodeEditor } from "../CodeEditor.jsx";
|
||||||
|
import { LogViewer } from "../LogViewer.jsx";
|
||||||
|
import { ScriptMetaPanel } from "../ScriptMetaPanel.jsx";
|
||||||
|
import { StatusBadge } from "../../lib/format.jsx";
|
||||||
|
import { contextFromMeta, prettyJson } from "../../lib/script.js";
|
||||||
|
import { ConfigFields } from "./ConfigFields.jsx";
|
||||||
|
import { FieldLabel } from "./FieldHelp.jsx";
|
||||||
|
|
||||||
|
export function ScriptCard({
|
||||||
|
step,
|
||||||
|
index,
|
||||||
|
otherSteps,
|
||||||
|
scriptsByName,
|
||||||
|
onChange,
|
||||||
|
onRemove,
|
||||||
|
disabled,
|
||||||
|
workflows,
|
||||||
|
owner,
|
||||||
|
excludeFile,
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: step.uiId,
|
||||||
|
disabled,
|
||||||
|
});
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
};
|
||||||
|
const [tryOpen, setTryOpen] = useState(false);
|
||||||
|
const listed = scriptsByName?.get(step.script);
|
||||||
|
const meta = listed?.meta ?? null;
|
||||||
|
const metaError = listed?.metaError ?? null;
|
||||||
|
|
||||||
|
const duplicateId =
|
||||||
|
step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="card bg-base-100 border border-primary"
|
||||||
|
>
|
||||||
|
<div className="card-body gap-3 p-4">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs btn-square mt-0.5 cursor-grab active:cursor-grabbing"
|
||||||
|
aria-label={`Drag step ${index + 1}`}
|
||||||
|
disabled={disabled}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<LuGripVertical className="size-4 opacity-60" />
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="card-title text-base font-mono">
|
||||||
|
{step.kind === "set" ? `set:${step.as || "…"}` : step.script || "untitled"}
|
||||||
|
</h3>
|
||||||
|
{step.id ? <span className="badge badge-ghost badge-sm font-mono">{step.id}</span> : null}
|
||||||
|
{step.kind === "script" && meta?.description ? (
|
||||||
|
<p className="text-sm opacity-70 mt-1">{meta.description}</p>
|
||||||
|
) : null}
|
||||||
|
{step.kind === "set" ? (
|
||||||
|
<p className="text-sm opacity-70 mt-1">Assign a JSONata result onto the context</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="card-actions shrink-0">
|
||||||
|
{step.kind === "script" && step.script ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
title="Try"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setTryOpen(true)}
|
||||||
|
>
|
||||||
|
<LuPlay className="size-4" />
|
||||||
|
Try
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to={`/scripts/${encodeURIComponent(step.script)}/edit`}
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
title="Edit script"
|
||||||
|
>
|
||||||
|
<LuPencil className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs text-error"
|
||||||
|
title="Remove"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<LuTrash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step.kind === "script" && (meta?.input || meta?.output) ? (
|
||||||
|
<details className="collapse collapse-arrow bg-base-200">
|
||||||
|
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">
|
||||||
|
Input / output
|
||||||
|
</summary>
|
||||||
|
<div className="collapse-content">
|
||||||
|
<ScriptMetaPanel meta={meta} metaError={metaError} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step.kind === "set" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FieldLabel name="as" required description="Context field to write (not data or config)" />
|
||||||
|
<input
|
||||||
|
className="input input-sm w-full font-mono"
|
||||||
|
value={step.as ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange({ ...step, as: e.target.value })}
|
||||||
|
/>
|
||||||
|
<FieldLabel name="expression" required description="JSONata evaluated against ctx" />
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-sm w-full min-h-24 font-mono text-xs"
|
||||||
|
value={step.expression ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange({ ...step, expression: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ConfigFields
|
||||||
|
script={step.script}
|
||||||
|
config={step.config}
|
||||||
|
meta={meta}
|
||||||
|
disabled={disabled}
|
||||||
|
workflows={workflows}
|
||||||
|
owner={owner}
|
||||||
|
excludeFile={excludeFile}
|
||||||
|
onChange={(config) => onChange({ ...step, config })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<details className="collapse collapse-arrow bg-base-200">
|
||||||
|
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">Advanced</summary>
|
||||||
|
<div className="collapse-content space-y-2">
|
||||||
|
<label className="form-control">
|
||||||
|
<span className="label py-0 text-sm">id</span>
|
||||||
|
<input
|
||||||
|
className={`input input-sm font-mono ${duplicateId ? "input-error" : ""}`}
|
||||||
|
value={step.id ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange({ ...step, id: e.target.value })}
|
||||||
|
placeholder="optional step id"
|
||||||
|
/>
|
||||||
|
{duplicateId ? (
|
||||||
|
<span className="text-error text-xs">Duplicate step id</span>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
{step.kind === "set" ? null : (
|
||||||
|
<NeedsEditor
|
||||||
|
step={step}
|
||||||
|
otherSteps={otherSteps}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<label className="form-control">
|
||||||
|
<span className="label py-0 text-sm">when</span>
|
||||||
|
<input
|
||||||
|
className="input input-sm w-full font-mono"
|
||||||
|
value={step.when ?? ""}
|
||||||
|
disabled={disabled || Boolean(step.needs)}
|
||||||
|
onChange={(e) => onChange({ ...step, when: e.target.value })}
|
||||||
|
placeholder="JSONata; skip if false (linear only)"
|
||||||
|
/>
|
||||||
|
{step.needs ? (
|
||||||
|
<span className="text-xs opacity-60">when is not allowed when needs is set</span>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{tryOpen ? (
|
||||||
|
<ScriptTryDialog
|
||||||
|
script={step.script}
|
||||||
|
config={step.config}
|
||||||
|
meta={meta}
|
||||||
|
owner={owner}
|
||||||
|
onClose={() => setTryOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
function setMode(next) {
|
||||||
|
if (next === "none") onChange({ ...step, needs: null });
|
||||||
|
else if (next === "list") onChange({ ...step, needs: [], when: "" });
|
||||||
|
else onChange({ ...step, needs: {}, when: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="label py-0 text-sm">needs</span>
|
||||||
|
<select
|
||||||
|
className="select select-sm"
|
||||||
|
value={mode}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setMode(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="none">None (use previous step data)</option>
|
||||||
|
<option value="list">List of step ids</option>
|
||||||
|
<option value="map">Map alias → step id</option>
|
||||||
|
</select>
|
||||||
|
{mode === "list" ? (
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
{ids.length === 0 ? (
|
||||||
|
<span className="text-xs opacity-60">Give other steps an id first</span>
|
||||||
|
) : (
|
||||||
|
ids.map((id) => {
|
||||||
|
const checked = Array.isArray(step.needs) && step.needs.includes(id);
|
||||||
|
return (
|
||||||
|
<label key={id} className="label cursor-pointer gap-1 py-0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-xs"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const current = Array.isArray(step.needs) ? [...step.needs] : [];
|
||||||
|
onChange({
|
||||||
|
...step,
|
||||||
|
needs: e.target.checked
|
||||||
|
? [...current, id]
|
||||||
|
: current.filter((x) => x !== id),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-xs">{id}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{mode === "map" ? (
|
||||||
|
<NeedsMap
|
||||||
|
needs={step.needs && !Array.isArray(step.needs) ? step.needs : {}}
|
||||||
|
ids={ids}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(needs) => onChange({ ...step, needs })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NeedsMap({ needs, ids, disabled, onChange }) {
|
||||||
|
const entries = Object.entries(needs ?? {});
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{entries.map(([alias, from]) => (
|
||||||
|
<div key={alias} className="flex gap-1">
|
||||||
|
<input
|
||||||
|
className="input input-sm font-mono w-28"
|
||||||
|
value={alias}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = {};
|
||||||
|
for (const [k, v] of Object.entries(needs)) {
|
||||||
|
next[k === alias ? e.target.value : k] = v;
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="select select-sm flex-1"
|
||||||
|
value={from}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange({ ...needs, [alias]: e.target.value })}
|
||||||
|
>
|
||||||
|
{from && !ids.includes(from) ? <option value={from}>{from}</option> : null}
|
||||||
|
{ids.map((id) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
const next = { ...needs };
|
||||||
|
delete next[alias];
|
||||||
|
onChange(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
disabled={disabled || ids.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
const used = new Set(Object.keys(needs));
|
||||||
|
let alias = ids[0] || "step";
|
||||||
|
let i = 1;
|
||||||
|
while (used.has(alias)) {
|
||||||
|
alias = `${ids[0] || "step"}${i}`;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
onChange({ ...needs, [alias]: ids[0] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add mapping
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function needsMode(needs) {
|
||||||
|
if (needs == null) return "none";
|
||||||
|
if (Array.isArray(needs)) return "list";
|
||||||
|
return "map";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScriptTryDialog({ script, config, meta, owner, onClose }) {
|
||||||
|
const existing = useScript(script);
|
||||||
|
const dryRun = useDryRunScript();
|
||||||
|
const defaultData = useMemo(() => prettyJson(contextFromMeta(meta).data ?? {}), [meta]);
|
||||||
|
const [dataJson, setDataJson] = useState(defaultData);
|
||||||
|
const [parseError, setParseError] = useState(null);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDataJson(defaultData);
|
||||||
|
}, [defaultData]);
|
||||||
|
|
||||||
|
const outputJson = dryRun.data
|
||||||
|
? prettyJson(dryRun.data.status === "success" ? dryRun.data.output : { error: dryRun.data.error })
|
||||||
|
: "";
|
||||||
|
|
||||||
|
function onRun() {
|
||||||
|
setParseError(null);
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(dataJson || "null");
|
||||||
|
data = parsed;
|
||||||
|
} catch (err) {
|
||||||
|
setParseError(err instanceof Error ? err.message : String(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!existing.data?.content) {
|
||||||
|
setParseError("could not load script source");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dryRun.mutate({
|
||||||
|
name: script,
|
||||||
|
content: existing.data.content,
|
||||||
|
data,
|
||||||
|
config: config ?? {},
|
||||||
|
owner,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog className="modal modal-open">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
expanded
|
||||||
|
? "modal-box flex h-dvh max-h-dvh w-dvw max-w-none flex-col rounded-none"
|
||||||
|
: "modal-box flex max-w-4xl flex-col"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 items-start gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-bold font-mono">{script}</h3>
|
||||||
|
<p className="text-sm opacity-70">
|
||||||
|
Dry-run this script with the card config. Does not create an event.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm btn-square"
|
||||||
|
title={expanded ? "Exit full screen" : "Full screen"}
|
||||||
|
aria-label={expanded ? "Exit full screen" : "Full screen"}
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{expanded ? <LuMinimize2 className="size-4" /> : <LuMaximize2 className="size-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{parseError ? <p className="text-error text-sm mt-2 shrink-0">{parseError}</p> : null}
|
||||||
|
{dryRun.isError ? (
|
||||||
|
<p className="text-error text-sm mt-2 shrink-0">{errorMessage(dryRun.error)}</p>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={`grid grid-cols-1 md:grid-cols-2 gap-3 mt-3 min-h-0 ${expanded ? "flex-1" : ""}`}
|
||||||
|
>
|
||||||
|
<div className={expanded ? "min-h-0 flex-1 h-full" : "h-48"}>
|
||||||
|
<p className="text-xs opacity-60 mb-1">data</p>
|
||||||
|
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-full"}>
|
||||||
|
<CodeEditor language="json" value={dataJson} onChange={setDataJson} height="100%" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={expanded ? "min-h-0 flex-1 h-full" : "h-48"}>
|
||||||
|
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
|
||||||
|
output
|
||||||
|
{dryRun.data?.status ? <StatusBadge status={dryRun.data.status} /> : null}
|
||||||
|
</p>
|
||||||
|
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-full"}>
|
||||||
|
<CodeEditor language="json" value={outputJson} onChange={() => {}} readOnly height="100%" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details className="collapse collapse-arrow mt-2 border border-base-300 shrink-0">
|
||||||
|
<summary className="collapse-title min-h-0 py-2 text-sm">config (from card)</summary>
|
||||||
|
<div className="collapse-content">
|
||||||
|
<pre className="text-xs overflow-auto">{prettyJson(config ?? {})}</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{dryRun.data?.logs?.length ? (
|
||||||
|
<LogViewer logs={dryRun.data.logs} className={expanded ? "h-48 mt-2 shrink-0" : "h-36 mt-2"} />
|
||||||
|
) : null}
|
||||||
|
<div className="modal-action shrink-0">
|
||||||
|
<button type="button" className="btn" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={dryRun.isPending || existing.isLoading}
|
||||||
|
onClick={onRun}
|
||||||
|
>
|
||||||
|
{dryRun.isPending ? <span className="loading loading-spinner loading-xs" /> : null}
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" className="modal-backdrop">
|
||||||
|
<button type="button" onClick={onClose}>
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { LuPlus } from "react-icons/lu";
|
||||||
|
import { defaultConfigFromMeta } from "../../lib/script.js";
|
||||||
|
import { newScriptStep, newSetStep } from "../../lib/workflow-doc.js";
|
||||||
|
import { AddScriptDialog } from "./AddScriptDialog.jsx";
|
||||||
|
import { ScriptCard } from "./ScriptCard.jsx";
|
||||||
|
|
||||||
|
export function ScriptsTab({
|
||||||
|
doc,
|
||||||
|
onPatch,
|
||||||
|
disabled,
|
||||||
|
scripts = [],
|
||||||
|
workflows = [],
|
||||||
|
owner,
|
||||||
|
excludeFile,
|
||||||
|
}) {
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const steps = doc?.scripts ?? [];
|
||||||
|
const ids = steps.map((s) => s.uiId);
|
||||||
|
|
||||||
|
const scriptsByName = new Map();
|
||||||
|
for (const s of scripts) {
|
||||||
|
const name = typeof s === "string" ? s : s.name;
|
||||||
|
scriptsByName.set(name, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchSteps(next) {
|
||||||
|
onPatch((d) => {
|
||||||
|
d.scripts = next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd(event) {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const oldIndex = ids.indexOf(active.id);
|
||||||
|
const newIndex = ids.indexOf(over.id);
|
||||||
|
if (oldIndex < 0 || newIndex < 0) return;
|
||||||
|
patchSteps(arrayMove(steps, oldIndex, newIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto">
|
||||||
|
<div className="flex shrink-0 items-center justify-between">
|
||||||
|
<p className="text-sm opacity-70">
|
||||||
|
Steps run top to bottom (or by needs in DAG mode). Drag the handle to reorder.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
>
|
||||||
|
<LuPlus className="size-4" />
|
||||||
|
Add script
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{steps.length === 0 ? (
|
||||||
|
<p className="text-sm opacity-50">No scripts yet. Add one to get started.</p>
|
||||||
|
) : (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||||
|
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="flex flex-col gap-3 pb-6">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<ScriptCard
|
||||||
|
key={step.uiId}
|
||||||
|
step={step}
|
||||||
|
index={index}
|
||||||
|
otherSteps={steps}
|
||||||
|
scriptsByName={scriptsByName}
|
||||||
|
disabled={disabled}
|
||||||
|
workflows={workflows}
|
||||||
|
owner={owner}
|
||||||
|
excludeFile={excludeFile}
|
||||||
|
onChange={(next) => {
|
||||||
|
const copy = [...steps];
|
||||||
|
copy[index] = next;
|
||||||
|
patchSteps(copy);
|
||||||
|
}}
|
||||||
|
onRemove={() => patchSteps(steps.filter((_, i) => i !== index))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
<AddScriptDialog
|
||||||
|
open={addOpen}
|
||||||
|
onClose={() => setAddOpen(false)}
|
||||||
|
onPick={(picked) => {
|
||||||
|
if (picked.kind === "set") {
|
||||||
|
patchSteps([...steps, newSetStep()]);
|
||||||
|
} else {
|
||||||
|
patchSteps([
|
||||||
|
...steps,
|
||||||
|
newScriptStep(picked.name, defaultConfigFromMeta(picked.meta)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
setAddOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { LuCopy, LuGripVertical, LuTrash2 } from "react-icons/lu";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import cronstrue from "cronstrue";
|
||||||
|
import { namespacedPath, HTTP_METHODS } from "../../lib/workflow-doc.js";
|
||||||
|
import { CRON_CUSTOM, CRON_PRESETS, matchCronPreset, scheduleForPreset } from "../../lib/cron-presets.js";
|
||||||
|
|
||||||
|
export function TriggerCard({
|
||||||
|
trigger,
|
||||||
|
index,
|
||||||
|
owner,
|
||||||
|
onChange,
|
||||||
|
onRemove,
|
||||||
|
disabled,
|
||||||
|
auths = [],
|
||||||
|
pages = [],
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: trigger.uiId,
|
||||||
|
disabled,
|
||||||
|
});
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.6 : 1,
|
||||||
|
};
|
||||||
|
const type = trigger.type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article ref={setNodeRef} style={style} className="card bg-base-100 border border-primary">
|
||||||
|
<div className="card-body gap-3 p-4">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs btn-square mt-0.5 cursor-grab active:cursor-grabbing"
|
||||||
|
aria-label={`Drag trigger ${index + 1}`}
|
||||||
|
disabled={disabled}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<LuGripVertical className="size-4 opacity-60" />
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="card-title text-base">{typeLabel(type)}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs text-error"
|
||||||
|
title="Remove"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<LuTrash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{type === "HTTP" ? (
|
||||||
|
<HttpFields
|
||||||
|
trigger={trigger}
|
||||||
|
owner={owner}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={onChange}
|
||||||
|
auths={auths}
|
||||||
|
pages={pages}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{type === "cron" ? (
|
||||||
|
<CronFields trigger={trigger} disabled={disabled} onChange={onChange} />
|
||||||
|
) : null}
|
||||||
|
{type === "workflow" ? (
|
||||||
|
<p className="text-sm opacity-80">
|
||||||
|
Other workflows can call this one with <span className="font-mono">trigger-workflow.js</span>.
|
||||||
|
No extra fields.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{type !== "HTTP" && type !== "cron" && type !== "workflow" ? (
|
||||||
|
<p className="text-sm opacity-70">Unknown trigger type. Edit it in the YAML tab.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(type) {
|
||||||
|
if (type === "HTTP") return "HTTP";
|
||||||
|
if (type === "cron") return "Cron";
|
||||||
|
if (type === "workflow") return "Workflow";
|
||||||
|
return type || "Trigger";
|
||||||
|
}
|
||||||
|
|
||||||
|
function HttpFields({ trigger, owner, disabled, onChange, auths, pages }) {
|
||||||
|
const path = trigger.path || "/";
|
||||||
|
const url = namespacedPath(owner || "owner", path);
|
||||||
|
const authIsInline = trigger.auth != null && typeof trigger.auth === "object";
|
||||||
|
const authSelect = authIsInline
|
||||||
|
? "__inline__"
|
||||||
|
: typeof trigger.auth === "string" && trigger.auth
|
||||||
|
? trigger.auth
|
||||||
|
: "";
|
||||||
|
|
||||||
|
function copyUrl() {
|
||||||
|
if (typeof navigator?.clipboard?.writeText === "function") {
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="form-control">
|
||||||
|
<span className="label py-0 text-sm">Method</span>
|
||||||
|
<select
|
||||||
|
className="select select-sm"
|
||||||
|
value={trigger.method || "POST"}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange({ ...trigger, method: e.target.value })}
|
||||||
|
>
|
||||||
|
{HTTP_METHODS.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{HTTP_METHODS.includes(trigger.method || "POST") ? null : (
|
||||||
|
<option value={trigger.method}>{trigger.method}</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-control">
|
||||||
|
<span className="label py-0 text-sm">Path</span>
|
||||||
|
<input
|
||||||
|
className="input input-sm font-mono"
|
||||||
|
value={trigger.path ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange({ ...trigger, path: e.target.value })}
|
||||||
|
placeholder="/hook"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-mono opacity-80">
|
||||||
|
<span className="truncate">{url}</span>
|
||||||
|
<button type="button" className="btn btn-ghost btn-xs btn-square" title="Copy URL" onClick={copyUrl}>
|
||||||
|
<LuCopy className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label className="form-control">
|
||||||
|
<span className="label py-0 text-sm">Auth</span>
|
||||||
|
<select
|
||||||
|
className="select select-sm"
|
||||||
|
value={authSelect}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (v === "" || v === "__inline__") {
|
||||||
|
onChange({ ...trigger, auth: v === "__inline__" ? trigger.auth : null });
|
||||||
|
} else {
|
||||||
|
onChange({ ...trigger, auth: v });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{auths.map((a) => (
|
||||||
|
<option key={a.name} value={a.name}>
|
||||||
|
{a.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{authIsInline ? <option value="__inline__">Inline (edit in YAML)</option> : null}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-control">
|
||||||
|
<span className="label py-0 text-sm">Response page</span>
|
||||||
|
<select
|
||||||
|
className="select select-sm"
|
||||||
|
value={trigger.response ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange({ ...trigger, response: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">JSON default</option>
|
||||||
|
{pages.map((p) => (
|
||||||
|
<option key={p.name ?? p.id} value={p.name}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{trigger.response && !pages.some((p) => p.name === trigger.response) ? (
|
||||||
|
<option value={trigger.response}>{trigger.response}</option>
|
||||||
|
) : null}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CronFields({ trigger, disabled, onChange }) {
|
||||||
|
const preset = matchCronPreset(trigger.schedule);
|
||||||
|
let human = "";
|
||||||
|
try {
|
||||||
|
human = cronstrue.toString(trigger.schedule || "", { throwExceptionOnParseError: true });
|
||||||
|
} catch {
|
||||||
|
human = "Invalid cron expression";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="form-control">
|
||||||
|
<span className="label py-0 text-sm">Preset</span>
|
||||||
|
<select
|
||||||
|
className="select select-sm"
|
||||||
|
value={preset}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = e.target.value;
|
||||||
|
if (id === CRON_CUSTOM) return;
|
||||||
|
const schedule = scheduleForPreset(id);
|
||||||
|
if (schedule) onChange({ ...trigger, schedule });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{CRON_PRESETS.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value={CRON_CUSTOM}>Custom</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="form-control">
|
||||||
|
<span className="label py-0 text-sm">Schedule</span>
|
||||||
|
<input
|
||||||
|
className="input input-sm font-mono"
|
||||||
|
value={trigger.schedule ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange({ ...trigger, schedule: e.target.value })}
|
||||||
|
placeholder="* * * * *"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs opacity-70">{human}</p>
|
||||||
|
<p className="text-xs opacity-60">
|
||||||
|
Cron runs use this workflow's top-level <span className="font-mono">data</span> as the payload.
|
||||||
|
Set it in YAML or the Test panel prefill.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddTriggerDialog({ open, onClose, onPick }) {
|
||||||
|
const [kind, setKind] = useState("HTTP");
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog className="modal modal-open">
|
||||||
|
<div className="modal-box">
|
||||||
|
<h3 className="font-bold text-lg">Add trigger</h3>
|
||||||
|
<div className="form-control mt-3">
|
||||||
|
<label className="label cursor-pointer justify-start gap-3">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="trig-kind"
|
||||||
|
className="radio radio-sm"
|
||||||
|
checked={kind === "HTTP"}
|
||||||
|
onChange={() => setKind("HTTP")}
|
||||||
|
/>
|
||||||
|
<span>HTTP — webhook at <span className="font-mono">/u/owner/path</span></span>
|
||||||
|
</label>
|
||||||
|
<label className="label cursor-pointer justify-start gap-3">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="trig-kind"
|
||||||
|
className="radio radio-sm"
|
||||||
|
checked={kind === "cron"}
|
||||||
|
onChange={() => setKind("cron")}
|
||||||
|
/>
|
||||||
|
<span>Cron — run on a schedule</span>
|
||||||
|
</label>
|
||||||
|
<label className="label cursor-pointer justify-start gap-3">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="trig-kind"
|
||||||
|
className="radio radio-sm"
|
||||||
|
checked={kind === "workflow"}
|
||||||
|
onChange={() => setKind("workflow")}
|
||||||
|
/>
|
||||||
|
<span>Workflow — callable by other workflows</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="modal-action">
|
||||||
|
<button type="button" className="btn" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
onPick(kind);
|
||||||
|
setKind("HTTP");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" className="modal-backdrop">
|
||||||
|
<button type="button" onClick={onClose}>
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { LuPlus } from "react-icons/lu";
|
||||||
|
import { newCronTrigger, newHttpTrigger, newWorkflowTrigger } from "../../lib/workflow-doc.js";
|
||||||
|
import { AddTriggerDialog, TriggerCard } from "./TriggerCard.jsx";
|
||||||
|
|
||||||
|
export function TriggersTab({
|
||||||
|
doc,
|
||||||
|
onPatch,
|
||||||
|
disabled,
|
||||||
|
owner,
|
||||||
|
auths = [],
|
||||||
|
pages = [],
|
||||||
|
}) {
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const triggers = doc?.triggers ?? [];
|
||||||
|
const ids = triggers.map((t) => t.uiId);
|
||||||
|
|
||||||
|
function patchTriggers(next) {
|
||||||
|
onPatch((d) => {
|
||||||
|
d.triggers = next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd(event) {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const oldIndex = ids.indexOf(active.id);
|
||||||
|
const newIndex = ids.indexOf(over.id);
|
||||||
|
if (oldIndex < 0 || newIndex < 0) return;
|
||||||
|
patchTriggers(arrayMove(triggers, oldIndex, newIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto">
|
||||||
|
<div className="flex shrink-0 items-center justify-between">
|
||||||
|
<p className="text-sm opacity-70">How this workflow is started. Manual Run from the Test panel is always available.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
>
|
||||||
|
<LuPlus className="size-4" />
|
||||||
|
Add trigger
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{triggers.length === 0 ? (
|
||||||
|
<p className="text-sm opacity-50">No triggers. The Test panel can still run a saved workflow.</p>
|
||||||
|
) : (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||||
|
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="flex flex-col gap-3 pb-6">
|
||||||
|
{triggers.map((trigger, index) => (
|
||||||
|
<TriggerCard
|
||||||
|
key={trigger.uiId}
|
||||||
|
trigger={trigger}
|
||||||
|
index={index}
|
||||||
|
owner={owner}
|
||||||
|
auths={auths}
|
||||||
|
pages={pages}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(next) => {
|
||||||
|
const copy = [...triggers];
|
||||||
|
copy[index] = next;
|
||||||
|
patchTriggers(copy);
|
||||||
|
}}
|
||||||
|
onRemove={() => patchTriggers(triggers.filter((_, i) => i !== index))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
<AddTriggerDialog
|
||||||
|
open={addOpen}
|
||||||
|
onClose={() => setAddOpen(false)}
|
||||||
|
onPick={(kind) => {
|
||||||
|
const next =
|
||||||
|
kind === "cron"
|
||||||
|
? newCronTrigger()
|
||||||
|
: kind === "workflow"
|
||||||
|
? newWorkflowTrigger()
|
||||||
|
: newHttpTrigger();
|
||||||
|
patchTriggers([...triggers, next]);
|
||||||
|
setAddOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { LuPlay } from "react-icons/lu";
|
||||||
|
import { errorMessage } from "../../api/client.js";
|
||||||
|
import { useRunWorkflow } from "../../api/hooks.js";
|
||||||
|
import { CodeEditor } from "../CodeEditor.jsx";
|
||||||
|
import { StatusBadge } from "../../lib/format.jsx";
|
||||||
|
import { prettyJson } from "../../lib/script.js";
|
||||||
|
|
||||||
|
export function WorkflowTestPanel({
|
||||||
|
owner,
|
||||||
|
file,
|
||||||
|
defaultData,
|
||||||
|
disabled,
|
||||||
|
disabledReason,
|
||||||
|
testKey = 0,
|
||||||
|
}) {
|
||||||
|
const run = useRunWorkflow();
|
||||||
|
const [dataJson, setDataJson] = useState(() => prettyJson(defaultData ?? {}));
|
||||||
|
const [parseError, setParseError] = useState(null);
|
||||||
|
const [last, setLast] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDataJson(prettyJson(defaultData ?? {}));
|
||||||
|
}, [defaultData]);
|
||||||
|
|
||||||
|
function onRun() {
|
||||||
|
setParseError(null);
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(dataJson || "null");
|
||||||
|
} catch (err) {
|
||||||
|
setParseError(err instanceof Error ? err.message : String(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run.mutate(
|
||||||
|
{ owner, file, data },
|
||||||
|
{
|
||||||
|
onSuccess: (res) => {
|
||||||
|
setLast({
|
||||||
|
status: res.status ?? "success",
|
||||||
|
runId: res.runId ?? null,
|
||||||
|
result: res.result,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const runId = err?.response?.data?.runId;
|
||||||
|
setLast({
|
||||||
|
status: "failed",
|
||||||
|
runId: runId ?? null,
|
||||||
|
result: null,
|
||||||
|
error: errorMessage(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultJson = last
|
||||||
|
? prettyJson(last.error ? { error: last.error } : last.result)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
key={testKey}
|
||||||
|
className="collapse collapse-arrow border border-base-300 bg-base-100 shrink-0"
|
||||||
|
open={testKey > 0 ? true : undefined}
|
||||||
|
>
|
||||||
|
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">
|
||||||
|
Test
|
||||||
|
</summary>
|
||||||
|
<div className="collapse-content space-y-2">
|
||||||
|
<p className="text-xs opacity-70">
|
||||||
|
Runs the <strong>saved</strong> YAML and writes an Event. Manual run works even when the
|
||||||
|
workflow is disabled. Save before testing unsaved edits.
|
||||||
|
</p>
|
||||||
|
{disabledReason ? <p className="text-warning text-xs">{disabledReason}</p> : null}
|
||||||
|
{parseError ? <p className="text-error text-sm">{parseError}</p> : null}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div className="h-40">
|
||||||
|
<p className="text-xs opacity-60 mb-1">data</p>
|
||||||
|
<CodeEditor language="json" value={dataJson} onChange={setDataJson} height="100%" />
|
||||||
|
</div>
|
||||||
|
<div className="h-40">
|
||||||
|
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
|
||||||
|
result
|
||||||
|
{last?.status ? <StatusBadge status={last.status} /> : null}
|
||||||
|
{last?.runId ? (
|
||||||
|
<Link className="link text-xs" to={`/events/${last.runId}`}>
|
||||||
|
{last.runId}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<CodeEditor language="json" value={resultJson} onChange={() => {}} readOnly height="100%" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={disabled || run.isPending}
|
||||||
|
onClick={onRun}
|
||||||
|
>
|
||||||
|
{run.isPending ? (
|
||||||
|
<span className="loading loading-spinner loading-xs" />
|
||||||
|
) : (
|
||||||
|
<LuPlay className="size-4" />
|
||||||
|
)}
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { parse as parseYaml } from "yaml";
|
||||||
|
import { useHttpAuths, useHttpPages, useScripts, useWorkflows } from "../../api/hooks.js";
|
||||||
|
import { workflowToFlowchart } from "../../lib/workflow-mermaid.js";
|
||||||
|
import { parseWorkflowYaml, stringifyWorkflowDoc } from "../../lib/workflow-doc.js";
|
||||||
|
import { ScriptsTab } from "./ScriptsTab.jsx";
|
||||||
|
import { TriggersTab } from "./TriggersTab.jsx";
|
||||||
|
import { YamlTab } from "./YamlTab.jsx";
|
||||||
|
import { WorkflowTestPanel } from "./WorkflowTestPanel.jsx";
|
||||||
|
|
||||||
|
export function useYamlPreview(content) {
|
||||||
|
return useMemo(() => {
|
||||||
|
try {
|
||||||
|
const parsedYaml = parseYaml(content);
|
||||||
|
return {
|
||||||
|
parsed: parsedYaml,
|
||||||
|
parseError: null,
|
||||||
|
mermaid: workflowToFlowchart(parsedYaml),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
parsed: null,
|
||||||
|
parseError: err instanceof Error ? err.message : String(err),
|
||||||
|
mermaid: { chart: "", scriptIds: {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [content]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowVisualEditor({
|
||||||
|
yaml,
|
||||||
|
onYamlChange,
|
||||||
|
owner,
|
||||||
|
file,
|
||||||
|
extraChrome,
|
||||||
|
showTest,
|
||||||
|
savedYaml,
|
||||||
|
testKey,
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState("scripts");
|
||||||
|
const [lastEdited, setLastEdited] = useState("yaml");
|
||||||
|
const [doc, setDoc] = useState(() => parseWorkflowYaml(yaml).doc);
|
||||||
|
|
||||||
|
const { parsed, parseError, mermaid } = useYamlPreview(yaml);
|
||||||
|
const parsedDoc = useMemo(() => parseWorkflowYaml(yaml), [yaml]);
|
||||||
|
const displayDoc = lastEdited === "visual" && doc ? doc : parsedDoc.doc;
|
||||||
|
const visualDisabled = !displayDoc;
|
||||||
|
|
||||||
|
const { data: scripts = [] } = useScripts();
|
||||||
|
const { data: workflows = [] } = useWorkflows(owner || undefined);
|
||||||
|
const { data: auths = [] } = useHttpAuths();
|
||||||
|
const { data: allPages = [] } = useHttpPages();
|
||||||
|
const pages = allPages.filter((p) => p.kind !== "template");
|
||||||
|
|
||||||
|
const scriptCount = displayDoc?.scripts?.length ?? 0;
|
||||||
|
const triggerCount = displayDoc?.triggers?.length ?? 0;
|
||||||
|
const unsaved = savedYaml != null && yaml !== savedYaml;
|
||||||
|
|
||||||
|
function patchDoc(mutator) {
|
||||||
|
const base = lastEdited === "visual" && doc ? doc : parsedDoc.doc;
|
||||||
|
if (!base) return;
|
||||||
|
const next = structuredClone(base);
|
||||||
|
mutator(next);
|
||||||
|
setDoc(next);
|
||||||
|
onYamlChange(stringifyWorkflowDoc(next));
|
||||||
|
setLastEdited("visual");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onYamlTabChange(value) {
|
||||||
|
setLastEdited("yaml");
|
||||||
|
onYamlChange(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTab(next) {
|
||||||
|
if (next !== "yaml" && lastEdited === "yaml") {
|
||||||
|
const { doc: d } = parseWorkflowYaml(yaml);
|
||||||
|
if (d) setDoc(d);
|
||||||
|
}
|
||||||
|
setTab(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultData = displayDoc?.extra?.data ?? {};
|
||||||
|
let testDisabledReason = null;
|
||||||
|
if (visualDisabled) testDisabledReason = "Fix YAML before running.";
|
||||||
|
else if (unsaved) testDisabledReason = "Save the workflow before running. Test uses the saved YAML.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
|
{extraChrome}
|
||||||
|
<input
|
||||||
|
className="input input-sm sm:max-w-xs"
|
||||||
|
placeholder="name"
|
||||||
|
value={displayDoc?.name ?? ""}
|
||||||
|
disabled={visualDisabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchDoc((d) => {
|
||||||
|
d.name = e.target.value;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-sm min-h-10 w-full sm:max-w-md"
|
||||||
|
placeholder="description"
|
||||||
|
rows={1}
|
||||||
|
value={displayDoc?.description ?? ""}
|
||||||
|
disabled={visualDisabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchDoc((d) => {
|
||||||
|
d.description = e.target.value;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{unsaved ? <span className="badge badge-warning badge-sm">Unsaved</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="tablist" className="tabs tabs-box shrink-0 w-full sm:w-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className={`tab ${tab === "scripts" ? "tab-active" : ""}`}
|
||||||
|
onClick={() => selectTab("scripts")}
|
||||||
|
>
|
||||||
|
Scripts ({scriptCount})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className={`tab ${tab === "triggers" ? "tab-active" : ""}`}
|
||||||
|
onClick={() => selectTab("triggers")}
|
||||||
|
>
|
||||||
|
Triggers ({triggerCount})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className={`tab ${tab === "yaml" ? "tab-active" : ""}`}
|
||||||
|
onClick={() => selectTab("yaml")}
|
||||||
|
>
|
||||||
|
YAML
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{parseError && tab !== "yaml" ? (
|
||||||
|
<p className="text-error text-sm shrink-0">
|
||||||
|
YAML is invalid — switch to the YAML tab to fix it. Visual edits are disabled.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tab === "scripts" ? (
|
||||||
|
<ScriptsTab
|
||||||
|
doc={displayDoc}
|
||||||
|
onPatch={patchDoc}
|
||||||
|
disabled={visualDisabled}
|
||||||
|
scripts={scripts}
|
||||||
|
workflows={workflows}
|
||||||
|
owner={owner}
|
||||||
|
excludeFile={file}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{tab === "triggers" ? (
|
||||||
|
<TriggersTab
|
||||||
|
doc={displayDoc}
|
||||||
|
onPatch={patchDoc}
|
||||||
|
disabled={visualDisabled}
|
||||||
|
owner={owner}
|
||||||
|
auths={auths}
|
||||||
|
pages={pages}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{tab === "yaml" ? (
|
||||||
|
<YamlTab
|
||||||
|
content={yaml}
|
||||||
|
onChange={onYamlTabChange}
|
||||||
|
parseError={parseError}
|
||||||
|
mermaid={mermaid}
|
||||||
|
parsed={parsed}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showTest && owner && file ? (
|
||||||
|
<WorkflowTestPanel
|
||||||
|
owner={owner}
|
||||||
|
file={file}
|
||||||
|
defaultData={defaultData}
|
||||||
|
disabled={Boolean(testDisabledReason)}
|
||||||
|
disabledReason={testDisabledReason}
|
||||||
|
testKey={testKey}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { CodeEditor } from "../CodeEditor.jsx";
|
||||||
|
import { MermaidDiagram } from "../MermaidDiagram.jsx";
|
||||||
|
|
||||||
|
export function YamlTab({ content, onChange, parseError, mermaid, parsed }) {
|
||||||
|
return (
|
||||||
|
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-2">
|
||||||
|
<div className="min-h-0">
|
||||||
|
<CodeEditor language="yaml" value={content} onChange={onChange} height="100%" />
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 overflow-auto">
|
||||||
|
{parseError ? (
|
||||||
|
<p className="text-error text-sm">{parseError}</p>
|
||||||
|
) : (
|
||||||
|
<MermaidDiagram chart={mermaid.chart} scriptIds={mermaid.scriptIds} />
|
||||||
|
)}
|
||||||
|
{parsed?.name ? <p className="text-sm opacity-70 mt-2">{parsed.name}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export const CRON_PRESETS = [
|
||||||
|
{ id: "every-minute", label: "Every minute", schedule: "* * * * *" },
|
||||||
|
{ id: "every-5-minutes", label: "Every 5 minutes", schedule: "*/5 * * * *" },
|
||||||
|
{ id: "every-15-minutes", label: "Every 15 minutes", schedule: "*/15 * * * *" },
|
||||||
|
{ id: "every-30-minutes", label: "Every 30 minutes", schedule: "*/30 * * * *" },
|
||||||
|
{ id: "every-hour", label: "Every hour", schedule: "0 * * * *" },
|
||||||
|
{ id: "every-2-hours", label: "Every 2 hours", schedule: "0 */2 * * *" },
|
||||||
|
{ id: "every-6-hours", label: "Every 6 hours", schedule: "0 */6 * * *" },
|
||||||
|
{ id: "daily-midnight", label: "Every day at midnight", schedule: "0 0 * * *" },
|
||||||
|
{ id: "daily-1am", label: "Every day at 1:00 AM", schedule: "0 1 * * *" },
|
||||||
|
{ id: "weekdays-9am", label: "Weekdays at 9:00 AM", schedule: "0 9 * * 1-5" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CRON_CUSTOM = "custom";
|
||||||
|
|
||||||
|
export function matchCronPreset(schedule) {
|
||||||
|
const value = String(schedule ?? "").trim();
|
||||||
|
const found = CRON_PRESETS.find((p) => p.schedule === value);
|
||||||
|
return found?.id ?? CRON_CUSTOM;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleForPreset(id) {
|
||||||
|
return CRON_PRESETS.find((p) => p.id === id)?.schedule ?? null;
|
||||||
|
}
|
||||||
@@ -49,6 +49,13 @@ function defaultsFromFields(fields) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function defaultConfigFromMeta(meta) {
|
||||||
|
if (meta?.example?.config && typeof meta.example.config === "object" && !Array.isArray(meta.example.config)) {
|
||||||
|
return { ...meta.example.config };
|
||||||
|
}
|
||||||
|
return defaultsFromFields(meta?.config);
|
||||||
|
}
|
||||||
|
|
||||||
export function contextFromMeta(meta) {
|
export function contextFromMeta(meta) {
|
||||||
if (meta?.example && typeof meta.example === "object" && !Array.isArray(meta.example)) {
|
if (meta?.example && typeof meta.example === "object" && !Array.isArray(meta.example)) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
||||||
|
|
||||||
|
const KNOWN_TOP = new Set(["name", "description", "enabled", "scripts", "triggers"]);
|
||||||
|
const HTTP_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
||||||
|
|
||||||
|
export const NEW_WORKFLOW_YAML = `name: new workflow
|
||||||
|
scripts:
|
||||||
|
- get-current-time.js
|
||||||
|
triggers:
|
||||||
|
- type: HTTP
|
||||||
|
method: POST
|
||||||
|
path: /new
|
||||||
|
`;
|
||||||
|
|
||||||
|
let uidSeq = 0;
|
||||||
|
|
||||||
|
export function nextUiId(prefix = "ui") {
|
||||||
|
uidSeq += 1;
|
||||||
|
return `${prefix}-${uidSeq}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function namespacedPath(owner, triggerPath) {
|
||||||
|
const cleaned = String(triggerPath ?? "")
|
||||||
|
.replace(/^\/+/, "")
|
||||||
|
.trim();
|
||||||
|
return `/u/${owner}/${cleaned}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} text
|
||||||
|
* @returns {{ doc: WorkflowDoc | null, parseError: string | null }}
|
||||||
|
*/
|
||||||
|
export function parseWorkflowYaml(text) {
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = parseYaml(text ?? "");
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
doc: null,
|
||||||
|
parseError: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return { doc: null, parseError: "workflow yaml must be an object" };
|
||||||
|
}
|
||||||
|
return { doc: yamlObjectToDoc(raw), parseError: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown>} raw
|
||||||
|
*/
|
||||||
|
export function yamlObjectToDoc(raw) {
|
||||||
|
/** @type {Record<string, unknown>} */
|
||||||
|
const extra = {};
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (!KNOWN_TOP.has(key)) extra[key] = value;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: typeof raw.name === "string" ? raw.name : "",
|
||||||
|
description: raw.description == null ? "" : String(raw.description),
|
||||||
|
enabled: raw.enabled !== false,
|
||||||
|
scripts: Array.isArray(raw.scripts) ? raw.scripts.map((step) => normalizeStep(step)) : [],
|
||||||
|
triggers: Array.isArray(raw.triggers)
|
||||||
|
? raw.triggers.map((t) => normalizeTrigger(t))
|
||||||
|
: [],
|
||||||
|
extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringifyWorkflowDoc(doc) {
|
||||||
|
/** @type {Record<string, unknown>} */
|
||||||
|
const out = {};
|
||||||
|
if (doc?.name) out.name = doc.name;
|
||||||
|
if (doc?.description) out.description = doc.description;
|
||||||
|
if (doc?.enabled === false) out.enabled = false;
|
||||||
|
Object.assign(out, doc?.extra ?? {});
|
||||||
|
out.scripts = (doc?.scripts ?? []).map(dumpStep);
|
||||||
|
out.triggers = (doc?.triggers ?? []).map(dumpTrigger);
|
||||||
|
return stringifyYaml(out, { indent: 2, lineWidth: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newScriptStep(script, config = {}) {
|
||||||
|
return {
|
||||||
|
uiId: nextUiId("step"),
|
||||||
|
kind: "script",
|
||||||
|
script,
|
||||||
|
config: config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {},
|
||||||
|
id: "",
|
||||||
|
when: "",
|
||||||
|
needs: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newSetStep() {
|
||||||
|
return {
|
||||||
|
uiId: nextUiId("step"),
|
||||||
|
kind: "set",
|
||||||
|
script: "set",
|
||||||
|
as: "value",
|
||||||
|
expression: "",
|
||||||
|
id: "",
|
||||||
|
when: "",
|
||||||
|
needs: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newHttpTrigger() {
|
||||||
|
return {
|
||||||
|
uiId: nextUiId("trig"),
|
||||||
|
type: "HTTP",
|
||||||
|
method: "POST",
|
||||||
|
path: "/new",
|
||||||
|
schedule: "",
|
||||||
|
auth: null,
|
||||||
|
response: "",
|
||||||
|
unauthorized: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newCronTrigger() {
|
||||||
|
return {
|
||||||
|
uiId: nextUiId("trig"),
|
||||||
|
type: "cron",
|
||||||
|
method: "POST",
|
||||||
|
path: "",
|
||||||
|
schedule: "* * * * *",
|
||||||
|
auth: null,
|
||||||
|
response: "",
|
||||||
|
unauthorized: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newWorkflowTrigger() {
|
||||||
|
return {
|
||||||
|
uiId: nextUiId("trig"),
|
||||||
|
type: "workflow",
|
||||||
|
method: "POST",
|
||||||
|
path: "",
|
||||||
|
schedule: "",
|
||||||
|
auth: null,
|
||||||
|
response: "",
|
||||||
|
unauthorized: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasWorkflowTrigger(workflow) {
|
||||||
|
return (workflow?.triggers ?? []).some((t) => String(t?.type ?? "").toLowerCase() === "workflow");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triggerDestinations(workflows, { owner, excludeFile } = {}) {
|
||||||
|
return (workflows ?? []).filter((w) => {
|
||||||
|
if (owner && w.owner !== owner) return false;
|
||||||
|
if (excludeFile && w.file === excludeFile && w.owner === owner) return false;
|
||||||
|
return hasWorkflowTrigger(w);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HTTP_METHODS };
|
||||||
|
|
||||||
|
function normalizeStep(step) {
|
||||||
|
const uiId = nextUiId("step");
|
||||||
|
if (typeof step === "string") {
|
||||||
|
return {
|
||||||
|
uiId,
|
||||||
|
kind: "script",
|
||||||
|
script: step,
|
||||||
|
config: {},
|
||||||
|
id: "",
|
||||||
|
when: "",
|
||||||
|
needs: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (step == null || typeof step !== "object" || Array.isArray(step)) {
|
||||||
|
return {
|
||||||
|
uiId,
|
||||||
|
kind: "script",
|
||||||
|
script: "",
|
||||||
|
config: {},
|
||||||
|
id: "",
|
||||||
|
when: "",
|
||||||
|
needs: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (step.set != null) {
|
||||||
|
const spec = step.set && typeof step.set === "object" ? step.set : {};
|
||||||
|
return {
|
||||||
|
uiId,
|
||||||
|
kind: "set",
|
||||||
|
script: "set",
|
||||||
|
as: typeof spec.as === "string" ? spec.as : "",
|
||||||
|
expression: typeof spec.expression === "string" ? spec.expression : "",
|
||||||
|
id: typeof step.id === "string" ? step.id : "",
|
||||||
|
when: typeof step.when === "string" ? step.when : "",
|
||||||
|
needs: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const known = new Set(["script", "config", "id", "when", "needs", "set"]);
|
||||||
|
/** @type {Record<string, unknown>} */
|
||||||
|
const extra = {};
|
||||||
|
for (const [key, value] of Object.entries(step)) {
|
||||||
|
if (!known.has(key)) extra[key] = value;
|
||||||
|
}
|
||||||
|
const config =
|
||||||
|
step.config && typeof step.config === "object" && !Array.isArray(step.config)
|
||||||
|
? { ...step.config }
|
||||||
|
: {};
|
||||||
|
return {
|
||||||
|
uiId,
|
||||||
|
kind: "script",
|
||||||
|
script: typeof step.script === "string" ? step.script : "",
|
||||||
|
config,
|
||||||
|
id: typeof step.id === "string" ? step.id : "",
|
||||||
|
when: typeof step.when === "string" ? step.when : "",
|
||||||
|
needs: step.needs ?? null,
|
||||||
|
extra: Object.keys(extra).length ? extra : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrigger(raw) {
|
||||||
|
const uiId = nextUiId("trig");
|
||||||
|
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return newHttpTrigger();
|
||||||
|
}
|
||||||
|
let type = raw.type ?? "HTTP";
|
||||||
|
if (String(type).toLowerCase() === "http") type = "HTTP";
|
||||||
|
const known =
|
||||||
|
type === "HTTP"
|
||||||
|
? new Set(["type", "method", "path", "auth", "response", "unauthorized"])
|
||||||
|
: type === "cron"
|
||||||
|
? new Set(["type", "schedule"])
|
||||||
|
: new Set(["type"]);
|
||||||
|
/** @type {Record<string, unknown>} */
|
||||||
|
const extra = {};
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (!known.has(key)) extra[key] = value;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
uiId,
|
||||||
|
type,
|
||||||
|
method: typeof raw.method === "string" && raw.method ? raw.method : "POST",
|
||||||
|
path: typeof raw.path === "string" ? raw.path : "",
|
||||||
|
schedule: typeof raw.schedule === "string" ? raw.schedule : "",
|
||||||
|
auth: raw.auth ?? null,
|
||||||
|
response: typeof raw.response === "string" ? raw.response : "",
|
||||||
|
unauthorized: raw.unauthorized ?? null,
|
||||||
|
extra: Object.keys(extra).length ? extra : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dumpStep(step) {
|
||||||
|
if (step.kind === "set") {
|
||||||
|
/** @type {Record<string, unknown>} */
|
||||||
|
const out = {};
|
||||||
|
if (step.id) out.id = step.id;
|
||||||
|
out.set = {
|
||||||
|
expression: step.expression ?? "",
|
||||||
|
as: step.as ?? "",
|
||||||
|
};
|
||||||
|
if (step.when) out.when = step.when;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
/** @type {Record<string, unknown>} */
|
||||||
|
const out = {};
|
||||||
|
if (step.id) out.id = step.id;
|
||||||
|
out.script = step.script ?? "";
|
||||||
|
const config = step.config;
|
||||||
|
if (config && typeof config === "object" && !Array.isArray(config) && Object.keys(config).length) {
|
||||||
|
out.config = config;
|
||||||
|
}
|
||||||
|
if (step.when) out.when = step.when;
|
||||||
|
if (step.needs != null && !isEmptyNeeds(step.needs)) out.needs = step.needs;
|
||||||
|
Object.assign(out, step.extra ?? {});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dumpTrigger(t) {
|
||||||
|
const type = String(t?.type ?? "").toLowerCase() === "http" ? "HTTP" : t?.type;
|
||||||
|
if (type === "HTTP") {
|
||||||
|
/** @type {Record<string, unknown>} */
|
||||||
|
const out = {
|
||||||
|
type: "HTTP",
|
||||||
|
method: t.method || "POST",
|
||||||
|
path: t.path || "/",
|
||||||
|
};
|
||||||
|
if (t.auth != null && t.auth !== false && t.auth !== "") out.auth = t.auth;
|
||||||
|
if (t.response) out.response = t.response;
|
||||||
|
if (t.unauthorized != null && t.unauthorized !== "") out.unauthorized = t.unauthorized;
|
||||||
|
Object.assign(out, t.extra ?? {});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (type === "cron") {
|
||||||
|
/** @type {Record<string, unknown>} */
|
||||||
|
const out = { type: "cron", schedule: t.schedule || "" };
|
||||||
|
Object.assign(out, t.extra ?? {});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (type === "workflow") {
|
||||||
|
return { type: "workflow", ...(t.extra ?? {}) };
|
||||||
|
}
|
||||||
|
return { type: t?.type ?? "HTTP", ...(t.extra ?? {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyNeeds(needs) {
|
||||||
|
if (needs == null) return true;
|
||||||
|
if (Array.isArray(needs)) return needs.length === 0;
|
||||||
|
if (typeof needs === "object") return Object.keys(needs).length === 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -1,46 +1,15 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { parse as parseYaml } from "yaml";
|
|
||||||
import { LuArrowLeft, LuPause, LuPlay, LuSave } from "react-icons/lu";
|
import { LuArrowLeft, LuPause, LuPlay, LuSave } from "react-icons/lu";
|
||||||
import { errorMessage } from "../api/client.js";
|
import { errorMessage } from "../api/client.js";
|
||||||
import {
|
import {
|
||||||
useOwners,
|
useOwners,
|
||||||
useRunWorkflow,
|
|
||||||
useSaveWorkflow,
|
useSaveWorkflow,
|
||||||
useSetWorkflowEnabled,
|
useSetWorkflowEnabled,
|
||||||
useWorkflow,
|
useWorkflow,
|
||||||
} from "../api/hooks.js";
|
} from "../api/hooks.js";
|
||||||
import { CodeEditor } from "../components/CodeEditor.jsx";
|
import { WorkflowVisualEditor } from "../components/workflow/WorkflowVisualEditor.jsx";
|
||||||
import { MermaidDiagram } from "../components/MermaidDiagram.jsx";
|
import { NEW_WORKFLOW_YAML, parseWorkflowYaml } from "../lib/workflow-doc.js";
|
||||||
import { workflowToFlowchart } from "../lib/workflow-mermaid.js";
|
|
||||||
|
|
||||||
const NEW_YAML = `name: new workflow
|
|
||||||
scripts:
|
|
||||||
- get-current-time.js
|
|
||||||
triggers:
|
|
||||||
- type: HTTP
|
|
||||||
method: POST
|
|
||||||
path: /new
|
|
||||||
`;
|
|
||||||
|
|
||||||
function useYamlPreview(content) {
|
|
||||||
return useMemo(() => {
|
|
||||||
try {
|
|
||||||
const parsedYaml = parseYaml(content);
|
|
||||||
return {
|
|
||||||
parsed: parsedYaml,
|
|
||||||
parseError: null,
|
|
||||||
mermaid: workflowToFlowchart(parsedYaml),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
parsed: null,
|
|
||||||
parseError: err instanceof Error ? err.message : String(err),
|
|
||||||
mermaid: { chart: "", scriptIds: {} },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [content]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkflowEditorLayout({
|
function WorkflowEditorLayout({
|
||||||
title,
|
title,
|
||||||
@@ -49,11 +18,8 @@ function WorkflowEditorLayout({
|
|||||||
saveDisabled,
|
saveDisabled,
|
||||||
saveError,
|
saveError,
|
||||||
saveSuccess,
|
saveSuccess,
|
||||||
formId,
|
onSave,
|
||||||
onRun,
|
onTest,
|
||||||
runPending,
|
|
||||||
runDisabled,
|
|
||||||
runError,
|
|
||||||
onToggleEnabled,
|
onToggleEnabled,
|
||||||
enabled,
|
enabled,
|
||||||
enablePending,
|
enablePending,
|
||||||
@@ -86,64 +52,26 @@ function WorkflowEditorLayout({
|
|||||||
{enabled ? "Disable" : "Enable"}
|
{enabled ? "Disable" : "Enable"}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{onRun ? (
|
{onTest ? (
|
||||||
<button
|
<button type="button" className="btn btn-sm" onClick={onTest}>
|
||||||
type="button"
|
|
||||||
className="btn btn-sm"
|
|
||||||
disabled={runPending || runDisabled}
|
|
||||||
onClick={onRun}
|
|
||||||
>
|
|
||||||
{runPending ? (
|
|
||||||
<span className="loading loading-spinner loading-xs" />
|
|
||||||
) : (
|
|
||||||
<LuPlay className="size-4" />
|
<LuPlay className="size-4" />
|
||||||
)}
|
Test
|
||||||
Run
|
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
form={formId}
|
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
disabled={savePending || saveDisabled}
|
disabled={savePending || saveDisabled}
|
||||||
|
onClick={onSave}
|
||||||
>
|
>
|
||||||
<LuSave className="size-4" />
|
<LuSave className="size-4" />
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
{saveError ? (
|
{saveError ? <p className="text-error text-sm shrink-0">{saveError}</p> : null}
|
||||||
<p className="text-error text-sm shrink-0">{saveError}</p>
|
{enableError ? <p className="text-error text-sm shrink-0">{enableError}</p> : null}
|
||||||
) : null}
|
{saveSuccess ? <p className="text-success text-sm shrink-0">Workflow saved</p> : null}
|
||||||
{runError ? (
|
|
||||||
<p className="text-error text-sm shrink-0">{runError}</p>
|
|
||||||
) : null}
|
|
||||||
{enableError ? (
|
|
||||||
<p className="text-error text-sm shrink-0">{enableError}</p>
|
|
||||||
) : null}
|
|
||||||
{saveSuccess ? (
|
|
||||||
<p className="text-success text-sm shrink-0">Workflow saved</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkflowYamlAndDiagram({ content, onChange, parseError, mermaid, parsed }) {
|
|
||||||
return (
|
|
||||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-2">
|
|
||||||
<div className="min-h-0">
|
|
||||||
<CodeEditor language="yaml" value={content} onChange={onChange} height="100%" />
|
|
||||||
</div>
|
|
||||||
<div className="min-h-0 overflow-auto">
|
|
||||||
{parseError ? (
|
|
||||||
<p className="text-error text-sm">{parseError}</p>
|
|
||||||
) : (
|
|
||||||
<MermaidDiagram chart={mermaid.chart} scriptIds={mermaid.scriptIds} />
|
|
||||||
)}
|
|
||||||
{parsed?.name ? (
|
|
||||||
<p className="text-sm opacity-70 mt-2">{parsed.name}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -153,16 +81,15 @@ export function WorkflowNewPage() {
|
|||||||
const { data: owners = [] } = useOwners();
|
const { data: owners = [] } = useOwners();
|
||||||
const [owner, setOwner] = useState("");
|
const [owner, setOwner] = useState("");
|
||||||
const [file, setFile] = useState("");
|
const [file, setFile] = useState("");
|
||||||
const [content, setContent] = useState(NEW_YAML);
|
const [content, setContent] = useState(NEW_WORKFLOW_YAML);
|
||||||
|
const [savedYaml] = useState(NEW_WORKFLOW_YAML);
|
||||||
const save = useSaveWorkflow();
|
const save = useSaveWorkflow();
|
||||||
const { parsed, parseError, mermaid } = useYamlPreview(content);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!owner && owners[0]) setOwner(owners[0]);
|
if (!owner && owners[0]) setOwner(owners[0]);
|
||||||
}, [owner, owners]);
|
}, [owner, owners]);
|
||||||
|
|
||||||
function onSave(e) {
|
function onSave() {
|
||||||
e.preventDefault();
|
|
||||||
const yamlFile = file.endsWith(".yaml") || file.endsWith(".yml") ? file : `${file}.yaml`;
|
const yamlFile = file.endsWith(".yaml") || file.endsWith(".yml") ? file : `${file}.yaml`;
|
||||||
save.mutate(
|
save.mutate(
|
||||||
{ owner, file: yamlFile, content },
|
{ owner, file: yamlFile, content },
|
||||||
@@ -178,17 +105,21 @@ export function WorkflowNewPage() {
|
|||||||
return (
|
return (
|
||||||
<WorkflowEditorLayout
|
<WorkflowEditorLayout
|
||||||
title="New workflow"
|
title="New workflow"
|
||||||
formId="workflow-edit-form"
|
onSave={onSave}
|
||||||
savePending={save.isPending}
|
savePending={save.isPending}
|
||||||
saveDisabled={!owner || !file}
|
saveDisabled={!owner || !file}
|
||||||
saveError={save.isError ? errorMessage(save.error) : null}
|
saveError={save.isError ? errorMessage(save.error) : null}
|
||||||
>
|
>
|
||||||
<form
|
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||||
id="workflow-edit-form"
|
<WorkflowVisualEditor
|
||||||
onSubmit={onSave}
|
yaml={content}
|
||||||
className="flex min-h-0 flex-1 flex-col gap-3"
|
onYamlChange={setContent}
|
||||||
>
|
owner={owner}
|
||||||
<div className="flex shrink-0 flex-col sm:flex-row gap-2">
|
file={file}
|
||||||
|
savedYaml={savedYaml}
|
||||||
|
showTest={false}
|
||||||
|
extraChrome={
|
||||||
|
<>
|
||||||
<input
|
<input
|
||||||
className="input input-sm w-full sm:max-w-xs"
|
className="input input-sm w-full sm:max-w-xs"
|
||||||
placeholder="owner"
|
placeholder="owner"
|
||||||
@@ -203,58 +134,42 @@ export function WorkflowNewPage() {
|
|||||||
onChange={(e) => setFile(e.target.value)}
|
onChange={(e) => setFile(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</>
|
||||||
<WorkflowYamlAndDiagram
|
}
|
||||||
content={content}
|
|
||||||
onChange={setContent}
|
|
||||||
parseError={parseError}
|
|
||||||
mermaid={mermaid}
|
|
||||||
parsed={parsed}
|
|
||||||
/>
|
/>
|
||||||
</form>
|
</div>
|
||||||
</WorkflowEditorLayout>
|
</WorkflowEditorLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkflowEditPage() {
|
export function WorkflowEditPage() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { owner: rawOwner, file: rawFile } = useParams();
|
const { owner: rawOwner, file: rawFile } = useParams();
|
||||||
const owner = decodeURIComponent(rawOwner ?? "");
|
const owner = decodeURIComponent(rawOwner ?? "");
|
||||||
const file = decodeURIComponent(rawFile ?? "");
|
const file = decodeURIComponent(rawFile ?? "");
|
||||||
const existing = useWorkflow(owner, file);
|
const existing = useWorkflow(owner, file);
|
||||||
const save = useSaveWorkflow();
|
const save = useSaveWorkflow();
|
||||||
const run = useRunWorkflow();
|
|
||||||
const setEnabled = useSetWorkflowEnabled();
|
const setEnabled = useSetWorkflowEnabled();
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
|
const [savedYaml, setSavedYaml] = useState("");
|
||||||
const [contentReady, setContentReady] = useState(false);
|
const [contentReady, setContentReady] = useState(false);
|
||||||
const { parsed, parseError, mermaid } = useYamlPreview(content);
|
const [testKey, setTestKey] = useState(0);
|
||||||
|
|
||||||
function onRun() {
|
|
||||||
run.mutate(
|
|
||||||
{ owner, file },
|
|
||||||
{
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data?.runId) navigate(`/events/${data.runId}`);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
const runId = err?.response?.data?.runId;
|
|
||||||
if (runId) navigate(`/events/${runId}`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existing.isLoading) return;
|
if (existing.isLoading) return;
|
||||||
if (existing.data?.content != null) {
|
if (!contentReady && existing.data?.content != null) {
|
||||||
setContent(existing.data.content);
|
setContent(existing.data.content);
|
||||||
|
setSavedYaml(existing.data.content);
|
||||||
setContentReady(true);
|
setContentReady(true);
|
||||||
}
|
}
|
||||||
}, [existing.data, existing.isLoading]);
|
}, [existing.data, existing.isLoading, contentReady]);
|
||||||
|
|
||||||
function onSave(e) {
|
function onSave() {
|
||||||
e.preventDefault();
|
save.mutate(
|
||||||
save.mutate({ owner, file, content });
|
{ owner, file, content },
|
||||||
|
{
|
||||||
|
onSuccess: () => setSavedYaml(content),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing.isLoading) {
|
if (existing.isLoading) {
|
||||||
@@ -277,18 +192,17 @@ export function WorkflowEditPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const yamlOk = !parseWorkflowYaml(content).parseError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkflowEditorLayout
|
<WorkflowEditorLayout
|
||||||
title={`${owner}/${file}`}
|
title={`${owner}/${file}`}
|
||||||
formId="workflow-edit-form"
|
|
||||||
savePending={save.isPending}
|
savePending={save.isPending}
|
||||||
saveDisabled={!contentReady}
|
saveDisabled={!contentReady}
|
||||||
saveError={save.isError ? errorMessage(save.error) : null}
|
saveError={save.isError ? errorMessage(save.error) : null}
|
||||||
saveSuccess={save.isSuccess}
|
saveSuccess={save.isSuccess}
|
||||||
onRun={onRun}
|
onSave={onSave}
|
||||||
runPending={run.isPending}
|
onTest={() => setTestKey((k) => k + 1)}
|
||||||
runDisabled={!contentReady || Boolean(parseError) || Boolean(existing.data?.loadError)}
|
|
||||||
runError={run.isError && !run.error?.response?.data?.runId ? errorMessage(run.error) : null}
|
|
||||||
onToggleEnabled={() =>
|
onToggleEnabled={() =>
|
||||||
setEnabled.mutate({
|
setEnabled.mutate({
|
||||||
owner,
|
owner,
|
||||||
@@ -298,18 +212,20 @@ export function WorkflowEditPage() {
|
|||||||
}
|
}
|
||||||
enabled={existing.data?.parsed?.enabled !== false}
|
enabled={existing.data?.parsed?.enabled !== false}
|
||||||
enablePending={setEnabled.isPending}
|
enablePending={setEnabled.isPending}
|
||||||
enableDisabled={!contentReady || Boolean(existing.data?.parseError)}
|
enableDisabled={!contentReady || Boolean(existing.data?.parseError) || !yamlOk}
|
||||||
enableError={setEnabled.isError ? errorMessage(setEnabled.error) : null}
|
enableError={setEnabled.isError ? errorMessage(setEnabled.error) : null}
|
||||||
>
|
>
|
||||||
<form id="workflow-edit-form" onSubmit={onSave} className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||||
<WorkflowYamlAndDiagram
|
<WorkflowVisualEditor
|
||||||
content={content}
|
yaml={content}
|
||||||
onChange={setContent}
|
onYamlChange={setContent}
|
||||||
parseError={parseError}
|
owner={owner}
|
||||||
mermaid={mermaid}
|
file={file}
|
||||||
parsed={parsed}
|
savedYaml={savedYaml}
|
||||||
|
showTest
|
||||||
|
testKey={testKey}
|
||||||
/>
|
/>
|
||||||
</form>
|
</div>
|
||||||
</WorkflowEditorLayout>
|
</WorkflowEditorLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+70
@@ -68,6 +68,15 @@ importers:
|
|||||||
|
|
||||||
packages/web:
|
packages/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@dnd-kit/core':
|
||||||
|
specifier: ^6.3.1
|
||||||
|
version: 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
|
'@dnd-kit/sortable':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
|
||||||
|
'@dnd-kit/utilities':
|
||||||
|
specifier: ^3.2.2
|
||||||
|
version: 3.2.2(react@19.2.8)
|
||||||
'@monaco-editor/react':
|
'@monaco-editor/react':
|
||||||
specifier: ^4.7.0
|
specifier: ^4.7.0
|
||||||
version: 4.7.0(monaco-editor@0.56.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
version: 4.7.0(monaco-editor@0.56.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
@@ -77,6 +86,9 @@ importers:
|
|||||||
axios:
|
axios:
|
||||||
specifier: ^1.11.0
|
specifier: ^1.11.0
|
||||||
version: 1.19.0
|
version: 1.19.0
|
||||||
|
cronstrue:
|
||||||
|
specifier: ^3.24.0
|
||||||
|
version: 3.24.0
|
||||||
mermaid:
|
mermaid:
|
||||||
specifier: ^11.9.0
|
specifier: ^11.9.0
|
||||||
version: 11.16.1
|
version: 11.16.1
|
||||||
@@ -206,6 +218,28 @@ packages:
|
|||||||
'@chevrotain/types@11.1.2':
|
'@chevrotain/types@11.1.2':
|
||||||
resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
|
resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1':
|
||||||
|
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1':
|
||||||
|
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0':
|
||||||
|
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@dnd-kit/core': ^6.3.0
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2':
|
||||||
|
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.28.2':
|
'@esbuild/aix-ppc64@0.28.2':
|
||||||
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
|
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -912,6 +946,10 @@ packages:
|
|||||||
cose-base@2.2.0:
|
cose-base@2.2.0:
|
||||||
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
|
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
|
||||||
|
|
||||||
|
cronstrue@3.24.0:
|
||||||
|
resolution: {integrity: sha512-t/Ji3Ur2c/pzhIAWNwC0ftl3JAE4dLfCjAdZoTZXmPDZwcispnS1PaMcMS4OmIIXyIVouAz+yw+mfQiE3hz5OQ==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -1902,6 +1940,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==}
|
resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==}
|
||||||
engines: {node: '>=6.10'}
|
engines: {node: '>=6.10'}
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
update-browserslist-db@1.3.1:
|
update-browserslist-db@1.3.1:
|
||||||
resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==}
|
resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2100,6 +2141,31 @@ snapshots:
|
|||||||
|
|
||||||
'@chevrotain/types@11.1.2': {}
|
'@chevrotain/types@11.1.2': {}
|
||||||
|
|
||||||
|
'@dnd-kit/accessibility@3.1.1(react@19.2.8)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.8
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/accessibility': 3.1.1(react@19.2.8)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@19.2.8)
|
||||||
|
react: 19.2.8
|
||||||
|
react-dom: 19.2.8(react@19.2.8)
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)':
|
||||||
|
dependencies:
|
||||||
|
'@dnd-kit/core': 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
|
'@dnd-kit/utilities': 3.2.2(react@19.2.8)
|
||||||
|
react: 19.2.8
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@dnd-kit/utilities@3.2.2(react@19.2.8)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.8
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.28.2':
|
'@esbuild/aix-ppc64@0.28.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2711,6 +2777,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
layout-base: 2.0.1
|
layout-base: 2.0.1
|
||||||
|
|
||||||
|
cronstrue@3.24.0: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -3674,6 +3742,8 @@ snapshots:
|
|||||||
|
|
||||||
ts-dedent@2.3.0: {}
|
ts-dedent@2.3.0: {}
|
||||||
|
|
||||||
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
update-browserslist-db@1.3.1(browserslist@4.28.8):
|
update-browserslist-db@1.3.1(browserslist@4.28.8):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.28.8
|
browserslist: 4.28.8
|
||||||
|
|||||||
Reference in New Issue
Block a user