feat(profiles): add typed live step config profiles
Profiles store script + default config per owner. Workflow steps reference them with profile:, overlay keys win, and the UI marks overrides. Profile name is immutable after create so YAML refs stay stable. Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
LuGitBranch,
|
||||
LuHouse,
|
||||
LuKey,
|
||||
LuLayers,
|
||||
LuLogOut,
|
||||
LuMenu,
|
||||
LuMoon,
|
||||
@@ -31,6 +32,7 @@ const navSections = [
|
||||
items: [
|
||||
{ to: "/workflows", label: "Workflows", icon: LuGitBranch },
|
||||
{ to: "/scripts", label: "Scripts", icon: LuCode },
|
||||
{ to: "/profiles", label: "Profiles", icon: LuLayers },
|
||||
{ to: "/events", label: "Events", icon: LuActivity },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useState } from "react";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import { useOwners, useScripts, useUpsertProfile } from "../api/hooks.js";
|
||||
import { ConfigFields } from "./workflow/ConfigFields.jsx";
|
||||
|
||||
/**
|
||||
* @param {"add" | "edit"} mode
|
||||
* @param {{
|
||||
* owner: string,
|
||||
* name?: string,
|
||||
* script?: string,
|
||||
* config?: Record<string, unknown>,
|
||||
* description?: string,
|
||||
* }} initial
|
||||
* @param {number} [usageCount]
|
||||
*/
|
||||
export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount = 0 }) {
|
||||
const { data: owners = [] } = useOwners();
|
||||
const { data: scripts = [] } = useScripts();
|
||||
const upsert = useUpsertProfile();
|
||||
const [form, setForm] = useState(() => ({
|
||||
owner: initial.owner || owners[0] || "default",
|
||||
name: initial.name || "",
|
||||
script: initial.script || "",
|
||||
config:
|
||||
initial.config && typeof initial.config === "object" && !Array.isArray(initial.config)
|
||||
? { ...initial.config }
|
||||
: {},
|
||||
description: initial.description || "",
|
||||
}));
|
||||
const [formError, setFormError] = useState(null);
|
||||
const [confirmScript, setConfirmScript] = useState(false);
|
||||
|
||||
const scriptChanged = mode === "edit" && form.script !== (initial.script || "");
|
||||
const listed = scripts.find((s) => (typeof s === "string" ? s : s.name) === form.script);
|
||||
const meta = listed && typeof listed === "object" ? listed.meta : null;
|
||||
|
||||
function submit() {
|
||||
setFormError(null);
|
||||
if (!form.name.trim()) {
|
||||
setFormError("name is required");
|
||||
return;
|
||||
}
|
||||
if (!form.script.trim()) {
|
||||
setFormError("script is required");
|
||||
return;
|
||||
}
|
||||
upsert.mutate(
|
||||
{
|
||||
owner: form.owner,
|
||||
name: form.name,
|
||||
script: form.script,
|
||||
config: form.config,
|
||||
description: form.description,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
onSaved?.(data?.profile ?? data);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (scriptChanged && usageCount > 0 && !confirmScript) {
|
||||
setConfirmScript(true);
|
||||
return;
|
||||
}
|
||||
submit();
|
||||
}
|
||||
|
||||
const title = mode === "add" ? "New profile" : `Edit ${form.owner}/${form.name}`;
|
||||
|
||||
return (
|
||||
<dialog className="modal modal-open">
|
||||
<div className="modal-box max-w-lg">
|
||||
<h3 className="font-bold">{title}</h3>
|
||||
<form className="mt-3 space-y-2" onSubmit={onSubmit}>
|
||||
{mode === "add" ? (
|
||||
<>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Owner</span>
|
||||
{owners.length > 0 ? (
|
||||
<select
|
||||
className="select w-full"
|
||||
value={form.owner}
|
||||
onChange={(e) => setForm({ ...form, owner: e.target.value })}
|
||||
required
|
||||
>
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="input w-full"
|
||||
value={form.owner}
|
||||
onChange={(e) => setForm({ ...form, owner: e.target.value })}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Name</span>
|
||||
<input
|
||||
className="input w-full font-mono"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
required
|
||||
pattern="[A-Za-z0-9._-]+"
|
||||
title="Letters, numbers, dots, underscores, hyphens. Cannot be changed later."
|
||||
/>
|
||||
<span className="label-text-alt opacity-60">
|
||||
YAML id. Locked after create so live workflow refs stay valid.
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm opacity-70">
|
||||
<span className="font-mono">{form.owner}/{form.name}</span>
|
||||
<span className="ml-2 opacity-60">(name cannot be changed)</span>
|
||||
</p>
|
||||
)}
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Script</span>
|
||||
<select
|
||||
className="select w-full font-mono"
|
||||
value={form.script}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, script: e.target.value });
|
||||
setConfirmScript(false);
|
||||
}}
|
||||
required
|
||||
>
|
||||
<option value="">Select script</option>
|
||||
{scripts.map((s) => {
|
||||
const name = typeof s === "string" ? s : s.name;
|
||||
return (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
{form.script && !scripts.some((s) => (typeof s === "string" ? s : s.name) === form.script) ? (
|
||||
<option value={form.script}>{form.script}</option>
|
||||
) : null}
|
||||
</select>
|
||||
</label>
|
||||
{scriptChanged && usageCount > 0 ? (
|
||||
<p className="text-warning text-sm">
|
||||
Changing the script updates {usageCount} workflow
|
||||
{usageCount === 1 ? "" : "s"} that use this profile
|
||||
{confirmScript ? ". Save to apply." : "."}
|
||||
</p>
|
||||
) : null}
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Description</span>
|
||||
<input
|
||||
className="input w-full"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
maxLength={500}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<p className="text-sm mb-1">Config (defaults for every step that uses this profile)</p>
|
||||
<ConfigFields
|
||||
script={form.script}
|
||||
config={form.config}
|
||||
meta={meta}
|
||||
owner={form.owner}
|
||||
onChange={(config) => setForm({ ...form, config })}
|
||||
/>
|
||||
</div>
|
||||
{formError ? <p className="text-error text-sm">{formError}</p> : null}
|
||||
{upsert.isError ? (
|
||||
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
|
||||
) : null}
|
||||
<div className="modal-action">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={upsert.isPending}>
|
||||
{upsert.isPending ? <span className="loading loading-spinner loading-xs" /> : null}
|
||||
{scriptChanged && usageCount > 0 && !confirmScript ? "Confirm script change" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button type="button" onClick={onClose}>
|
||||
close
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useScripts } from "../../api/hooks.js";
|
||||
import { useProfiles, useScripts } from "../../api/hooks.js";
|
||||
import { ScriptIcon } from "../ScriptIcon.jsx";
|
||||
import { scriptTags } from "../../lib/script.js";
|
||||
|
||||
export function AddScriptDialog({ open, onClose, onPick }) {
|
||||
export function AddScriptDialog({ open, onClose, onPick, owner }) {
|
||||
const { data: scripts = [], isLoading } = useScripts();
|
||||
const { data: profiles = [], isLoading: profilesLoading } = useProfiles(
|
||||
owner || undefined,
|
||||
{ enabled: open && Boolean(owner) },
|
||||
);
|
||||
const [q, setQ] = useState("");
|
||||
const [tab, setTab] = useState("scripts");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const filteredScripts = useMemo(() => {
|
||||
const term = q.trim().toLowerCase();
|
||||
const list = scripts.map((s) => ({
|
||||
name: typeof s === "string" ? s : s.name,
|
||||
@@ -26,60 +31,128 @@ export function AddScriptDialog({ open, onClose, onPick }) {
|
||||
);
|
||||
}, [scripts, q]);
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
const term = q.trim().toLowerCase();
|
||||
if (!term) return profiles;
|
||||
return profiles.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(term) ||
|
||||
String(p.script ?? "").toLowerCase().includes(term) ||
|
||||
String(p.description ?? "").toLowerCase().includes(term),
|
||||
);
|
||||
}, [profiles, 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>
|
||||
<h3 className="font-bold text-lg">Add step</h3>
|
||||
<div role="tablist" className="tabs tabs-box mt-3 w-full">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
className={`tab ${tab === "scripts" ? "tab-active" : ""}`}
|
||||
onClick={() => setTab("scripts")}
|
||||
>
|
||||
Scripts
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
className={`tab ${tab === "profiles" ? "tab-active" : ""}`}
|
||||
onClick={() => setTab("profiles")}
|
||||
>
|
||||
Profiles
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="input input-sm w-full mt-3"
|
||||
placeholder="Search scripts"
|
||||
placeholder={tab === "profiles" ? "Search profiles" : "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">
|
||||
JSONata over ctx; result becomes the next step’s data
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{isLoading ? (
|
||||
<li className="p-4">
|
||||
<span className="loading loading-spinner loading-sm" />
|
||||
{tab === "scripts" ? (
|
||||
<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">
|
||||
JSONata over ctx; result becomes the next step’s data
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
) : (
|
||||
filtered.map((s) => (
|
||||
<li key={s.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPick({ kind: "script", name: s.name, meta: s.meta });
|
||||
setQ("");
|
||||
}}
|
||||
>
|
||||
<ScriptIcon name={s.name} hasIcon={s.hasIcon} className="size-8 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="font-mono">{s.name}</span>
|
||||
<span className="text-xs opacity-70 line-clamp-2 block">
|
||||
{s.metaError ? s.metaError : s.description || "No description"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{isLoading ? (
|
||||
<li className="p-4">
|
||||
<span className="loading loading-spinner loading-sm" />
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
filteredScripts.map((s) => (
|
||||
<li key={s.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPick({ kind: "script", name: s.name, meta: s.meta });
|
||||
setQ("");
|
||||
}}
|
||||
>
|
||||
<ScriptIcon name={s.name} hasIcon={s.hasIcon} className="size-8 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="font-mono">{s.name}</span>
|
||||
<span className="text-xs opacity-70 line-clamp-2 block">
|
||||
{s.metaError ? s.metaError : s.description || "No description"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="menu mt-3 max-h-80 overflow-auto rounded-box border border-accent/40 p-0">
|
||||
{!owner ? (
|
||||
<li className="p-4 text-sm opacity-60">Save the workflow with an owner first.</li>
|
||||
) : profilesLoading ? (
|
||||
<li className="p-4">
|
||||
<span className="loading loading-spinner loading-sm" />
|
||||
</li>
|
||||
) : filteredProfiles.length === 0 ? (
|
||||
<li className="p-4 text-sm opacity-60">No profiles for this owner.</li>
|
||||
) : (
|
||||
filteredProfiles.map((p) => (
|
||||
<li key={p.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPick({
|
||||
kind: "profile",
|
||||
name: p.name,
|
||||
script: p.script,
|
||||
});
|
||||
setQ("");
|
||||
}}
|
||||
>
|
||||
<ScriptIcon name={p.script} className="size-8 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="font-mono">{p.name}</span>
|
||||
<span className="text-xs opacity-70 line-clamp-2 block">
|
||||
{p.script}
|
||||
{p.description ? ` · ${p.description}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
<div className="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -487,15 +487,23 @@ export function ConfigFields({
|
||||
owner,
|
||||
excludeFile,
|
||||
disabled,
|
||||
inheritedConfig = null,
|
||||
}) {
|
||||
const cfg = config && typeof config === "object" && !Array.isArray(config) ? config : {};
|
||||
const inherited =
|
||||
inheritedConfig && typeof inheritedConfig === "object" && !Array.isArray(inheritedConfig)
|
||||
? inheritedConfig
|
||||
: null;
|
||||
const metaKeys = Object.keys(meta?.config ?? {});
|
||||
const extraKeys = Object.keys(cfg).filter((k) => !metaKeys.includes(k));
|
||||
const inheritedExtraKeys = inherited
|
||||
? Object.keys(inherited).filter((k) => !metaKeys.includes(k) && !(k in cfg))
|
||||
: [];
|
||||
|
||||
function setField(key, value) {
|
||||
const spec = fieldSpec(meta, key);
|
||||
const next = { ...cfg };
|
||||
const required = Boolean(spec.required);
|
||||
const required = Boolean(spec.required) && !inherited;
|
||||
if (value === undefined || (value === "" && !required)) {
|
||||
delete next[key];
|
||||
} else {
|
||||
@@ -504,6 +512,12 @@ export function ConfigFields({
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function resetField(key) {
|
||||
const next = { ...cfg };
|
||||
delete next[key];
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function renameExtra(oldKey, nextKey) {
|
||||
if (!nextKey || nextKey === oldKey) return;
|
||||
const next = {};
|
||||
@@ -516,35 +530,70 @@ export function ConfigFields({
|
||||
function addExtra() {
|
||||
let key = "key";
|
||||
let i = 1;
|
||||
while (key in cfg) {
|
||||
while (key in cfg || (inherited && key in inherited)) {
|
||||
key = `key${i}`;
|
||||
i += 1;
|
||||
}
|
||||
onChange({ ...cfg, [key]: "" });
|
||||
}
|
||||
|
||||
function renderField(key, { extra = false, inheritedOnly = false } = {}) {
|
||||
const spec = extra ? { type: "string" } : fieldSpec(meta, key);
|
||||
const overridden = Boolean(inherited && Object.prototype.hasOwnProperty.call(cfg, key));
|
||||
const displayValue = inheritedOnly
|
||||
? inherited?.[key]
|
||||
: overridden || !inherited
|
||||
? cfg[key]
|
||||
: inherited[key];
|
||||
const unknown =
|
||||
extra && metaKeys.length > 0 && !Object.prototype.hasOwnProperty.call(meta?.config ?? {}, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`space-y-1 ${
|
||||
overridden ? "rounded-box border border-warning/50 bg-warning/5 p-2" : ""
|
||||
}`}
|
||||
>
|
||||
<FieldLabel name={key} required={Boolean(spec.required) && !inherited} description={spec.description}>
|
||||
{overridden ? (
|
||||
<span className="badge badge-warning badge-xs">overridden</span>
|
||||
) : inherited && !inheritedOnly ? (
|
||||
<span className="badge badge-ghost badge-xs">from profile</span>
|
||||
) : null}
|
||||
{unknown ? (
|
||||
<span className="badge badge-error badge-xs">not in schema</span>
|
||||
) : null}
|
||||
{overridden ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
disabled={disabled}
|
||||
onClick={() => resetField(key)}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
) : null}
|
||||
</FieldLabel>
|
||||
<ValueEditor
|
||||
value={displayValue}
|
||||
onChange={(v) => setField(key, v)}
|
||||
spec={extra ? { type: displayValue != null && typeof displayValue === "object" ? "object" : "string" } : spec}
|
||||
script={script}
|
||||
fieldKey={key}
|
||||
workflows={workflows}
|
||||
owner={owner}
|
||||
excludeFile={excludeFile}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ConfigRefHint value={displayValue} owner={owner} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
<ConfigRefHint value={cfg[key]} owner={owner} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{metaKeys.map((key) => renderField(key))}
|
||||
{extraKeys.map((key) => (
|
||||
<div key={key} className="space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -552,16 +601,18 @@ export function ConfigFields({
|
||||
value={key}
|
||||
onCommit={(nk) => renameExtra(key, nk)}
|
||||
className="font-mono text-sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
{inherited ? <span className="badge badge-warning badge-xs">overridden</span> : null}
|
||||
{metaKeys.length > 0 ? (
|
||||
<span className="badge badge-error badge-xs">not in schema</span>
|
||||
) : null}
|
||||
<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);
|
||||
}}
|
||||
disabled={disabled}
|
||||
onClick={() => resetField(key)}
|
||||
>
|
||||
<LuTrash2 className="size-3.5" />
|
||||
</button>
|
||||
@@ -580,6 +631,7 @@ export function ConfigFields({
|
||||
<ConfigRefHint value={cfg[key]} owner={owner} />
|
||||
</div>
|
||||
))}
|
||||
{inheritedExtraKeys.map((key) => renderField(key, { extra: true, inheritedOnly: true }))}
|
||||
<button type="button" className="btn btn-ghost btn-xs" disabled={disabled} onClick={addExtra}>
|
||||
<LuPlus className="size-3.5" />
|
||||
Add field
|
||||
|
||||
@@ -11,12 +11,14 @@ import { contextFromMeta, prettyJson } from "../../lib/script.js";
|
||||
import { ConfigFields } from "./ConfigFields.jsx";
|
||||
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
|
||||
import { ScriptIcon } from "../ScriptIcon.jsx";
|
||||
import { configHasOverlay, mergeProfileConfig } from "../../lib/profile.js";
|
||||
|
||||
export function ScriptCard({
|
||||
step,
|
||||
index,
|
||||
otherSteps,
|
||||
scriptsByName,
|
||||
profilesByName,
|
||||
onChange,
|
||||
onRemove,
|
||||
disabled,
|
||||
@@ -35,25 +37,33 @@ export function ScriptCard({
|
||||
};
|
||||
const [tryOpen, setTryOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const listed = scriptsByName?.get(step.script);
|
||||
const profile = step.profile && profilesByName?.get(step.profile);
|
||||
const scriptName = profile?.script || step.script;
|
||||
const listed = scriptsByName?.get(scriptName);
|
||||
const meta = listed?.meta ?? null;
|
||||
const inheritedConfig = profile?.config ?? null;
|
||||
const mergedConfig = profile
|
||||
? mergeProfileConfig(profile.config, step.config)
|
||||
: step.config;
|
||||
const overridden = Boolean(step.profile && configHasOverlay(step.config));
|
||||
|
||||
const duplicateId =
|
||||
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.script || "untitled";
|
||||
const preview = previewConfigValue(mergedConfig, meta?.previewConfigKey);
|
||||
const previewFull = configValueText(mergedConfig, meta?.previewConfigKey);
|
||||
const baseName = step.kind === "set" ? "set" : scriptName || "untitled";
|
||||
const titleFull = previewFull ? `${baseName} (${previewFull})` : baseName;
|
||||
const setConfig =
|
||||
step.kind === "set" ? { expression: step.expression ?? "" } : null;
|
||||
const missingProfile = Boolean(step.profile) && !profile;
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`card bg-base-100 border border-primary ${
|
||||
isDragging ? "z-40" : "z-0 hover:z-30 focus-within:z-30"
|
||||
}`}
|
||||
className={`card bg-base-100 border ${
|
||||
step.profile ? "border-accent" : "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">
|
||||
@@ -67,8 +77,8 @@ export function ScriptCard({
|
||||
>
|
||||
<LuGripVertical className="size-4 opacity-60" />
|
||||
</button>
|
||||
{step.kind === "script" && step.script ? (
|
||||
<ScriptIcon name={step.script} hasIcon={listed?.hasIcon} className="size-8 shrink-0" />
|
||||
{step.kind === "script" && scriptName ? (
|
||||
<ScriptIcon name={scriptName} hasIcon={listed?.hasIcon} className="size-8 shrink-0" />
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
@@ -87,10 +97,29 @@ export function ScriptCard({
|
||||
</span>
|
||||
) : null}
|
||||
</h3>
|
||||
{step.profile ? (
|
||||
<span
|
||||
className={`badge badge-sm shrink-0 font-mono font-normal ${
|
||||
overridden ? "badge-warning" : "badge-accent"
|
||||
}`}
|
||||
title={
|
||||
overridden
|
||||
? `profile ${step.profile} + overridden`
|
||||
: `profile ${step.profile}`
|
||||
}
|
||||
>
|
||||
{overridden ? `profile ${step.profile} + overridden` : `profile ${step.profile}`}
|
||||
</span>
|
||||
) : null}
|
||||
{step.id ? (
|
||||
<span className="badge badge-ghost badge-sm font-mono shrink-0">{step.id}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{expanded && step.kind === "script" && missingProfile ? (
|
||||
<p className="text-sm text-error mt-1">
|
||||
Profile {step.profile} not found for owner {owner || "?"}
|
||||
</p>
|
||||
) : null}
|
||||
{expanded && step.kind === "script" && meta?.description ? (
|
||||
<p className="text-sm opacity-70 mt-1">{meta.description}</p>
|
||||
) : null}
|
||||
@@ -104,10 +133,10 @@ export function ScriptCard({
|
||||
<SchemaTooltip label="Input" fields={meta?.input} />
|
||||
) : null}
|
||||
{!expanded ? (
|
||||
<ConfigTooltip config={step.kind === "set" ? setConfig : (step.config ?? {})} />
|
||||
<ConfigTooltip config={step.kind === "set" ? setConfig : (mergedConfig ?? {})} />
|
||||
) : null}
|
||||
<div className="card-actions shrink-0">
|
||||
{step.kind === "script" && step.script ? (
|
||||
{step.kind === "script" && scriptName ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
@@ -158,8 +187,9 @@ export function ScriptCard({
|
||||
</div>
|
||||
) : (
|
||||
<ConfigFields
|
||||
script={step.script}
|
||||
script={scriptName}
|
||||
config={step.config}
|
||||
inheritedConfig={inheritedConfig}
|
||||
meta={meta}
|
||||
disabled={disabled}
|
||||
workflows={workflows}
|
||||
@@ -211,8 +241,8 @@ export function ScriptCard({
|
||||
</div>
|
||||
{tryOpen ? (
|
||||
<ScriptTryDialog
|
||||
script={step.script}
|
||||
config={step.config}
|
||||
script={scriptName}
|
||||
config={mergedConfig}
|
||||
meta={meta}
|
||||
owner={owner}
|
||||
onClose={() => setTryOpen(false)}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} 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 { newProfileStep, newScriptStep, newSetStep } from "../../lib/workflow-doc.js";
|
||||
import { AddScriptDialog } from "./AddScriptDialog.jsx";
|
||||
import { ScriptCard } from "./ScriptCard.jsx";
|
||||
|
||||
@@ -24,6 +24,7 @@ export function ScriptsTab({
|
||||
onPatch,
|
||||
disabled,
|
||||
scripts = [],
|
||||
profiles = [],
|
||||
workflows = [],
|
||||
owner,
|
||||
excludeFile,
|
||||
@@ -42,6 +43,10 @@ export function ScriptsTab({
|
||||
const name = typeof s === "string" ? s : s.name;
|
||||
scriptsByName.set(name, s);
|
||||
}
|
||||
const profilesByName = new Map();
|
||||
for (const p of profiles) {
|
||||
if (p?.name) profilesByName.set(p.name, p);
|
||||
}
|
||||
|
||||
function patchSteps(next) {
|
||||
onPatch((d) => {
|
||||
@@ -72,7 +77,7 @@ export function ScriptsTab({
|
||||
onClick={() => setAddOpen(true)}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add script
|
||||
Add step
|
||||
</button>
|
||||
</div>
|
||||
{steps.length === 0 ? (
|
||||
@@ -88,6 +93,7 @@ export function ScriptsTab({
|
||||
index={index}
|
||||
otherSteps={steps}
|
||||
scriptsByName={scriptsByName}
|
||||
profilesByName={profilesByName}
|
||||
disabled={disabled}
|
||||
workflows={workflows}
|
||||
owner={owner}
|
||||
@@ -106,10 +112,13 @@ export function ScriptsTab({
|
||||
)}
|
||||
<AddScriptDialog
|
||||
open={addOpen}
|
||||
owner={owner}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onPick={(picked) => {
|
||||
if (picked.kind === "set") {
|
||||
patchSteps([...steps, newSetStep()]);
|
||||
} else if (picked.kind === "profile") {
|
||||
patchSteps([...steps, newProfileStep(picked.name, picked.script)]);
|
||||
} else {
|
||||
patchSteps([
|
||||
...steps,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { useHttpAuths, useHttpPages, useScripts, useWorkflows } from "../../api/hooks.js";
|
||||
import { useHttpAuths, useHttpPages, useProfiles, 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";
|
||||
@@ -49,6 +49,7 @@ export function WorkflowVisualEditor({
|
||||
const visualDisabled = !displayDoc;
|
||||
|
||||
const { data: scripts = [] } = useScripts();
|
||||
const { data: profiles = [] } = useProfiles(owner || undefined, { enabled: Boolean(owner) });
|
||||
const { data: workflows = [] } = useWorkflows(owner || undefined);
|
||||
const { data: auths = [] } = useHttpAuths();
|
||||
const { data: allPages = [] } = useHttpPages();
|
||||
@@ -90,9 +91,17 @@ export function WorkflowVisualEditor({
|
||||
return map;
|
||||
}, [scripts]);
|
||||
|
||||
const profilesByName = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const p of profiles) {
|
||||
if (p?.name) map.set(p.name, p);
|
||||
}
|
||||
return map;
|
||||
}, [profiles]);
|
||||
|
||||
const inputMeta = useMemo(
|
||||
() => firstInputMeta(displayDoc?.scripts, scriptsByName),
|
||||
[displayDoc?.scripts, scriptsByName],
|
||||
() => firstInputMeta(displayDoc?.scripts, scriptsByName, profilesByName),
|
||||
[displayDoc?.scripts, scriptsByName, profilesByName],
|
||||
);
|
||||
|
||||
const defaultData = useMemo(() => {
|
||||
@@ -175,6 +184,7 @@ export function WorkflowVisualEditor({
|
||||
onPatch={patchDoc}
|
||||
disabled={visualDisabled}
|
||||
scripts={scripts}
|
||||
profiles={profiles}
|
||||
workflows={workflows}
|
||||
owner={owner}
|
||||
excludeFile={file}
|
||||
|
||||
Reference in New Issue
Block a user