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:
2026-08-15 18:33:13 +07:00
parent 94ba931d9b
commit 7fe5defc43
22 changed files with 2486 additions and 159 deletions
@@ -20,6 +20,11 @@ function FieldTable({ title, fields }) {
const notes = [
field.required ? "required" : 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,
]
.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&apos;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>
);
}