feat(config-refs): introduce config reference parsing and resolution
- Added a new module for parsing and resolving configuration references, supporting `$SECRET_`, `$KV_`, and `$CONTEXT_` prefixes. - Implemented functions to handle the resolution of these references within workflow contexts, enhancing the flexibility of configuration management. - Updated existing workflows and scripts to utilize the new config reference resolution, improving data handling and context management. - Introduced a smoke test to validate the functionality of the config reference system, ensuring robustness and reliability. This update significantly enhances the ability to manage dynamic configurations within workflows, improving overall usability and maintainability.
This commit is contained in:
@@ -2,10 +2,12 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import { useDuplicateWorkflow, useOwners, useWorkflows } from "../api/hooks.js";
|
||||
import { ensureWorkflowFilename, suggestCopyFilename } from "../lib/workflow-doc.js";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
const EMPTY_WORKFLOWS = [];
|
||||
|
||||
export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplicated }) {
|
||||
const { notify } = useNotifications();
|
||||
const { data: owners = [] } = useOwners();
|
||||
const { data: workflows = EMPTY_WORKFLOWS } = useWorkflows();
|
||||
const duplicate = useDuplicateWorkflow();
|
||||
@@ -40,6 +42,7 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
notify.success(`Duplicated to ${data.owner}/${data.file}`);
|
||||
onDuplicated?.(data);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,6 +6,33 @@ import { FieldLabel } from "./FieldHelp.jsx";
|
||||
|
||||
const MULTILINE_KEYS = new Set(["expression", "jsonata"]);
|
||||
|
||||
const CONFIG_REF_PREFIXES = [
|
||||
{ prefix: "$SECRET_", label: "secret" },
|
||||
{ prefix: "$CONTEXT_", label: "context" },
|
||||
{ prefix: "$KV_", label: "KV" },
|
||||
];
|
||||
|
||||
function describeConfigRef(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
for (const { prefix, label } of CONFIG_REF_PREFIXES) {
|
||||
if (trimmed.startsWith(prefix) && trimmed.length > prefix.length) {
|
||||
return { label, name: trimmed.slice(prefix.length) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ConfigRefHint({ value }) {
|
||||
const ref = describeConfigRef(value);
|
||||
if (!ref) return null;
|
||||
return (
|
||||
<p className="text-xs opacity-60">
|
||||
from {ref.label} <span className="font-mono">{ref.name}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function fieldSpec(meta, key) {
|
||||
const spec = meta?.config?.[key];
|
||||
if (spec && typeof spec === "object") return spec;
|
||||
@@ -261,11 +288,14 @@ function ObjectFields({ value, onChange }) {
|
||||
onChange={(nv) => setVal(k, nv)}
|
||||
/>
|
||||
) : (
|
||||
<EditableText
|
||||
value={v == null ? "" : String(v)}
|
||||
onCommit={(nv) => setVal(k, nv)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<EditableText
|
||||
value={v == null ? "" : String(v)}
|
||||
onCommit={(nv) => setVal(k, nv)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<ConfigRefHint value={v} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
@@ -395,6 +425,7 @@ export function ConfigFields({
|
||||
excludeFile={excludeFile}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ConfigRefHint value={cfg[key]} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -430,6 +461,7 @@ export function ConfigFields({
|
||||
excludeFile={excludeFile}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ConfigRefHint value={cfg[key]} />
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-ghost btn-xs" disabled={disabled} onClick={addExtra}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuMaximize2, LuMinimize2, LuPlay } from "react-icons/lu";
|
||||
import { errorMessage } from "../../api/client.js";
|
||||
@@ -6,25 +6,68 @@ import { useRunWorkflow } from "../../api/hooks.js";
|
||||
import { CodeEditor } from "../CodeEditor.jsx";
|
||||
import { StatusBadge } from "../../lib/format.jsx";
|
||||
import { prettyJson } from "../../lib/script.js";
|
||||
import {
|
||||
overlayWorkflowTestData,
|
||||
readWorkflowTestData,
|
||||
writeWorkflowTestData,
|
||||
} from "../../lib/workflow-test-storage.js";
|
||||
import { SchemaTooltip } from "./FieldHelp.jsx";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 300;
|
||||
|
||||
function initialDataJson(owner, file, defaultData) {
|
||||
return prettyJson(overlayWorkflowTestData(defaultData, readWorkflowTestData(owner, file)));
|
||||
}
|
||||
|
||||
export function WorkflowTestPanel({
|
||||
owner,
|
||||
file,
|
||||
defaultData,
|
||||
inputFields,
|
||||
disabled,
|
||||
disabledReason,
|
||||
open,
|
||||
onClose,
|
||||
}) {
|
||||
const run = useRunWorkflow();
|
||||
const [dataJson, setDataJson] = useState(() => prettyJson(defaultData ?? {}));
|
||||
const [dataJson, setDataJson] = useState(() => initialDataJson(owner, file, defaultData));
|
||||
const [inputTouched, setInputTouched] = useState(() => readWorkflowTestData(owner, file) != null);
|
||||
const [parseError, setParseError] = useState(null);
|
||||
const [last, setLast] = useState(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const saveTimer = useRef(null);
|
||||
|
||||
const seedJson = prettyJson(
|
||||
overlayWorkflowTestData(defaultData, readWorkflowTestData(owner, file)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDataJson(prettyJson(defaultData ?? {}));
|
||||
}, [defaultData]);
|
||||
return () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputTouched) return;
|
||||
setDataJson(seedJson);
|
||||
}, [seedJson, inputTouched]);
|
||||
|
||||
function persist(data) {
|
||||
writeWorkflowTestData(owner, file, data);
|
||||
}
|
||||
|
||||
function onDataChange(value) {
|
||||
setDataJson(value);
|
||||
setInputTouched(true);
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
try {
|
||||
persist(JSON.parse(value || "null"));
|
||||
} catch {
|
||||
// skip invalid JSON
|
||||
}
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function onRun() {
|
||||
setParseError(null);
|
||||
@@ -35,6 +78,7 @@ export function WorkflowTestPanel({
|
||||
setParseError(err instanceof Error ? err.message : String(err));
|
||||
return;
|
||||
}
|
||||
persist(data);
|
||||
run.mutate(
|
||||
{ owner, file, data },
|
||||
{
|
||||
@@ -64,6 +108,15 @@ export function WorkflowTestPanel({
|
||||
: "";
|
||||
|
||||
function close() {
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
try {
|
||||
persist(JSON.parse(dataJson || "null"));
|
||||
} catch {
|
||||
// skip invalid
|
||||
}
|
||||
setExpanded(false);
|
||||
onClose?.();
|
||||
}
|
||||
@@ -103,9 +156,12 @@ export function WorkflowTestPanel({
|
||||
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 flex items-center gap-2">
|
||||
data
|
||||
<SchemaTooltip label="Input" fields={inputFields} />
|
||||
</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={onDataChange} height="100%" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={expanded ? "min-h-0 h-full" : "min-h-64 h-64"}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { useHttpAuths, useHttpPages, useScripts, useWorkflows } from "../../api/hooks.js";
|
||||
import { dataFromInputMeta, firstInputMeta } from "../../lib/script.js";
|
||||
import { workflowToFlowchart } from "../../lib/workflow-mermaid.js";
|
||||
import { parseWorkflowYaml, stringifyWorkflowDoc } from "../../lib/workflow-doc.js";
|
||||
import { ScriptsTab } from "./ScriptsTab.jsx";
|
||||
@@ -80,7 +81,27 @@ export function WorkflowVisualEditor({
|
||||
setTab(next);
|
||||
}
|
||||
|
||||
const defaultData = displayDoc?.extra?.data ?? {};
|
||||
const scriptsByName = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const s of scripts) {
|
||||
const name = typeof s === "string" ? s : s.name;
|
||||
if (name) map.set(name, s);
|
||||
}
|
||||
return map;
|
||||
}, [scripts]);
|
||||
|
||||
const inputMeta = useMemo(
|
||||
() => firstInputMeta(displayDoc?.scripts, scriptsByName),
|
||||
[displayDoc?.scripts, scriptsByName],
|
||||
);
|
||||
|
||||
const defaultData = useMemo(() => {
|
||||
const yamlData = displayDoc?.extra?.data;
|
||||
return dataFromInputMeta(inputMeta, yamlData);
|
||||
}, [displayDoc?.extra?.data, inputMeta]);
|
||||
|
||||
const inputFields = inputMeta?.input;
|
||||
|
||||
let testDisabledReason = null;
|
||||
if (visualDisabled) testDisabledReason = "Fix YAML before running.";
|
||||
else if (unsaved) testDisabledReason = "Save the workflow before running. Test uses the saved YAML.";
|
||||
@@ -181,9 +202,11 @@ export function WorkflowVisualEditor({
|
||||
|
||||
{showTest && owner && file ? (
|
||||
<WorkflowTestPanel
|
||||
key={`${owner}/${file}`}
|
||||
owner={owner}
|
||||
file={file}
|
||||
defaultData={defaultData}
|
||||
inputFields={inputFields}
|
||||
disabled={Boolean(testDisabledReason)}
|
||||
disabledReason={testDisabledReason}
|
||||
open={testOpen}
|
||||
|
||||
@@ -78,8 +78,12 @@ export function prettyJson(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function defaultsFromFields(fields) {
|
||||
if (fields == null || typeof fields !== "object" || Array.isArray(fields)) return {};
|
||||
if (!isPlainObject(fields)) return {};
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
for (const [key, spec] of Object.entries(fields)) {
|
||||
@@ -90,6 +94,57 @@ function defaultsFromFields(fields) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function placeholderForField(spec) {
|
||||
const type = spec?.type;
|
||||
if (type === "object") return {};
|
||||
if (type === "array") return [];
|
||||
if (type === "number" || type === "integer") return 0;
|
||||
if (type === "boolean") return false;
|
||||
if (type === "string") return "";
|
||||
return null;
|
||||
}
|
||||
|
||||
function seedFromInputFields(fields) {
|
||||
if (!isPlainObject(fields)) return {};
|
||||
/** @type {Record<string, unknown>} */
|
||||
const out = {};
|
||||
for (const [key, spec] of Object.entries(fields)) {
|
||||
const field = spec && typeof spec === "object" ? spec : {};
|
||||
if ("default" in field) out[key] = field.default;
|
||||
else if (field.required) out[key] = placeholderForField(field);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function inputHasFields(meta) {
|
||||
return isPlainObject(meta?.input) && Object.keys(meta.input).length > 0;
|
||||
}
|
||||
|
||||
/** First script step with a non-empty `meta.input`; else first script meta. */
|
||||
export function firstInputMeta(steps, scriptsByName) {
|
||||
let fallback = null;
|
||||
for (const step of steps ?? []) {
|
||||
if (step?.kind === "set") continue;
|
||||
const name = typeof step === "string" ? step : step?.script;
|
||||
if (!name) continue;
|
||||
const listed = scriptsByName?.get(name);
|
||||
const meta = listed && typeof listed === "object" ? listed.meta ?? null : null;
|
||||
if (!fallback) fallback = meta;
|
||||
if (inputHasFields(meta)) return meta;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Seed workflow test `data` from required/default fields, example, then YAML `data`. */
|
||||
export function dataFromInputMeta(meta, yamlData) {
|
||||
const fromExample = contextFromMeta(meta).data;
|
||||
return {
|
||||
...seedFromInputFields(meta?.input),
|
||||
...(isPlainObject(fromExample) ? fromExample : {}),
|
||||
...(isPlainObject(yamlData) ? yamlData : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultConfigFromMeta(meta) {
|
||||
if (meta?.example?.config && typeof meta.example.config === "object" && !Array.isArray(meta.example.config)) {
|
||||
return { ...meta.example.config };
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const PREFIX = "scrunner.workflowTestData:";
|
||||
|
||||
export function workflowTestStorageKey(owner, file) {
|
||||
return `${PREFIX}${owner}/${file}`;
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Overlay saved test JSON on the schema/YAML seed. Stored object keys win. */
|
||||
export function overlayWorkflowTestData(seed, stored) {
|
||||
const base = isPlainObject(seed) ? seed : {};
|
||||
if (stored == null) return base;
|
||||
if (isPlainObject(stored)) return { ...base, ...stored };
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function readWorkflowTestData(owner, file) {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(workflowTestStorageKey(owner, file));
|
||||
if (raw == null || raw === "") return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeWorkflowTestData(owner, file, data) {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(workflowTestStorageKey(owner, file), JSON.stringify(data));
|
||||
} catch {
|
||||
// quota / private mode
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider } from "./theme.jsx";
|
||||
import { NotificationProvider } from "./notifications.jsx";
|
||||
import { App } from "./App.jsx";
|
||||
import "./index.css";
|
||||
|
||||
@@ -19,9 +20,11 @@ createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<NotificationProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</NotificationProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { LuX } from "react-icons/lu";
|
||||
|
||||
const NotificationContext = createContext(null);
|
||||
|
||||
const TYPES = new Set(["info", "success", "warning", "error"]);
|
||||
const ALERT_CLASS = {
|
||||
info: "alert-info",
|
||||
success: "alert-success",
|
||||
warning: "alert-warning",
|
||||
error: "alert-error",
|
||||
};
|
||||
|
||||
const DEFAULT_DURATION = 4000;
|
||||
const MAX_ITEMS = 5;
|
||||
|
||||
let seq = 0;
|
||||
function nextId() {
|
||||
seq += 1;
|
||||
return `toast-${seq}`;
|
||||
}
|
||||
|
||||
function isSticky(duration) {
|
||||
return duration === 0 || duration === Infinity;
|
||||
}
|
||||
|
||||
function parseNotify(input) {
|
||||
const raw = typeof input === "string" ? { message: input } : (input ?? {});
|
||||
const type = TYPES.has(raw.type) ? raw.type : "info";
|
||||
const message = String(raw.message ?? "");
|
||||
const duration = raw.duration ?? DEFAULT_DURATION;
|
||||
return { type, message, duration };
|
||||
}
|
||||
|
||||
export function NotificationProvider({ children }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const timers = useRef(new Map());
|
||||
|
||||
const dismiss = useCallback((id) => {
|
||||
const timer = timers.current.get(id);
|
||||
if (timer != null) {
|
||||
clearTimeout(timer);
|
||||
timers.current.delete(id);
|
||||
}
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
}, []);
|
||||
|
||||
const dismissRef = useRef(dismiss);
|
||||
dismissRef.current = dismiss;
|
||||
|
||||
const notify = useMemo(() => {
|
||||
function notifyFn(input) {
|
||||
const parsed = parseNotify(input);
|
||||
if (!parsed.message) return null;
|
||||
|
||||
const id = nextId();
|
||||
const item = { id, ...parsed };
|
||||
|
||||
setItems((prev) => {
|
||||
let kept = prev;
|
||||
if (prev.length >= MAX_ITEMS) {
|
||||
const overflow = prev.slice(0, prev.length - MAX_ITEMS + 1);
|
||||
for (const old of overflow) {
|
||||
const timer = timers.current.get(old.id);
|
||||
if (timer != null) {
|
||||
clearTimeout(timer);
|
||||
timers.current.delete(old.id);
|
||||
}
|
||||
}
|
||||
kept = prev.slice(prev.length - MAX_ITEMS + 1);
|
||||
}
|
||||
return [...kept, item];
|
||||
});
|
||||
|
||||
if (!isSticky(parsed.duration)) {
|
||||
timers.current.set(
|
||||
id,
|
||||
setTimeout(() => dismissRef.current(id), parsed.duration),
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
notifyFn.success = (message, opts) => notifyFn({ type: "success", message, ...opts });
|
||||
notifyFn.error = (message, opts) => notifyFn({ type: "error", message, ...opts });
|
||||
notifyFn.warning = (message, opts) => notifyFn({ type: "warning", message, ...opts });
|
||||
notifyFn.info = (message, opts) => notifyFn({ type: "info", message, ...opts });
|
||||
return notifyFn;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const map = timers.current;
|
||||
return () => {
|
||||
for (const timer of map.values()) clearTimeout(timer);
|
||||
map.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ items, notify, dismiss }), [items, notify, dismiss]);
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
{children}
|
||||
<ToastHost items={items} onDismiss={dismiss} />
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastHost({ items, onDismiss }) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div className="toast toast-end toast-bottom z-[100]">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
role={item.type === "warning" || item.type === "error" ? "alert" : "status"}
|
||||
className={`alert ${ALERT_CLASS[item.type] ?? "alert-info"}`}
|
||||
>
|
||||
<span>{item.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-circle btn-ghost"
|
||||
aria-label="Dismiss"
|
||||
onClick={() => onDismiss(item.id)}
|
||||
>
|
||||
<LuX className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const ctx = useContext(NotificationContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useNotifications must be used within NotificationProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
contextFromMeta,
|
||||
prettyJson,
|
||||
} from "../lib/script.js";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
export function ScriptDryRunPage() {
|
||||
const { notify } = useNotifications();
|
||||
const { name: rawName } = useParams();
|
||||
const name = decodeURIComponent(rawName ?? "");
|
||||
const location = useLocation();
|
||||
@@ -129,7 +131,10 @@ export function ScriptDryRunPage() {
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
save.mutate({ name, content });
|
||||
save.mutate(
|
||||
{ name, content },
|
||||
{ onSuccess: () => notify.success("Script saved") },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -194,9 +199,6 @@ export function ScriptDryRunPage() {
|
||||
{save.isError ? (
|
||||
<p className="text-error text-sm shrink-0">{errorMessage(save.error)}</p>
|
||||
) : null}
|
||||
{save.isSuccess ? (
|
||||
<p className="text-success text-sm shrink-0">Script saved</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 lg:grid-cols-3">
|
||||
<section className="flex min-h-0 flex-col gap-1">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CodeEditor } from "../components/CodeEditor.jsx";
|
||||
import { ScriptIcon } from "../components/ScriptIcon.jsx";
|
||||
import { ScriptMetaPanel } from "../components/ScriptMetaPanel.jsx";
|
||||
import { NEW_SCRIPT_TEMPLATE, normalizeScriptName } from "../lib/script.js";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
export function ScriptNewPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -88,6 +89,7 @@ export function ScriptEditPage() {
|
||||
const { name: rawName } = useParams();
|
||||
const name = decodeURIComponent(rawName ?? "");
|
||||
const navigate = useNavigate();
|
||||
const { notify } = useNotifications();
|
||||
const existing = useScript(name);
|
||||
const save = useSaveScript();
|
||||
const [content, setContent] = useState("");
|
||||
@@ -103,7 +105,10 @@ export function ScriptEditPage() {
|
||||
|
||||
function onSave(e) {
|
||||
e.preventDefault();
|
||||
save.mutate({ name, content });
|
||||
save.mutate(
|
||||
{ name, content },
|
||||
{ onSuccess: () => notify.success("Script saved") },
|
||||
);
|
||||
}
|
||||
|
||||
function openDryRun() {
|
||||
@@ -172,9 +177,6 @@ export function ScriptEditPage() {
|
||||
{save.isError ? (
|
||||
<p className="text-error text-sm shrink-0">{errorMessage(save.error)}</p>
|
||||
) : null}
|
||||
{save.isSuccess ? (
|
||||
<p className="text-success text-sm shrink-0">Script saved</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.j
|
||||
import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx";
|
||||
import { WorkflowVisualEditor } from "../components/workflow/WorkflowVisualEditor.jsx";
|
||||
import { NEW_WORKFLOW_YAML, parseWorkflowYaml } from "../lib/workflow-doc.js";
|
||||
import { useNotifications } from "../notifications.jsx";
|
||||
|
||||
function WorkflowEditorLayout({
|
||||
title,
|
||||
@@ -19,7 +20,6 @@ function WorkflowEditorLayout({
|
||||
savePending,
|
||||
saveDisabled,
|
||||
saveError,
|
||||
saveSuccess,
|
||||
onSave,
|
||||
onTest,
|
||||
onDuplicate,
|
||||
@@ -81,7 +81,6 @@ function WorkflowEditorLayout({
|
||||
{children}
|
||||
{saveError ? <p className="text-error text-sm shrink-0">{saveError}</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>
|
||||
);
|
||||
}
|
||||
@@ -154,6 +153,7 @@ export function WorkflowNewPage() {
|
||||
|
||||
export function WorkflowEditPage() {
|
||||
const navigate = useNavigate();
|
||||
const { notify } = useNotifications();
|
||||
const { owner: rawOwner, file: rawFile } = useParams();
|
||||
const owner = decodeURIComponent(rawOwner ?? "");
|
||||
const file = decodeURIComponent(rawFile ?? "");
|
||||
@@ -190,7 +190,10 @@ export function WorkflowEditPage() {
|
||||
save.mutate(
|
||||
{ owner, file, content },
|
||||
{
|
||||
onSuccess: () => setSavedYaml(content),
|
||||
onSuccess: () => {
|
||||
setSavedYaml(content);
|
||||
notify.success("Workflow saved");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -227,7 +230,6 @@ export function WorkflowEditPage() {
|
||||
savePending={save.isPending}
|
||||
saveDisabled={!contentReady}
|
||||
saveError={save.isError ? errorMessage(save.error) : null}
|
||||
saveSuccess={save.isSuccess}
|
||||
onSave={onSave}
|
||||
onTest={() => setTestOpen(true)}
|
||||
onDuplicate={() => setDuplicateOpen(true)}
|
||||
|
||||
Reference in New Issue
Block a user