feat(web): peek variable values in workflow config field refs
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LuPlus, LuTrash2 } from "react-icons/lu";
|
import { Link } from "react-router-dom";
|
||||||
|
import { LuEye, LuEyeOff, LuExternalLink, LuPlus, LuTrash2 } from "react-icons/lu";
|
||||||
|
import { useVariables } from "../../api/hooks.js";
|
||||||
import { triggerDestinations } from "../../lib/workflow-doc.js";
|
import { triggerDestinations } from "../../lib/workflow-doc.js";
|
||||||
import { prettyJson } from "../../lib/script.js";
|
import { prettyJson } from "../../lib/script.js";
|
||||||
import { FieldLabel } from "./FieldHelp.jsx";
|
import { FieldLabel } from "./FieldHelp.jsx";
|
||||||
|
|
||||||
const MULTILINE_KEYS = new Set(["expression", "jsonata"]);
|
const MULTILINE_KEYS = new Set(["expression", "jsonata"]);
|
||||||
|
const VAR_PEEK_MAX = 48;
|
||||||
|
|
||||||
const CONFIG_REF_PREFIXES = [
|
const CONFIG_REF_PREFIXES = [
|
||||||
{ prefix: "$SECRET_", label: "secret" },
|
{ prefix: "$SECRET_", label: "secret" },
|
||||||
@@ -17,20 +20,132 @@ function describeConfigRef(value) {
|
|||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
for (const { prefix, label } of CONFIG_REF_PREFIXES) {
|
for (const { prefix, label } of CONFIG_REF_PREFIXES) {
|
||||||
if (trimmed.startsWith(prefix) && trimmed.length > prefix.length) {
|
if (trimmed.startsWith(prefix) && trimmed.length > prefix.length) {
|
||||||
return { label, name: trimmed.slice(prefix.length) };
|
return { label, name: trimmed.slice(prefix.length), kind: prefix };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfigRefHint({ value }) {
|
function formatVarDisplay(value) {
|
||||||
|
if (typeof value === "string") return value === "" ? '""' : value;
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncatePeek(text, maxLen = VAR_PEEK_MAX) {
|
||||||
|
if (!text) return "";
|
||||||
|
if (text.length <= maxLen) return text;
|
||||||
|
return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edit-time lookup for `$VAR_` against workflow owner (not a runtime guarantee). */
|
||||||
|
function lookupVariable(variables, owner, name) {
|
||||||
|
const list = Array.isArray(variables) ? variables : [];
|
||||||
|
const match = list.find((v) => v.owner === owner && v.name === name);
|
||||||
|
if (match) {
|
||||||
|
return { status: "found", value: match.value, type: match.type };
|
||||||
|
}
|
||||||
|
const otherOwners = [
|
||||||
|
...new Set(list.filter((v) => v.name === name && v.owner !== owner).map((v) => v.owner)),
|
||||||
|
];
|
||||||
|
if (otherOwners.length > 0) {
|
||||||
|
return { status: "other_owner", otherOwners };
|
||||||
|
}
|
||||||
|
return { status: "missing" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function variablesDeepLink({ owner, name, missing }) {
|
||||||
|
if (missing) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (owner) params.set("owner", owner);
|
||||||
|
if (name) params.set("name", name);
|
||||||
|
const q = params.toString();
|
||||||
|
return q ? `/variables/new?${q}` : "/variables/new";
|
||||||
|
}
|
||||||
|
return `/variables/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/edit`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigRefHint({ value, owner }) {
|
||||||
const ref = describeConfigRef(value);
|
const ref = describeConfigRef(value);
|
||||||
|
const isVar = ref?.kind === "$VAR_";
|
||||||
|
const { data: variables = [], isPending } = useVariables(undefined, { enabled: isVar });
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRevealed(false);
|
||||||
|
}, [value, owner]);
|
||||||
|
|
||||||
if (!ref) return null;
|
if (!ref) return null;
|
||||||
|
|
||||||
|
if (!isVar) {
|
||||||
return (
|
return (
|
||||||
<p className="text-xs opacity-60">
|
<p className="text-xs opacity-60">
|
||||||
from {ref.label} <span className="font-mono">{ref.name}</span>
|
from {ref.label} <span className="font-mono">{ref.name}</span>
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookup = !owner
|
||||||
|
? { status: "missing" }
|
||||||
|
: isPending
|
||||||
|
? { status: "loading" }
|
||||||
|
: lookupVariable(variables, owner, ref.name);
|
||||||
|
const linkTo = variablesDeepLink({
|
||||||
|
owner: owner || "",
|
||||||
|
name: ref.name,
|
||||||
|
missing: lookup.status !== "found",
|
||||||
|
});
|
||||||
|
|
||||||
|
let statusNote = null;
|
||||||
|
if (lookup.status === "missing") {
|
||||||
|
statusNote = <span className="text-warning">(missing)</span>;
|
||||||
|
} else if (lookup.status === "other_owner") {
|
||||||
|
statusNote = (
|
||||||
|
<span className="text-warning">
|
||||||
|
(missing · other owner: {lookup.otherOwners.join(", ")})
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullText = lookup.status === "found" ? formatVarDisplay(lookup.value) : "";
|
||||||
|
const peek = truncatePeek(fullText);
|
||||||
|
const masked = lookup.status === "found" ? "******" : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs opacity-60">
|
||||||
|
<span>
|
||||||
|
from variable <span className="font-mono">{ref.name}</span>
|
||||||
|
{lookup.status === "found" ? ":" : null}
|
||||||
|
</span>
|
||||||
|
{lookup.status === "found" ? (
|
||||||
|
<>
|
||||||
|
<span className="font-mono" title={revealed ? fullText : undefined}>
|
||||||
|
{revealed ? peek : masked}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs btn-square h-5 min-h-0 w-5"
|
||||||
|
aria-label={revealed ? "Hide value" : "Show value"}
|
||||||
|
title={revealed ? "Hide value" : "Show value"}
|
||||||
|
onClick={() => setRevealed((v) => !v)}
|
||||||
|
>
|
||||||
|
{revealed ? <LuEyeOff className="size-3" /> : <LuEye className="size-3" />}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{statusNote}
|
||||||
|
{lookup.status === "loading" ? null : (
|
||||||
|
<Link
|
||||||
|
to={linkTo}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="link link-hover inline-flex items-center gap-0.5"
|
||||||
|
>
|
||||||
|
{lookup.status === "found" ? "Open Variables" : "Create on Variables"}
|
||||||
|
<LuExternalLink className="size-3" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldSpec(meta, key) {
|
function fieldSpec(meta, key) {
|
||||||
@@ -185,7 +300,7 @@ function ValueEditor({ value, onChange, spec, script, fieldKey, workflows, owner
|
|||||||
|
|
||||||
if (type === "object" || (value != null && typeof value === "object" && !Array.isArray(value) && type !== "any")) {
|
if (type === "object" || (value != null && typeof value === "object" && !Array.isArray(value) && type !== "any")) {
|
||||||
const obj = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
const obj = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||||
return <ObjectFields value={obj} onChange={onChange} disabled={disabled} />;
|
return <ObjectFields value={obj} onChange={onChange} owner={owner} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "any") {
|
if (type === "any") {
|
||||||
@@ -242,7 +357,7 @@ function JsonOrTextArea({ text, onCommit, disabled }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ObjectFields({ value, onChange }) {
|
function ObjectFields({ value, onChange, owner }) {
|
||||||
const entries = Object.entries(value ?? {});
|
const entries = Object.entries(value ?? {});
|
||||||
|
|
||||||
function setKey(oldKey, nextKey) {
|
function setKey(oldKey, nextKey) {
|
||||||
@@ -286,6 +401,7 @@ function ObjectFields({ value, onChange }) {
|
|||||||
<ObjectFields
|
<ObjectFields
|
||||||
value={v && !Array.isArray(v) ? v : {}}
|
value={v && !Array.isArray(v) ? v : {}}
|
||||||
onChange={(nv) => setVal(k, nv)}
|
onChange={(nv) => setVal(k, nv)}
|
||||||
|
owner={owner}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
@@ -294,7 +410,7 @@ function ObjectFields({ value, onChange }) {
|
|||||||
onCommit={(nv) => setVal(k, nv)}
|
onCommit={(nv) => setVal(k, nv)}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
<ConfigRefHint value={v} />
|
<ConfigRefHint value={v} owner={owner} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -425,7 +541,7 @@ export function ConfigFields({
|
|||||||
excludeFile={excludeFile}
|
excludeFile={excludeFile}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<ConfigRefHint value={cfg[key]} />
|
<ConfigRefHint value={cfg[key]} owner={owner} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -461,7 +577,7 @@ export function ConfigFields({
|
|||||||
excludeFile={excludeFile}
|
excludeFile={excludeFile}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<ConfigRefHint value={cfg[key]} />
|
<ConfigRefHint value={cfg[key]} owner={owner} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button type="button" className="btn btn-ghost btn-xs" disabled={disabled} onClick={addExtra}>
|
<button type="button" className="btn btn-ghost btn-xs" disabled={disabled} onClick={addExtra}>
|
||||||
|
|||||||
Reference in New Issue
Block a user