feat(scripts): add previewConfigKey to various scripts and enhance UI components
- Added `previewConfigKey` metadata to multiple scripts including fetch-binary, fetch-html, fetch-http, fetch-rss-feed, fingerprint, get-secret, jsonata, ntfy, render-template, and send-email, improving configuration visibility. - Updated the `FieldHelp` and `ScriptCard` components to utilize the new preview configuration, enhancing the user interface for displaying script metadata. - Refactored `TriggerCard` and `WorkflowTestPanel` components to support expanded views, improving user interaction and accessibility of configuration details. - Enhanced the `WorkflowVisualEditor` to manage test panel visibility more effectively. This update improves the overall user experience by providing clearer context and configuration options for scripts and workflows.
This commit is contained in:
@@ -71,6 +71,7 @@ async function fetchBinary(ctx) {
|
|||||||
|
|
||||||
fetchBinary.meta = {
|
fetchBinary.meta = {
|
||||||
description: "Download a binary URL into ctx.data as a Buffer",
|
description: "Download a binary URL into ctx.data as a Buffer",
|
||||||
|
previewConfigKey: "url",
|
||||||
config: {
|
config: {
|
||||||
url: { type: "string", required: false, description: "Direct download URL" },
|
url: { type: "string", required: false, description: "Direct download URL" },
|
||||||
urlVar: { type: "string", required: false, description: "Key in ctx.data that holds the URL" },
|
urlVar: { type: "string", required: false, description: "Key in ctx.data that holds the URL" },
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ async function fetchHtml(ctx) {
|
|||||||
|
|
||||||
fetchHtml.meta = {
|
fetchHtml.meta = {
|
||||||
description: "Fetch HTML and optionally select elements or transform with JSONata",
|
description: "Fetch HTML and optionally select elements or transform with JSONata",
|
||||||
|
previewConfigKey: "url",
|
||||||
config: {
|
config: {
|
||||||
url: { type: "string", required: true, description: "Page URL" },
|
url: { type: "string", required: true, description: "Page URL" },
|
||||||
selector: { type: "string", required: false, description: "CSS selector" },
|
selector: { type: "string", required: false, description: "CSS selector" },
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ async function fetchHttp(ctx) {
|
|||||||
|
|
||||||
fetchHttp.meta = {
|
fetchHttp.meta = {
|
||||||
description: "Fetch a URL and store the response body.",
|
description: "Fetch a URL and store the response body.",
|
||||||
|
previewConfigKey: "url",
|
||||||
config: {
|
config: {
|
||||||
url: { type: "string", required: true, description: "Request URL" },
|
url: { type: "string", required: true, description: "Request URL" },
|
||||||
method: {
|
method: {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ async function fetchRssFeed(ctx) {
|
|||||||
|
|
||||||
fetchRssFeed.meta = {
|
fetchRssFeed.meta = {
|
||||||
description: "Fetch an RSS/Atom feed and optionally transform it with JSONata",
|
description: "Fetch an RSS/Atom feed and optionally transform it with JSONata",
|
||||||
|
previewConfigKey: "url",
|
||||||
config: {
|
config: {
|
||||||
url: { type: "string", required: false, description: "Feed URL (or pass data.url)" },
|
url: { type: "string", required: false, description: "Feed URL (or pass data.url)" },
|
||||||
outputVar: {
|
outputVar: {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ async function fingerprint(ctx) {
|
|||||||
fingerprint.meta = {
|
fingerprint.meta = {
|
||||||
description:
|
description:
|
||||||
"Hash a value, compare it to the last stored fingerprint, and skip remaining steps when unchanged",
|
"Hash a value, compare it to the last stored fingerprint, and skip remaining steps when unchanged",
|
||||||
|
previewConfigKey: "key",
|
||||||
config: {
|
config: {
|
||||||
key: {
|
key: {
|
||||||
type: "string",
|
type: "string",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ async function getSecret(ctx) {
|
|||||||
getSecret.meta = {
|
getSecret.meta = {
|
||||||
description:
|
description:
|
||||||
"Load a named secret for this workflow owner into ctx.data. The value is wrapped and redacted in logs.",
|
"Load a named secret for this workflow owner into ctx.data. The value is wrapped and redacted in logs.",
|
||||||
|
previewConfigKey: "name",
|
||||||
config: {
|
config: {
|
||||||
name: {
|
name: {
|
||||||
type: "string",
|
type: "string",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ async function jsonataFn(ctx) {
|
|||||||
|
|
||||||
jsonataFn.meta = {
|
jsonataFn.meta = {
|
||||||
description: "Evaluate a JSONata expression against ctx.data and return the result as the next context",
|
description: "Evaluate a JSONata expression against ctx.data and return the result as the next context",
|
||||||
|
previewConfigKey: "expression",
|
||||||
config: {
|
config: {
|
||||||
expression: { type: "string", required: true, description: "JSONata expression" },
|
expression: { type: "string", required: true, description: "JSONata expression" },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ async function ntfy(ctx) {
|
|||||||
|
|
||||||
ntfy.meta = {
|
ntfy.meta = {
|
||||||
description: "Send a message or file to an ntfy topic",
|
description: "Send a message or file to an ntfy topic",
|
||||||
|
previewConfigKey: "url",
|
||||||
config: {
|
config: {
|
||||||
url: {
|
url: {
|
||||||
type: "string",
|
type: "string",
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ async function renderTemplate(ctx) {
|
|||||||
renderTemplate.meta = {
|
renderTemplate.meta = {
|
||||||
description:
|
description:
|
||||||
"Render an HTML template from Responses (kind: template) with Mustache and return html + plain-text fallback",
|
"Render an HTML template from Responses (kind: template) with Mustache and return html + plain-text fallback",
|
||||||
|
previewConfigKey: "template",
|
||||||
config: {
|
config: {
|
||||||
template: {
|
template: {
|
||||||
type: "string",
|
type: "string",
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ async function sendEmail(ctx) {
|
|||||||
|
|
||||||
sendEmail.meta = {
|
sendEmail.meta = {
|
||||||
description: "Send an email via SMTP (nodemailer); plain text, HTML, or both",
|
description: "Send an email via SMTP (nodemailer); plain text, HTML, or both",
|
||||||
|
previewConfigKey: "from",
|
||||||
config: {
|
config: {
|
||||||
service: {
|
service: {
|
||||||
type: "string",
|
type: "string",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ async function triggerWorkflow(ctx) {
|
|||||||
triggerWorkflow.meta = {
|
triggerWorkflow.meta = {
|
||||||
description:
|
description:
|
||||||
"Fire-and-forget another workflow by YAML name (same owner). Destination must declare triggers: [{ type: workflow }]. Optionally reshape ctx.data with JSONata before sending.",
|
"Fire-and-forget another workflow by YAML name (same owner). Destination must declare triggers: [{ type: workflow }]. Optionally reshape ctx.data with JSONata before sending.",
|
||||||
|
previewConfigKey: "name",
|
||||||
config: {
|
config: {
|
||||||
name: {
|
name: {
|
||||||
type: "string",
|
type: "string",
|
||||||
|
|||||||
@@ -30,3 +30,4 @@ triggers:
|
|||||||
- type: HTTP
|
- type: HTTP
|
||||||
method: GET
|
method: GET
|
||||||
path: /new
|
path: /new
|
||||||
|
enabled: false
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export function FieldHelp({ description }) {
|
|||||||
if (!description) return null;
|
if (!description) return null;
|
||||||
const long = String(description).length > 80;
|
const long = String(description).length > 80;
|
||||||
return (
|
return (
|
||||||
<div className="tooltip tooltip-top" {...(long ? {} : { "data-tip": description })}>
|
<div className="tooltip tooltip-top z-50" {...(long ? {} : { "data-tip": description })}>
|
||||||
{long ? (
|
{long ? (
|
||||||
<div className="tooltip-content">
|
<div className="tooltip-content">
|
||||||
<p className="max-w-xs text-left text-xs whitespace-pre-wrap">{description}</p>
|
<p className="max-w-xs text-left text-xs whitespace-pre-wrap">{description}</p>
|
||||||
@@ -27,3 +27,140 @@ export function FieldLabel({ name, required, description, children }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fieldNotes(field) {
|
||||||
|
return [
|
||||||
|
field.required ? "required" : null,
|
||||||
|
field.default !== undefined ? `default ${JSON.stringify(field.default)}` : null,
|
||||||
|
Array.isArray(field.enum)
|
||||||
|
? field.enum.map((x) => (x && typeof x === "object" ? x.value : x)).join(" | ")
|
||||||
|
: Array.isArray(field.options)
|
||||||
|
? field.options.map((x) => (x && typeof x === "object" ? x.value : x)).join(" | ")
|
||||||
|
: null,
|
||||||
|
field.description,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const TITLE_PREVIEW_MAX = 40;
|
||||||
|
const TOOLTIP_VALUE_MAX = 120;
|
||||||
|
|
||||||
|
function stringifyConfigValue(val) {
|
||||||
|
if (typeof val === "string") return val.replace(/\s+/g, " ").trim();
|
||||||
|
if (typeof val === "number" || typeof val === "boolean") return String(val);
|
||||||
|
try {
|
||||||
|
return JSON.stringify(val);
|
||||||
|
} catch {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateConfigValue(val, maxLen) {
|
||||||
|
const text = stringifyConfigValue(val);
|
||||||
|
if (!text) return "";
|
||||||
|
if (text.length <= maxLen) return text;
|
||||||
|
return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapsed card title suffix from `meta.previewConfigKey`. */
|
||||||
|
export function previewConfigValue(config, key, maxLen = TITLE_PREVIEW_MAX) {
|
||||||
|
const text = configValueText(config, key);
|
||||||
|
if (!text) return "";
|
||||||
|
if (text.length <= maxLen) return text;
|
||||||
|
return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configValueText(config, key) {
|
||||||
|
if (typeof key !== "string" || !key.trim()) return "";
|
||||||
|
if (config == null || typeof config !== "object") return "";
|
||||||
|
const val = config[key];
|
||||||
|
if (val == null || val === "") return "";
|
||||||
|
return stringifyConfigValue(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formatted DaisyUI tooltip for a step's current config. */
|
||||||
|
export function ConfigTooltip({ config, label = "config" }) {
|
||||||
|
const entries =
|
||||||
|
config && typeof config === "object" && !Array.isArray(config) ? Object.entries(config) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tooltip tooltip-bottom z-50">
|
||||||
|
<div className="tooltip-content z-50 p-0">
|
||||||
|
<div className="max-w-sm text-left">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="px-3 py-2 text-xs opacity-80">No config</p>
|
||||||
|
) : (
|
||||||
|
<table className="table table-xs">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Key</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map(([name, val]) => (
|
||||||
|
<tr key={name}>
|
||||||
|
<td className="font-mono align-top">{name}</td>
|
||||||
|
<td className="max-w-56 break-all whitespace-normal font-mono opacity-90">
|
||||||
|
{val == null || val === "" ? (
|
||||||
|
<span className="opacity-50">—</span>
|
||||||
|
) : (
|
||||||
|
truncateConfigValue(val, TOOLTIP_VALUE_MAX)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="badge badge-ghost badge-sm h-6 cursor-help font-normal" aria-label="Current config">
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formatted DaisyUI tooltip for script.meta.input (or similar field maps). */
|
||||||
|
export function SchemaTooltip({ label = "Input", fields }) {
|
||||||
|
const entries = Object.entries(fields ?? {});
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tooltip tooltip-bottom z-50">
|
||||||
|
<div className="tooltip-content z-50 p-0">
|
||||||
|
<div className="max-w-sm text-left">
|
||||||
|
<table className="table table-xs">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Field</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map(([name, spec]) => {
|
||||||
|
const field = spec && typeof spec === "object" ? spec : { description: String(spec) };
|
||||||
|
return (
|
||||||
|
<tr key={name}>
|
||||||
|
<td className="font-mono">
|
||||||
|
{name}
|
||||||
|
{field.required ? <span className="text-error">*</span> : null}
|
||||||
|
</td>
|
||||||
|
<td className="opacity-80">{field.type ?? ""}</td>
|
||||||
|
<td className="max-w-48 whitespace-normal opacity-90">{fieldNotes(field)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-ghost btn-xs" aria-label={`${label} schema`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { LuGripVertical, LuMaximize2, LuMinimize2, LuPencil, LuPlay, LuTrash2 } from "react-icons/lu";
|
import { LuChevronDown, LuGripVertical, LuMaximize2, LuMinimize2, LuPlay, LuTrash2 } from "react-icons/lu";
|
||||||
import { useDryRunScript, useScript } from "../../api/hooks.js";
|
import { useDryRunScript, useScript } from "../../api/hooks.js";
|
||||||
import { errorMessage } from "../../api/client.js";
|
import { errorMessage } from "../../api/client.js";
|
||||||
import { CodeEditor } from "../CodeEditor.jsx";
|
import { CodeEditor } from "../CodeEditor.jsx";
|
||||||
import { LogViewer } from "../LogViewer.jsx";
|
import { LogViewer } from "../LogViewer.jsx";
|
||||||
import { ScriptMetaPanel } from "../ScriptMetaPanel.jsx";
|
|
||||||
import { StatusBadge } from "../../lib/format.jsx";
|
import { StatusBadge } from "../../lib/format.jsx";
|
||||||
import { contextFromMeta, prettyJson } from "../../lib/script.js";
|
import { contextFromMeta, prettyJson } from "../../lib/script.js";
|
||||||
import { ConfigFields } from "./ConfigFields.jsx";
|
import { ConfigFields } from "./ConfigFields.jsx";
|
||||||
import { FieldLabel } from "./FieldHelp.jsx";
|
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
|
||||||
|
|
||||||
export function ScriptCard({
|
export function ScriptCard({
|
||||||
step,
|
step,
|
||||||
@@ -35,24 +33,32 @@ export function ScriptCard({
|
|||||||
opacity: isDragging ? 0.6 : 1,
|
opacity: isDragging ? 0.6 : 1,
|
||||||
};
|
};
|
||||||
const [tryOpen, setTryOpen] = useState(false);
|
const [tryOpen, setTryOpen] = useState(false);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
const listed = scriptsByName?.get(step.script);
|
const listed = scriptsByName?.get(step.script);
|
||||||
const meta = listed?.meta ?? null;
|
const meta = listed?.meta ?? null;
|
||||||
const metaError = listed?.metaError ?? null;
|
|
||||||
|
|
||||||
const duplicateId =
|
const duplicateId =
|
||||||
step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId);
|
step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId);
|
||||||
|
const preview = previewConfigValue(step.config, meta?.previewConfigKey);
|
||||||
|
const previewFull = configValueText(step.config, meta?.previewConfigKey);
|
||||||
|
const baseName = step.kind === "set" ? `set:${step.as || "…"}` : step.script || "untitled";
|
||||||
|
const titleFull = previewFull ? `${baseName} (${previewFull})` : baseName;
|
||||||
|
const setConfig =
|
||||||
|
step.kind === "set" ? { as: step.as ?? "", expression: step.expression ?? "" } : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className="card bg-base-100 border border-primary"
|
className={`card bg-base-100 border border-primary ${
|
||||||
|
isDragging ? "z-40" : "z-0 hover:z-30 focus-within:z-30"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="card-body gap-3 p-4">
|
<div className={`card-body gap-3 ${expanded ? "p-4" : "p-3"}`}>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-xs btn-square mt-0.5 cursor-grab active:cursor-grabbing"
|
className="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||||
aria-label={`Drag step ${index + 1}`}
|
aria-label={`Drag step ${index + 1}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
@@ -60,21 +66,42 @@ export function ScriptCard({
|
|||||||
>
|
>
|
||||||
<LuGripVertical className="size-4 opacity-60" />
|
<LuGripVertical className="size-4 opacity-60" />
|
||||||
</button>
|
</button>
|
||||||
<div className="min-w-0 flex-1">
|
<button
|
||||||
<h3 className="card-title text-base font-mono">
|
type="button"
|
||||||
{step.kind === "set" ? `set:${step.as || "…"}` : step.script || "untitled"}
|
className="min-w-0 flex-1 text-left"
|
||||||
</h3>
|
onClick={() => setExpanded((v) => !v)}
|
||||||
{step.id ? <span className="badge badge-ghost badge-sm font-mono">{step.id}</span> : null}
|
>
|
||||||
{step.kind === "script" && meta?.description ? (
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<p className="text-sm opacity-70 mt-1">{meta.description}</p>
|
<h3 className="card-title min-w-0 flex-1 text-base font-mono" title={titleFull}>
|
||||||
|
<span className="min-w-0 truncate">{baseName}</span>
|
||||||
|
{!expanded && preview ? (
|
||||||
|
<span
|
||||||
|
className="badge badge-secondary badge-sm max-w-[min(16rem,45%)] shrink-0 truncate font-mono font-normal"
|
||||||
|
title={previewFull}
|
||||||
|
>
|
||||||
|
{preview}
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{step.kind === "set" ? (
|
</h3>
|
||||||
<p className="text-sm opacity-70 mt-1">Assign a JSONata result onto the context</p>
|
{step.id ? (
|
||||||
|
<span className="badge badge-ghost badge-sm font-mono shrink-0">{step.id}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{expanded && step.kind === "script" && meta?.description ? (
|
||||||
|
<p className="text-sm opacity-70 mt-1">{meta.description}</p>
|
||||||
|
) : null}
|
||||||
|
{expanded && step.kind === "set" ? (
|
||||||
|
<p className="text-sm opacity-70 mt-1">Assign a JSONata result onto the context</p>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
{step.kind === "script" ? (
|
||||||
|
<SchemaTooltip label="Input" fields={meta?.input} />
|
||||||
|
) : null}
|
||||||
|
{!expanded ? (
|
||||||
|
<ConfigTooltip config={step.kind === "set" ? setConfig : (step.config ?? {})} />
|
||||||
|
) : null}
|
||||||
<div className="card-actions shrink-0">
|
<div className="card-actions shrink-0">
|
||||||
{step.kind === "script" && step.script ? (
|
{step.kind === "script" && step.script ? (
|
||||||
<>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-xs"
|
className="btn btn-ghost btn-xs"
|
||||||
@@ -85,14 +112,6 @@ export function ScriptCard({
|
|||||||
<LuPlay className="size-4" />
|
<LuPlay className="size-4" />
|
||||||
Try
|
Try
|
||||||
</button>
|
</button>
|
||||||
<Link
|
|
||||||
to={`/scripts/${encodeURIComponent(step.script)}/edit`}
|
|
||||||
className="btn btn-ghost btn-xs"
|
|
||||||
title="Edit script"
|
|
||||||
>
|
|
||||||
<LuPencil className="size-4" />
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -103,20 +122,20 @@ export function ScriptCard({
|
|||||||
>
|
>
|
||||||
<LuTrash2 className="size-4" />
|
<LuTrash2 className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs btn-square"
|
||||||
|
title={expanded ? "Collapse" : "Expand"}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
<LuChevronDown className={`size-4 opacity-70 transition-transform ${expanded ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{step.kind === "script" && (meta?.input || meta?.output) ? (
|
{expanded ? (
|
||||||
<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" ? (
|
{step.kind === "set" ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FieldLabel name="as" required description="Context field to write (not data or config)" />
|
<FieldLabel name="as" required description="Context field to write (not data or config)" />
|
||||||
@@ -186,6 +205,8 @@ export function ScriptCard({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{tryOpen ? (
|
{tryOpen ? (
|
||||||
<ScriptTryDialog
|
<ScriptTryDialog
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { LuCopy, LuGripVertical, LuTrash2 } from "react-icons/lu";
|
import { LuChevronDown, LuCopy, LuGripVertical, LuTrash2 } from "react-icons/lu";
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import cronstrue from "cronstrue";
|
import cronstrue from "cronstrue";
|
||||||
@@ -26,14 +26,21 @@ export function TriggerCard({
|
|||||||
opacity: isDragging ? 0.6 : 1,
|
opacity: isDragging ? 0.6 : 1,
|
||||||
};
|
};
|
||||||
const type = trigger.type;
|
const type = trigger.type;
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article ref={setNodeRef} style={style} className="card bg-base-100 border border-primary">
|
<article
|
||||||
<div className="card-body gap-3 p-4">
|
ref={setNodeRef}
|
||||||
<div className="flex items-start gap-2">
|
style={style}
|
||||||
|
className={`card bg-base-100 border border-primary ${
|
||||||
|
isDragging ? "z-40" : "z-0 hover:z-30 focus-within:z-30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`card-body gap-3 ${expanded ? "p-4" : "p-3"}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-xs btn-square mt-0.5 cursor-grab active:cursor-grabbing"
|
className="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||||
aria-label={`Drag trigger ${index + 1}`}
|
aria-label={`Drag trigger ${index + 1}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
@@ -41,9 +48,14 @@ export function TriggerCard({
|
|||||||
>
|
>
|
||||||
<LuGripVertical className="size-4 opacity-60" />
|
<LuGripVertical className="size-4 opacity-60" />
|
||||||
</button>
|
</button>
|
||||||
<div className="min-w-0 flex-1">
|
<button
|
||||||
<h3 className="card-title text-base">{typeLabel(type)}</h3>
|
type="button"
|
||||||
</div>
|
className="min-w-0 flex-1 text-left"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
<h3 className="card-title text-base inline">{typeLabel(type)}</h3>
|
||||||
|
<span className="ml-2 font-mono text-xs opacity-70">{triggerSummary(trigger, owner)}</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-xs text-error"
|
className="btn btn-ghost btn-xs text-error"
|
||||||
@@ -53,7 +65,18 @@ export function TriggerCard({
|
|||||||
>
|
>
|
||||||
<LuTrash2 className="size-4" />
|
<LuTrash2 className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs btn-square"
|
||||||
|
title={expanded ? "Collapse" : "Expand"}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
<LuChevronDown className={`size-4 opacity-70 transition-transform ${expanded ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{expanded ? (
|
||||||
|
<>
|
||||||
{type === "HTTP" ? (
|
{type === "HTTP" ? (
|
||||||
<HttpFields
|
<HttpFields
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
@@ -76,11 +99,23 @@ export function TriggerCard({
|
|||||||
{type !== "HTTP" && type !== "cron" && type !== "workflow" ? (
|
{type !== "HTTP" && type !== "cron" && type !== "workflow" ? (
|
||||||
<p className="text-sm opacity-70">Unknown trigger type. Edit it in the YAML tab.</p>
|
<p className="text-sm opacity-70">Unknown trigger type. Edit it in the YAML tab.</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerSummary(trigger, owner) {
|
||||||
|
const type = trigger?.type;
|
||||||
|
if (type === "HTTP") {
|
||||||
|
return `${trigger.method || "POST"} ${namespacedPath(owner || "owner", trigger.path || "/")}`;
|
||||||
|
}
|
||||||
|
if (type === "cron") return trigger.schedule || "";
|
||||||
|
if (type === "workflow") return "callable";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function typeLabel(type) {
|
function typeLabel(type) {
|
||||||
if (type === "HTTP") return "HTTP";
|
if (type === "HTTP") return "HTTP";
|
||||||
if (type === "cron") return "Cron";
|
if (type === "cron") return "Cron";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { LuPlay } from "react-icons/lu";
|
import { LuMaximize2, LuMinimize2, LuPlay } from "react-icons/lu";
|
||||||
import { errorMessage } from "../../api/client.js";
|
import { errorMessage } from "../../api/client.js";
|
||||||
import { useRunWorkflow } from "../../api/hooks.js";
|
import { useRunWorkflow } from "../../api/hooks.js";
|
||||||
import { CodeEditor } from "../CodeEditor.jsx";
|
import { CodeEditor } from "../CodeEditor.jsx";
|
||||||
@@ -13,12 +13,14 @@ export function WorkflowTestPanel({
|
|||||||
defaultData,
|
defaultData,
|
||||||
disabled,
|
disabled,
|
||||||
disabledReason,
|
disabledReason,
|
||||||
testKey = 0,
|
open,
|
||||||
|
onClose,
|
||||||
}) {
|
}) {
|
||||||
const run = useRunWorkflow();
|
const run = useRunWorkflow();
|
||||||
const [dataJson, setDataJson] = useState(() => prettyJson(defaultData ?? {}));
|
const [dataJson, setDataJson] = useState(() => prettyJson(defaultData ?? {}));
|
||||||
const [parseError, setParseError] = useState(null);
|
const [parseError, setParseError] = useState(null);
|
||||||
const [last, setLast] = useState(null);
|
const [last, setLast] = useState(null);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDataJson(prettyJson(defaultData ?? {}));
|
setDataJson(prettyJson(defaultData ?? {}));
|
||||||
@@ -61,28 +63,52 @@ export function WorkflowTestPanel({
|
|||||||
? prettyJson(last.error ? { error: last.error } : last.result)
|
? prettyJson(last.error ? { error: last.error } : last.result)
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
setExpanded(false);
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details
|
<dialog className={`modal ${open ? "modal-open" : ""}`}>
|
||||||
key={testKey}
|
<div
|
||||||
className="collapse collapse-arrow border border-base-300 bg-base-100 shrink-0"
|
className={
|
||||||
open={testKey > 0 ? true : undefined}
|
expanded
|
||||||
|
? "modal-box flex h-dvh max-h-dvh w-dvw max-w-none flex-col rounded-none"
|
||||||
|
: "modal-box flex max-w-5xl flex-col"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<summary className="collapse-title min-h-0 py-2 text-sm font-semibold">
|
<div className="flex shrink-0 items-start gap-2">
|
||||||
Test
|
<div className="min-w-0 flex-1">
|
||||||
</summary>
|
<h3 className="font-bold text-lg">Test workflow</h3>
|
||||||
<div className="collapse-content space-y-2">
|
<p className="text-sm opacity-70">
|
||||||
<p className="text-xs opacity-70">
|
Runs the <strong>saved</strong> YAML and writes an Event. Manual run works even when
|
||||||
Runs the <strong>saved</strong> YAML and writes an Event. Manual run works even when the
|
the workflow is disabled. Save before testing unsaved edits.
|
||||||
workflow is disabled. Save before testing unsaved edits.
|
|
||||||
</p>
|
</p>
|
||||||
{disabledReason ? <p className="text-warning text-xs">{disabledReason}</p> : null}
|
</div>
|
||||||
{parseError ? <p className="text-error text-sm">{parseError}</p> : null}
|
<button
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
type="button"
|
||||||
<div className="h-40">
|
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>
|
||||||
|
{disabledReason ? (
|
||||||
|
<p className="text-warning text-xs mt-2 shrink-0">{disabledReason}</p>
|
||||||
|
) : null}
|
||||||
|
{parseError ? <p className="text-error text-sm mt-2 shrink-0">{parseError}</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 h-full" : "min-h-64 h-64"}>
|
||||||
<p className="text-xs opacity-60 mb-1">data</p>
|
<p className="text-xs opacity-60 mb-1">data</p>
|
||||||
|
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-[calc(100%-1.25rem)]"}>
|
||||||
<CodeEditor language="json" value={dataJson} onChange={setDataJson} height="100%" />
|
<CodeEditor language="json" value={dataJson} onChange={setDataJson} height="100%" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-40">
|
</div>
|
||||||
|
<div className={expanded ? "min-h-0 h-full" : "min-h-64 h-64"}>
|
||||||
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
|
<p className="text-xs opacity-60 mb-1 flex items-center gap-2">
|
||||||
result
|
result
|
||||||
{last?.status ? <StatusBadge status={last.status} /> : null}
|
{last?.status ? <StatusBadge status={last.status} /> : null}
|
||||||
@@ -92,13 +118,24 @@ export function WorkflowTestPanel({
|
|||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
<CodeEditor language="json" value={resultJson} onChange={() => {}} readOnly height="100%" />
|
<div className={expanded ? "h-[calc(100%-1.25rem)]" : "h-[calc(100%-1.25rem)]"}>
|
||||||
|
<CodeEditor
|
||||||
|
language="json"
|
||||||
|
value={resultJson}
|
||||||
|
onChange={() => {}}
|
||||||
|
readOnly
|
||||||
|
height="100%"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
</div>
|
||||||
|
<div className="modal-action shrink-0">
|
||||||
|
<button type="button" className="btn" onClick={close}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary"
|
||||||
disabled={disabled || run.isPending}
|
disabled={disabled || run.isPending}
|
||||||
onClick={onRun}
|
onClick={onRun}
|
||||||
>
|
>
|
||||||
@@ -111,6 +148,11 @@ export function WorkflowTestPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
<form method="dialog" className="modal-backdrop">
|
||||||
|
<button type="button" onClick={close}>
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ export function WorkflowVisualEditor({
|
|||||||
extraChrome,
|
extraChrome,
|
||||||
showTest,
|
showTest,
|
||||||
savedYaml,
|
savedYaml,
|
||||||
testKey,
|
testOpen,
|
||||||
|
onTestClose,
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState("scripts");
|
const [tab, setTab] = useState("scripts");
|
||||||
const [lastEdited, setLastEdited] = useState("yaml");
|
const [lastEdited, setLastEdited] = useState("yaml");
|
||||||
@@ -185,7 +186,8 @@ export function WorkflowVisualEditor({
|
|||||||
defaultData={defaultData}
|
defaultData={defaultData}
|
||||||
disabled={Boolean(testDisabledReason)}
|
disabled={Boolean(testDisabledReason)}
|
||||||
disabledReason={testDisabledReason}
|
disabledReason={testDisabledReason}
|
||||||
testKey={testKey}
|
open={testOpen}
|
||||||
|
onClose={onTestClose}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const NEW_SCRIPT_TEMPLATE = `async function main(ctx) {
|
|||||||
|
|
||||||
main.meta = {
|
main.meta = {
|
||||||
description: "",
|
description: "",
|
||||||
|
previewConfigKey: "",
|
||||||
config: {},
|
config: {},
|
||||||
input: {},
|
input: {},
|
||||||
output: {},
|
output: {},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { LuArrowLeft, LuFilter } from "react-icons/lu";
|
import { LuArrowLeft, LuFilter } from "react-icons/lu";
|
||||||
import { useRun } from "../api/hooks.js";
|
import { useRun } from "../api/hooks.js";
|
||||||
import { LogViewer } from "../components/LogViewer.jsx";
|
import { LogViewer } from "../components/LogViewer.jsx";
|
||||||
@@ -22,9 +22,19 @@ function isEditableScript(s) {
|
|||||||
|
|
||||||
export function EventDetailPage() {
|
export function EventDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { data: run, isLoading, error } = useRun(id);
|
const { data: run, isLoading, error } = useRun(id);
|
||||||
const [stepFilters, setStepFilters] = useState([]);
|
const [stepFilters, setStepFilters] = useState([]);
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
const idx = window.history.state?.idx;
|
||||||
|
if (typeof idx === "number" && idx > 0) {
|
||||||
|
navigate(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate("/events");
|
||||||
|
}
|
||||||
|
|
||||||
const steps = run?.steps ?? [];
|
const steps = run?.steps ?? [];
|
||||||
const logs = run?.logs ?? [];
|
const logs = run?.logs ?? [];
|
||||||
const logFilters = useMemo(
|
const logFilters = useMemo(
|
||||||
@@ -53,9 +63,9 @@ export function EventDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link to="/events" className="btn btn-ghost btn-sm btn-square" aria-label="Back">
|
<button type="button" className="btn btn-ghost btn-sm btn-square" aria-label="Back" onClick={goBack}>
|
||||||
<LuArrowLeft className="size-4" />
|
<LuArrowLeft className="size-4" />
|
||||||
</Link>
|
</button>
|
||||||
<h1 className="text-xl font-semibold truncate">{run.workflow_name || run.workflow}</h1>
|
<h1 className="text-xl font-semibold truncate">{run.workflow_name || run.workflow}</h1>
|
||||||
<StatusBadge status={run.status} />
|
<StatusBadge status={run.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export function WorkflowEditPage() {
|
|||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [savedYaml, setSavedYaml] = useState("");
|
const [savedYaml, setSavedYaml] = useState("");
|
||||||
const [contentReady, setContentReady] = useState(false);
|
const [contentReady, setContentReady] = useState(false);
|
||||||
const [testKey, setTestKey] = useState(0);
|
const [testOpen, setTestOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existing.isLoading) return;
|
if (existing.isLoading) return;
|
||||||
@@ -192,17 +192,20 @@ export function WorkflowEditPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const yamlOk = !parseWorkflowYaml(content).parseError;
|
const parsedDoc = parseWorkflowYaml(content);
|
||||||
|
const yamlOk = !parsedDoc.parseError;
|
||||||
|
const workflowName = parsedDoc.doc?.name?.trim();
|
||||||
|
const pageTitle = workflowName ? `${workflowName} (${file})` : file;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkflowEditorLayout
|
<WorkflowEditorLayout
|
||||||
title={`${owner}/${file}`}
|
title={pageTitle}
|
||||||
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}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
onTest={() => setTestKey((k) => k + 1)}
|
onTest={() => setTestOpen(true)}
|
||||||
onToggleEnabled={() =>
|
onToggleEnabled={() =>
|
||||||
setEnabled.mutate({
|
setEnabled.mutate({
|
||||||
owner,
|
owner,
|
||||||
@@ -223,7 +226,8 @@ export function WorkflowEditPage() {
|
|||||||
file={file}
|
file={file}
|
||||||
savedYaml={savedYaml}
|
savedYaml={savedYaml}
|
||||||
showTest
|
showTest
|
||||||
testKey={testKey}
|
testOpen={testOpen}
|
||||||
|
onTestClose={() => setTestOpen(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</WorkflowEditorLayout>
|
</WorkflowEditorLayout>
|
||||||
|
|||||||
Reference in New Issue
Block a user