feat(dashboard): workflow editor dialogs, inline rename, and dirty-state guard
This commit is contained in:
@@ -533,6 +533,84 @@
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* Inline name + description strip (KTD-10). */
|
||||
.wf-name-strip {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wf-workflow-name,
|
||||
.wf-workflow-name--readonly {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px var(--space-xs);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wf-workflow-name:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.wf-workflow-name--readonly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wf-workflow-name-input {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px var(--space-xs);
|
||||
}
|
||||
|
||||
.wf-workflow-description,
|
||||
.wf-workflow-description--readonly {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-tertiary);
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px var(--space-xs);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wf-workflow-description:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.wf-workflow-description--readonly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wf-workflow-description-input {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px var(--space-xs);
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
/* Create-workflow dialog (KTD-7). */
|
||||
.wf-create-error {
|
||||
margin: var(--space-xs) 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ws-error);
|
||||
}
|
||||
|
||||
.wf-column-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { Agent } from "../api";
|
||||
import type { DiscoveredSkill } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||
import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
|
||||
import { WorkflowEditorCatalogContext } from "./nodes/WorkflowEditorCatalogContext";
|
||||
@@ -86,6 +87,29 @@ function parseModelDropdownValue(value: string): { provider: string; modelId: st
|
||||
return { provider: value.slice(0, slashIndex), modelId: value.slice(slashIndex + 1) };
|
||||
}
|
||||
|
||||
/** Normalized serialization of the editor's authoring state for dirty tracking
|
||||
* (U4). Serializes nodes/edges through flowToIr (so mapping-layer defaults are
|
||||
* materialized identically on the loaded and live sides) plus the editor-owned
|
||||
* name/description and the resulting layout (auto-layout/drag position changes
|
||||
* count as dirty). Returns a stable JSON string for cheap equality. */
|
||||
function serializeGraph(
|
||||
name: string,
|
||||
description: string,
|
||||
nodes: FlowNode<WorkflowFlowNodeData>[],
|
||||
edges: FlowEdge[],
|
||||
columns: WorkflowIrColumn[],
|
||||
fields: WorkflowFieldDefinition[],
|
||||
): string {
|
||||
const { ir, layout } = flowToIr(
|
||||
name,
|
||||
nodes,
|
||||
edges,
|
||||
columns.length ? columns : undefined,
|
||||
fields.length ? fields : undefined,
|
||||
);
|
||||
return JSON.stringify({ name, description, ir, layout });
|
||||
}
|
||||
|
||||
interface WorkflowNodeEditorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -124,6 +148,128 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof
|
||||
{ kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } },
|
||||
];
|
||||
|
||||
/** Local create-workflow dialog (KTD-7). Built on the shared `.modal` primitives
|
||||
* (precedent: NewTaskModal). Owns its own name/description/error state; the
|
||||
* parent supplies an async `onCreate` that performs the createWorkflow call and
|
||||
* throws on failure so the dialog can surface server rejections inline without
|
||||
* losing the typed input. Escape/overlay close (no dirty state of its own). */
|
||||
function CreateWorkflowDialog({
|
||||
onCreate,
|
||||
onClose,
|
||||
}: {
|
||||
onCreate: (name: string, description: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
nameRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const overlayProps = useOverlayDismiss(onClose);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
setError(t("workflows.createNameRequired", "Enter a workflow name"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onCreate(trimmed, description.trim());
|
||||
// Success path closes the dialog from the parent.
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || t("workflows.createFailed", "Failed to create workflow"));
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[name, description, onCreate, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open wf-create-overlay" {...overlayProps}>
|
||||
<div
|
||||
className="modal wf-create-modal"
|
||||
data-testid="wf-create-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("workflows.createTitle", "New workflow")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h3>{t("workflows.createTitle", "New workflow")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
onClick={onClose}
|
||||
aria-label={t("actions.close", "Close")}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
<label className="wf-field">
|
||||
<span>{t("workflows.createName", "Name")}</span>
|
||||
<input
|
||||
ref={nameRef}
|
||||
data-testid="wf-create-name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="wf-field">
|
||||
<span>{t("workflows.createDescription", "Description (optional)")}</span>
|
||||
<textarea
|
||||
rows={2}
|
||||
data-testid="wf-create-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p className="wf-create-error" role="alert" data-testid="wf-create-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
data-testid="wf-create-submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? <Loader2 size={13} className="wf-spin" /> : null}{" "}
|
||||
{t("workflows.createSubmit", "Create")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InnerEditor({
|
||||
onClose,
|
||||
addToast,
|
||||
@@ -144,6 +290,23 @@ function InnerEditor({
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const { t } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
// Create-workflow dialog (KTD-7) open state + focus-return ref to the
|
||||
// "New workflow" button (NewTaskModal focus pattern).
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const newWorkflowBtnRef = useRef<HTMLButtonElement>(null);
|
||||
// Inline-editable name/description (KTD-10). `name`/`description` mirror the
|
||||
// active workflow and are persisted through handleSave; `editingName`/
|
||||
// `editingDescription` flag the active inline input.
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [editingDescription, setEditingDescription] = useState(false);
|
||||
// Snapshot of the workflow as loaded, serialized through flowToIr AFTER
|
||||
// irToFlow so mapping-layer defaults (e.g. condition: "success", config
|
||||
// materialization) are present on both sides of the dirty comparison. Set by
|
||||
// the load effect; compared against the live serialization in `isDirty`.
|
||||
const loadedSnapshotRef = useRef<string | null>(null);
|
||||
// v2 columns the editor is authoring for the active workflow.
|
||||
const [columns, setColumns] = useState<WorkflowIrColumn[]>([]);
|
||||
// v2 custom field definitions the editor is authoring (KTD-13/14, U13).
|
||||
@@ -202,6 +365,18 @@ function InnerEditor({
|
||||
const unplaced = useMemo(() => unplacedNodeIds(nodes, columns), [nodes, columns]);
|
||||
const blockingViolationCount = columnViolations.filter((v) => v.severity === "error").length;
|
||||
|
||||
// Dirty = the normalized live serialization differs from the loaded snapshot
|
||||
// (U4). Built-ins are never dirty (read-only). Memoized over the inputs that
|
||||
// feed serializeGraph; the loaded snapshot is a ref set by the load effect.
|
||||
const isDirty = useMemo(() => {
|
||||
if (isBuiltin) return false;
|
||||
if (!activeWorkflow || loadedSnapshotRef.current === null) return false;
|
||||
return (
|
||||
serializeGraph(name, description, nodes, edges, columns, fields) !==
|
||||
loadedSnapshotRef.current
|
||||
);
|
||||
}, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields]);
|
||||
|
||||
const loadWorkflows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -226,13 +401,32 @@ function InnerEditor({
|
||||
setEdges([]);
|
||||
setColumns([]);
|
||||
setFields([]);
|
||||
setName("");
|
||||
setDescription("");
|
||||
loadedSnapshotRef.current = null;
|
||||
return;
|
||||
}
|
||||
const flow = irToFlow(activeWorkflow);
|
||||
setNodes(flow.nodes);
|
||||
setEdges(flow.edges);
|
||||
setColumns(columnsOf(activeWorkflow));
|
||||
setFields(fieldsOf(activeWorkflow));
|
||||
const loadedColumns = columnsOf(activeWorkflow);
|
||||
const loadedFields = fieldsOf(activeWorkflow);
|
||||
setColumns(loadedColumns);
|
||||
setFields(loadedFields);
|
||||
setName(activeWorkflow.name);
|
||||
setDescription(activeWorkflow.description ?? "");
|
||||
setEditingName(false);
|
||||
setEditingDescription(false);
|
||||
// Compute the normalized loaded snapshot from the materialized flow (so
|
||||
// mapping defaults match the live side) plus name/description.
|
||||
loadedSnapshotRef.current = serializeGraph(
|
||||
activeWorkflow.name,
|
||||
activeWorkflow.description ?? "",
|
||||
flow.nodes,
|
||||
flow.edges,
|
||||
loadedColumns,
|
||||
loadedFields,
|
||||
);
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
setValidationError(null);
|
||||
@@ -462,35 +656,55 @@ function InnerEditor({
|
||||
canvasRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleCreateWorkflow = useCallback(async () => {
|
||||
const name = window.prompt("New workflow name");
|
||||
if (!name?.trim()) return;
|
||||
try {
|
||||
// Close the create dialog and return focus to its trigger (NewTaskModal
|
||||
// focus-return pattern). Used by both the success and cancel paths.
|
||||
const closeCreateDialog = useCallback(() => {
|
||||
setCreateOpen(false);
|
||||
newWorkflowBtnRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Perform the createWorkflow call. Throws on failure so the dialog surfaces
|
||||
// the server error (e.g. duplicate name) inline without losing the input.
|
||||
const handleCreateWorkflow = useCallback(
|
||||
async (workflowName: string, workflowDescription: string) => {
|
||||
const created = await createWorkflow(
|
||||
{ name: name.trim(), ir: emptyWorkflowIr(name.trim()), layout: emptyWorkflowLayout() },
|
||||
{
|
||||
name: workflowName,
|
||||
description: workflowDescription || undefined,
|
||||
ir: emptyWorkflowIr(workflowName),
|
||||
layout: emptyWorkflowLayout(),
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
setWorkflows((ws) => [...ws, created]);
|
||||
setActiveId(created.id);
|
||||
addToast(`Created workflow "${created.name}"`, "success");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Failed to create workflow", "error");
|
||||
}
|
||||
}, [projectId, addToast]);
|
||||
addToast(t("workflows.created", 'Created workflow "{{name}}"', { name: created.name }), "success");
|
||||
closeCreateDialog();
|
||||
},
|
||||
[projectId, addToast, t, closeCreateDialog],
|
||||
);
|
||||
|
||||
const handleDeleteWorkflow = useCallback(async () => {
|
||||
if (!activeWorkflow) return;
|
||||
if (isBuiltinWorkflowId(activeWorkflow.id)) return; // built-ins are read-only
|
||||
if (!window.confirm(`Delete workflow "${activeWorkflow.name}"?`)) return;
|
||||
const ok = await confirm({
|
||||
title: t("workflows.deleteTitle", "Delete workflow?"),
|
||||
message: t("workflows.deleteMessage", 'Delete workflow "{{name}}"? This cannot be undone.', {
|
||||
name: activeWorkflow.name,
|
||||
}),
|
||||
confirmLabel: t("common.delete", "Delete"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteWorkflow(activeWorkflow.id, projectId);
|
||||
setWorkflows((ws) => ws.filter((w) => w.id !== activeWorkflow.id));
|
||||
setActiveId(null);
|
||||
addToast("Workflow deleted", "success");
|
||||
addToast(t("workflows.deleted", "Workflow deleted"), "success");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Failed to delete workflow", "error");
|
||||
addToast(getErrorMessage(err) || t("workflows.deleteFailed", "Failed to delete workflow"), "error");
|
||||
}
|
||||
}, [activeWorkflow, projectId, addToast]);
|
||||
}, [activeWorkflow, projectId, addToast, confirm, t]);
|
||||
|
||||
const handleDuplicate = useCallback(async () => {
|
||||
if (!activeWorkflow) return;
|
||||
@@ -544,15 +758,41 @@ function InnerEditor({
|
||||
setInterpreterOnly(false);
|
||||
setServerNodeError(null);
|
||||
try {
|
||||
const trimmedName = name.trim() || activeWorkflow.name;
|
||||
const { ir, layout } = flowToIr(
|
||||
activeWorkflow.name,
|
||||
trimmedName,
|
||||
nodes,
|
||||
edges,
|
||||
columns.length ? columns : undefined,
|
||||
fields.length ? fields : undefined,
|
||||
);
|
||||
const updated = await updateWorkflow(activeWorkflow.id, { ir, layout }, projectId);
|
||||
// Include name/description in the PATCH only when they changed from the
|
||||
// loaded workflow (KTD-10 inline rename/description persist here).
|
||||
const nameChanged = trimmedName !== activeWorkflow.name;
|
||||
const descChanged = description !== (activeWorkflow.description ?? "");
|
||||
const updated = await updateWorkflow(
|
||||
activeWorkflow.id,
|
||||
{
|
||||
ir,
|
||||
layout,
|
||||
...(nameChanged ? { name: trimmedName } : {}),
|
||||
...(descChanged ? { description } : {}),
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
setWorkflows((ws) => ws.map((w) => (w.id === updated.id ? updated : w)));
|
||||
// Re-baseline the dirty snapshot to the just-saved state so the editor is
|
||||
// clean immediately after a successful save.
|
||||
loadedSnapshotRef.current = serializeGraph(
|
||||
updated.name,
|
||||
updated.description ?? "",
|
||||
nodes,
|
||||
edges,
|
||||
columns,
|
||||
fields,
|
||||
);
|
||||
setName(updated.name);
|
||||
setDescription(updated.description ?? "");
|
||||
// Validate by compiling — surfaces non-linear graphs as a banner.
|
||||
try {
|
||||
await compileWorkflow(updated.id, projectId);
|
||||
@@ -586,7 +826,7 @@ function InnerEditor({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [activeWorkflow, nodes, edges, columns, fields, unplaced, blockingViolationCount, projectId, addToast, t]);
|
||||
}, [activeWorkflow, name, description, nodes, edges, columns, fields, unplaced, blockingViolationCount, projectId, addToast, t]);
|
||||
|
||||
// Stamp the shared error-state badge onto offending nodes: unplaced step
|
||||
// nodes and any node the server flagged (seam-in-branch). One component
|
||||
@@ -722,22 +962,86 @@ function InnerEditor({
|
||||
skills.length,
|
||||
]);
|
||||
|
||||
const overlayProps = useOverlayDismiss(onClose);
|
||||
// ── Dirty-state dismissal guard (U4, R7) ────────────────────────────────────
|
||||
// One synchronous decision point for every dismissal path. If the editor is
|
||||
// clean (or built-in), the action runs immediately; if dirty, the discard
|
||||
// confirm opens and the action runs only in the .then(true) callback. Used by
|
||||
// the X button, overlay click (via useOverlayDismiss), the Escape keydown
|
||||
// handler, and the sidebar workflow switch.
|
||||
const guardedDismiss = useCallback(
|
||||
(proceed: () => void) => {
|
||||
if (!isDirty) {
|
||||
proceed();
|
||||
return;
|
||||
}
|
||||
void confirm({
|
||||
title: t("workflows.discardTitle", "Discard unsaved changes?"),
|
||||
message: t(
|
||||
"workflows.discardMessage",
|
||||
"You have unsaved changes to this workflow. Discard them?",
|
||||
),
|
||||
confirmLabel: t("workflows.discardConfirm", "Discard"),
|
||||
danger: true,
|
||||
}).then((ok) => {
|
||||
if (ok) proceed();
|
||||
});
|
||||
},
|
||||
[isDirty, confirm, t],
|
||||
);
|
||||
|
||||
const requestClose = useCallback(() => {
|
||||
guardedDismiss(onClose);
|
||||
}, [guardedDismiss, onClose]);
|
||||
|
||||
// Sidebar workflow switch: route through the guard so dirty edits prompt
|
||||
// before the active workflow changes (cancel keeps the current selection).
|
||||
const requestSwitch = useCallback(
|
||||
(id: string) => {
|
||||
if (id === activeId) return;
|
||||
guardedDismiss(() => setActiveId(id));
|
||||
},
|
||||
[guardedDismiss, activeId],
|
||||
);
|
||||
|
||||
const overlayProps = useOverlayDismiss(requestClose);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open wf-editor-overlay" {...overlayProps}>
|
||||
<div className="modal wf-editor-modal" ref={modalRef} onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="modal wf-editor-modal"
|
||||
ref={modalRef}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
// Dedicated Escape handler (useOverlayDismiss does not cover Escape).
|
||||
// Ignore Escape originating from inputs/textareas/selects so inline
|
||||
// editors (name/description) keep their own Escape-to-cancel behavior.
|
||||
if (e.key !== "Escape") return;
|
||||
// The create dialog (rendered as a child) owns its own Escape; if it's
|
||||
// open, let it handle the event (it stops propagation already).
|
||||
if (createOpen) return;
|
||||
const target = e.target as HTMLElement;
|
||||
const tag = target.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||
e.stopPropagation();
|
||||
requestClose();
|
||||
}}
|
||||
>
|
||||
<header className="wf-editor-header">
|
||||
<h2>Workflows</h2>
|
||||
<button className="wf-editor-close" onClick={onClose} aria-label="Close workflow editor">
|
||||
<button className="wf-editor-close" onClick={requestClose} aria-label="Close workflow editor">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="wf-editor-body">
|
||||
<aside className="wf-editor-sidebar">
|
||||
<button className="wf-editor-new" onClick={handleCreateWorkflow}>
|
||||
<Plus size={14} /> New workflow
|
||||
<button
|
||||
className="wf-editor-new"
|
||||
ref={newWorkflowBtnRef}
|
||||
data-testid="wf-new-workflow"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus size={14} /> {t("workflows.newWorkflow", "New workflow")}
|
||||
</button>
|
||||
{loading ? (
|
||||
<div className="wf-editor-empty">
|
||||
@@ -751,7 +1055,7 @@ function InnerEditor({
|
||||
<li key={w.id}>
|
||||
<button
|
||||
className={`wf-editor-list-item${w.id === activeId ? " active" : ""}`}
|
||||
onClick={() => setActiveId(w.id)}
|
||||
onClick={() => requestSwitch(w.id)}
|
||||
>
|
||||
{w.name}
|
||||
</button>
|
||||
@@ -764,6 +1068,88 @@ function InnerEditor({
|
||||
<section className="wf-editor-canvas-wrap">
|
||||
{activeWorkflow ? (
|
||||
<>
|
||||
{/* Inline name + description strip (KTD-10). Built-ins render as
|
||||
plain text (no click affordance); user-owned workflows are
|
||||
click-to-edit (Enter commits, Escape cancels, blur commits). */}
|
||||
<div className="wf-name-strip">
|
||||
{isBuiltin ? (
|
||||
<span className="wf-workflow-name wf-workflow-name--readonly" data-testid="wf-workflow-name">
|
||||
{activeWorkflow.name}
|
||||
</span>
|
||||
) : editingName ? (
|
||||
<input
|
||||
className="wf-workflow-name-input"
|
||||
data-testid="wf-workflow-name-input"
|
||||
autoFocus
|
||||
value={name}
|
||||
aria-label={t("workflows.nameLabel", "Workflow name")}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) setName(activeWorkflow.name);
|
||||
setEditingName(false);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setName(activeWorkflow.name);
|
||||
setEditingName(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!name.trim()) setName(activeWorkflow.name);
|
||||
setEditingName(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="wf-workflow-name"
|
||||
data-testid="wf-workflow-name"
|
||||
onClick={() => setEditingName(true)}
|
||||
title={t("workflows.clickToRename", "Click to rename")}
|
||||
>
|
||||
{name || activeWorkflow.name}
|
||||
</button>
|
||||
)}
|
||||
{isBuiltin ? (
|
||||
activeWorkflow.description ? (
|
||||
<span className="wf-workflow-description wf-workflow-description--readonly" data-testid="wf-workflow-description">
|
||||
{activeWorkflow.description}
|
||||
</span>
|
||||
) : null
|
||||
) : editingDescription ? (
|
||||
<input
|
||||
className="wf-workflow-description-input"
|
||||
data-testid="wf-workflow-description-input"
|
||||
autoFocus
|
||||
value={description}
|
||||
aria-label={t("workflows.descriptionLabel", "Workflow description")}
|
||||
placeholder={t("workflows.descriptionPlaceholder", "Add a description")}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
setEditingDescription(false);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setDescription(activeWorkflow.description ?? "");
|
||||
setEditingDescription(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => setEditingDescription(false)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="wf-workflow-description"
|
||||
data-testid="wf-workflow-description"
|
||||
onClick={() => setEditingDescription(true)}
|
||||
title={t("workflows.clickToEditDescription", "Click to edit description")}
|
||||
>
|
||||
{description || t("workflows.descriptionPlaceholder", "Add a description")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isBuiltin ? (
|
||||
// Read-only built-in: a banner *replaces* the save/edit toolbar
|
||||
// (not an overlay); the canvas below stays inspectable.
|
||||
@@ -1551,6 +1937,9 @@ function InnerEditor({
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
{createOpen && (
|
||||
<CreateWorkflowDialog onCreate={handleCreateWorkflow} onClose={closeCreateDialog} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,9 +18,10 @@ vi.mock("../../api", () => ({
|
||||
}));
|
||||
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import { fetchWorkflows, fetchTraits, fetchStepParsers, updateWorkflow, compileWorkflow, createWorkflow, fetchModels } from "../../api";
|
||||
import { fetchWorkflows, fetchTraits, fetchStepParsers, updateWorkflow, compileWorkflow, createWorkflow, deleteWorkflow, fetchModels } from "../../api";
|
||||
import type { TraitCatalogEntry } from "../../api";
|
||||
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
|
||||
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
|
||||
|
||||
const TRAIT_CATALOG: TraitCatalogEntry[] = [
|
||||
{ id: "intake", name: "Intake", builtin: true, flags: { intake: true } },
|
||||
@@ -741,3 +742,221 @@ describe("WorkflowNodeEditor — U2 interpreter-only banner", () => {
|
||||
expect(screen.queryByTestId("wf-interpreter-only-banner")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── U4: dialogs, inline rename/description, dirty guard ─────────────────────
|
||||
|
||||
/** Render the editor wrapped in a ConfirmDialogProvider so confirm()/discard
|
||||
* prompts mount their ConfirmDialog (the app mounts this provider globally in
|
||||
* App.tsx). The ConfirmDialog's primary button carries the supplied label. */
|
||||
function renderWithConfirm(ui: import("react").ReactElement) {
|
||||
return render(<ConfirmDialogProvider>{ui}</ConfirmDialogProvider>);
|
||||
}
|
||||
|
||||
describe("WorkflowNodeEditor — U4 create dialog / delete / inline rename / dirty guard", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
|
||||
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
|
||||
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Create dialog (KTD-7) ──────────────────────────────────────────────────
|
||||
|
||||
it("opens the create dialog and blocks an empty name with an inline error", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
fireEvent.click(await screen.findByTestId("wf-new-workflow"));
|
||||
expect(await screen.findByTestId("wf-create-dialog")).toBeInTheDocument();
|
||||
|
||||
// Submitting with a whitespace-only name shows the inline error and does NOT
|
||||
// call createWorkflow or close the dialog.
|
||||
fireEvent.change(screen.getByTestId("wf-create-name"), { target: { value: " " } });
|
||||
fireEvent.click(screen.getByTestId("wf-create-submit"));
|
||||
expect(await screen.findByTestId("wf-create-error")).toBeInTheDocument();
|
||||
expect(createWorkflow).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("wf-create-dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates and activates a workflow on a valid submit", async () => {
|
||||
const addToast = vi.fn();
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(createWorkflow).mockResolvedValue({ ...v2Def(), id: "WF-NEW", name: "Pipeline" });
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={addToast} />);
|
||||
fireEvent.click(await screen.findByTestId("wf-new-workflow"));
|
||||
fireEvent.change(await screen.findByTestId("wf-create-name"), { target: { value: "Pipeline" } });
|
||||
fireEvent.click(screen.getByTestId("wf-create-submit"));
|
||||
|
||||
await waitFor(() => expect(createWorkflow).toHaveBeenCalled());
|
||||
const [input] = vi.mocked(createWorkflow).mock.calls[0];
|
||||
expect((input as { name: string }).name).toBe("Pipeline");
|
||||
// Dialog closes and the new workflow is active (its name shows in the strip).
|
||||
await waitFor(() => expect(screen.queryByTestId("wf-create-dialog")).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByTestId("wf-workflow-name")).toHaveTextContent("Pipeline"));
|
||||
expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/Pipeline/), "success");
|
||||
});
|
||||
|
||||
it("surfaces a server rejection inline and keeps the dialog open with input preserved", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(createWorkflow).mockRejectedValue(new Error("A workflow named 'Dup' already exists"));
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
fireEvent.click(await screen.findByTestId("wf-new-workflow"));
|
||||
const nameInput = await screen.findByTestId("wf-create-name");
|
||||
fireEvent.change(nameInput, { target: { value: "Dup" } });
|
||||
fireEvent.click(screen.getByTestId("wf-create-submit"));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("wf-create-error")).toHaveTextContent(/already exists/i));
|
||||
// Dialog stays open; the typed name is preserved.
|
||||
expect(screen.getByTestId("wf-create-dialog")).toBeInTheDocument();
|
||||
expect((nameInput as HTMLInputElement).value).toBe("Dup");
|
||||
});
|
||||
|
||||
// ── Delete confirm ─────────────────────────────────────────────────────────
|
||||
|
||||
it("does not delete when no ConfirmDialogProvider is mounted (fallback cancels)", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
fireEvent.click((await screen.findByText("Delete")).closest("button")!);
|
||||
// The no-op fallback resolves false → deleteWorkflow is never called.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(deleteWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes after confirming in the ConfirmDialog (with provider)", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
vi.mocked(deleteWorkflow).mockResolvedValue(undefined);
|
||||
renderWithConfirm(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
fireEvent.click((await screen.findByText("Delete")).closest("button")!);
|
||||
// The confirm dialog's primary (danger) button carries the "Delete" label.
|
||||
const dialog = await screen.findByRole("dialog", { name: /Delete workflow\?/i });
|
||||
const confirmBtn = within(dialog).getByRole("button", { name: "Delete" });
|
||||
fireEvent.click(confirmBtn);
|
||||
await waitFor(() => expect(deleteWorkflow).toHaveBeenCalledWith("WF-002", undefined));
|
||||
});
|
||||
|
||||
// ── Inline rename (KTD-10) ─────────────────────────────────────────────────
|
||||
|
||||
it("renames the workflow inline: click → input prefilled → Enter commits", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
const nameBtn = await screen.findByTestId("wf-workflow-name");
|
||||
expect(nameBtn).toHaveTextContent("Custom");
|
||||
fireEvent.click(nameBtn);
|
||||
const input = (await screen.findByTestId("wf-workflow-name-input")) as HTMLInputElement;
|
||||
expect(input.value).toBe("Custom");
|
||||
fireEvent.change(input, { target: { value: "Renamed" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await waitFor(() => expect(screen.getByTestId("wf-workflow-name")).toHaveTextContent("Renamed"));
|
||||
});
|
||||
|
||||
it("cancels an inline rename on Escape (value reverts)", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
fireEvent.click(await screen.findByTestId("wf-workflow-name"));
|
||||
const input = (await screen.findByTestId("wf-workflow-name-input")) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "Throwaway" } });
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await waitFor(() => expect(screen.getByTestId("wf-workflow-name")).toHaveTextContent("Custom"));
|
||||
});
|
||||
|
||||
it("shows a built-in workflow name as plain text (no rename input on click)", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
const nameEl = await screen.findByTestId("wf-workflow-name");
|
||||
// Built-in renders a plain <span>, not a clickable button.
|
||||
expect(nameEl.tagName).toBe("SPAN");
|
||||
fireEvent.click(nameEl);
|
||||
expect(screen.queryByTestId("wf-workflow-name-input")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("persists a renamed name through the save PATCH", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...v2Def(), ...(updates as object) }));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByText("Save");
|
||||
await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
|
||||
fireEvent.click(screen.getByTestId("wf-workflow-name"));
|
||||
const input = await screen.findByTestId("wf-workflow-name-input");
|
||||
fireEvent.change(input, { target: { value: "Renamed" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
||||
expect((updates as { name?: string }).name).toBe("Renamed");
|
||||
});
|
||||
|
||||
// ── Dirty guard ────────────────────────────────────────────────────────────
|
||||
|
||||
it("closes immediately with no confirm when there are no edits", async () => {
|
||||
const onClose = vi.fn();
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
renderWithConfirm(<WorkflowNodeEditor isOpen onClose={onClose} addToast={() => {}} />);
|
||||
// Wait for the workflow to load (clean snapshot established).
|
||||
await screen.findByTestId("wf-workflow-name");
|
||||
fireEvent.click(screen.getByLabelText("Close workflow editor"));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
// No discard confirm dialog appeared.
|
||||
expect(screen.queryByRole("dialog", { name: /Discard unsaved changes/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("load → immediately close produces no spurious dirty prompt", async () => {
|
||||
// Regression for mapping-default asymmetry: the loaded snapshot is computed
|
||||
// through flowToIr(irToFlow(...)) so default-materialization matches the live
|
||||
// side and a freshly-loaded workflow is never dirty.
|
||||
const onClose = vi.fn();
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([stepwiseDef()]);
|
||||
renderWithConfirm(<WorkflowNodeEditor isOpen onClose={onClose} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-node-foreach");
|
||||
fireEvent.click(screen.getByLabelText("Close workflow editor"));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByRole("dialog", { name: /Discard unsaved changes/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prompts to discard on close when dirty; confirming closes, cancelling keeps it open", async () => {
|
||||
const onClose = vi.fn();
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
renderWithConfirm(<WorkflowNodeEditor isOpen onClose={onClose} addToast={() => {}} />);
|
||||
// Make an edit: inline rename.
|
||||
fireEvent.click(await screen.findByTestId("wf-workflow-name"));
|
||||
const input = await screen.findByTestId("wf-workflow-name-input");
|
||||
fireEvent.change(input, { target: { value: "Edited" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
// Close → discard confirm appears. Cancel keeps the editor open.
|
||||
fireEvent.click(screen.getByLabelText("Close workflow editor"));
|
||||
const dialog = await screen.findByRole("dialog", { name: /Discard unsaved changes/i });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: /Cancel/i }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: /Discard unsaved changes/i })).not.toBeInTheDocument());
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
// Close again → confirm → onClose fires.
|
||||
fireEvent.click(screen.getByLabelText("Close workflow editor"));
|
||||
const dialog2 = await screen.findByRole("dialog", { name: /Discard unsaved changes/i });
|
||||
fireEvent.click(within(dialog2).getByRole("button", { name: /Discard/i }));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("prompts to discard when switching workflows while dirty", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([
|
||||
v2Def(),
|
||||
{ ...v2Def(), id: "WF-OTHER", name: "Other" },
|
||||
]);
|
||||
renderWithConfirm(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
// Edit the active workflow.
|
||||
fireEvent.click(await screen.findByTestId("wf-workflow-name"));
|
||||
const input = await screen.findByTestId("wf-workflow-name-input");
|
||||
fireEvent.change(input, { target: { value: "Edited" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
// Switch to the other workflow in the sidebar → discard confirm.
|
||||
fireEvent.click(screen.getByText("Other"));
|
||||
const dialog = await screen.findByRole("dialog", { name: /Discard unsaved changes/i });
|
||||
// Cancel keeps the current workflow (name still "Edited").
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: /Cancel/i }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: /Discard unsaved changes/i })).not.toBeInTheDocument());
|
||||
expect(screen.getByTestId("wf-workflow-name")).toHaveTextContent("Edited");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6809,7 +6809,27 @@
|
||||
"stepExecuteLabel": "Step execute"
|
||||
},
|
||||
"workflows": {
|
||||
"clickToEditDescription": "Click to edit description",
|
||||
"clickToRename": "Click to rename",
|
||||
"created": "Created workflow \"{{name}}\"",
|
||||
"createDescription": "Description (optional)",
|
||||
"createFailed": "Failed to create workflow",
|
||||
"createName": "Name",
|
||||
"createNameRequired": "Enter a workflow name",
|
||||
"createSubmit": "Create",
|
||||
"createTitle": "New workflow",
|
||||
"deleted": "Workflow deleted",
|
||||
"deleteFailed": "Failed to delete workflow",
|
||||
"deleteMessage": "Delete workflow \"{{name}}\"? This cannot be undone.",
|
||||
"deleteTitle": "Delete workflow?",
|
||||
"descriptionLabel": "Workflow description",
|
||||
"descriptionPlaceholder": "Add a description",
|
||||
"discardConfirm": "Discard",
|
||||
"discardMessage": "You have unsaved changes to this workflow. Discard them?",
|
||||
"discardTitle": "Discard unsaved changes?",
|
||||
"duplicateToCustomize": "Duplicate to customize",
|
||||
"nameLabel": "Workflow name",
|
||||
"newWorkflow": "New workflow",
|
||||
"readOnlyBuiltin": "Read-only built-in workflow",
|
||||
"saved": "Workflow saved",
|
||||
"savedNotCompilable": "Workflow saved but cannot be compiled",
|
||||
|
||||
@@ -6809,7 +6809,27 @@
|
||||
"stepExecuteLabel": "Step execute"
|
||||
},
|
||||
"workflows": {
|
||||
"clickToEditDescription": "",
|
||||
"clickToRename": "",
|
||||
"created": "",
|
||||
"createDescription": "",
|
||||
"createFailed": "",
|
||||
"createName": "",
|
||||
"createNameRequired": "",
|
||||
"createSubmit": "",
|
||||
"createTitle": "",
|
||||
"deleted": "",
|
||||
"deleteFailed": "",
|
||||
"deleteMessage": "",
|
||||
"deleteTitle": "",
|
||||
"descriptionLabel": "",
|
||||
"descriptionPlaceholder": "",
|
||||
"discardConfirm": "",
|
||||
"discardMessage": "",
|
||||
"discardTitle": "",
|
||||
"duplicateToCustomize": "",
|
||||
"nameLabel": "",
|
||||
"newWorkflow": "",
|
||||
"readOnlyBuiltin": "",
|
||||
"saved": "",
|
||||
"savedNotCompilable": "",
|
||||
|
||||
@@ -6809,7 +6809,27 @@
|
||||
"stepExecuteLabel": "Step execute"
|
||||
},
|
||||
"workflows": {
|
||||
"clickToEditDescription": "",
|
||||
"clickToRename": "",
|
||||
"created": "",
|
||||
"createDescription": "",
|
||||
"createFailed": "",
|
||||
"createName": "",
|
||||
"createNameRequired": "",
|
||||
"createSubmit": "",
|
||||
"createTitle": "",
|
||||
"deleted": "",
|
||||
"deleteFailed": "",
|
||||
"deleteMessage": "",
|
||||
"deleteTitle": "",
|
||||
"descriptionLabel": "",
|
||||
"descriptionPlaceholder": "",
|
||||
"discardConfirm": "",
|
||||
"discardMessage": "",
|
||||
"discardTitle": "",
|
||||
"duplicateToCustomize": "",
|
||||
"nameLabel": "",
|
||||
"newWorkflow": "",
|
||||
"readOnlyBuiltin": "",
|
||||
"saved": "",
|
||||
"savedNotCompilable": "",
|
||||
|
||||
@@ -6809,7 +6809,27 @@
|
||||
"stepExecuteLabel": "Step execute"
|
||||
},
|
||||
"workflows": {
|
||||
"clickToEditDescription": "",
|
||||
"clickToRename": "",
|
||||
"created": "",
|
||||
"createDescription": "",
|
||||
"createFailed": "",
|
||||
"createName": "",
|
||||
"createNameRequired": "",
|
||||
"createSubmit": "",
|
||||
"createTitle": "",
|
||||
"deleted": "",
|
||||
"deleteFailed": "",
|
||||
"deleteMessage": "",
|
||||
"deleteTitle": "",
|
||||
"descriptionLabel": "",
|
||||
"descriptionPlaceholder": "",
|
||||
"discardConfirm": "",
|
||||
"discardMessage": "",
|
||||
"discardTitle": "",
|
||||
"duplicateToCustomize": "",
|
||||
"nameLabel": "",
|
||||
"newWorkflow": "",
|
||||
"readOnlyBuiltin": "",
|
||||
"saved": "",
|
||||
"savedNotCompilable": "",
|
||||
|
||||
@@ -6809,7 +6809,27 @@
|
||||
"stepExecuteLabel": "Step execute"
|
||||
},
|
||||
"workflows": {
|
||||
"clickToEditDescription": "",
|
||||
"clickToRename": "",
|
||||
"created": "",
|
||||
"createDescription": "",
|
||||
"createFailed": "",
|
||||
"createName": "",
|
||||
"createNameRequired": "",
|
||||
"createSubmit": "",
|
||||
"createTitle": "",
|
||||
"deleted": "",
|
||||
"deleteFailed": "",
|
||||
"deleteMessage": "",
|
||||
"deleteTitle": "",
|
||||
"descriptionLabel": "",
|
||||
"descriptionPlaceholder": "",
|
||||
"discardConfirm": "",
|
||||
"discardMessage": "",
|
||||
"discardTitle": "",
|
||||
"duplicateToCustomize": "",
|
||||
"nameLabel": "",
|
||||
"newWorkflow": "",
|
||||
"readOnlyBuiltin": "",
|
||||
"saved": "",
|
||||
"savedNotCompilable": "",
|
||||
|
||||
@@ -6809,7 +6809,27 @@
|
||||
"stepExecuteLabel": "Step execute"
|
||||
},
|
||||
"workflows": {
|
||||
"clickToEditDescription": "",
|
||||
"clickToRename": "",
|
||||
"created": "",
|
||||
"createDescription": "",
|
||||
"createFailed": "",
|
||||
"createName": "",
|
||||
"createNameRequired": "",
|
||||
"createSubmit": "",
|
||||
"createTitle": "",
|
||||
"deleted": "",
|
||||
"deleteFailed": "",
|
||||
"deleteMessage": "",
|
||||
"deleteTitle": "",
|
||||
"descriptionLabel": "",
|
||||
"descriptionPlaceholder": "",
|
||||
"discardConfirm": "",
|
||||
"discardMessage": "",
|
||||
"discardTitle": "",
|
||||
"duplicateToCustomize": "",
|
||||
"nameLabel": "",
|
||||
"newWorkflow": "",
|
||||
"readOnlyBuiltin": "",
|
||||
"saved": "",
|
||||
"savedNotCompilable": "",
|
||||
|
||||
20
packages/i18n/src/resources.d.ts
vendored
20
packages/i18n/src/resources.d.ts
vendored
@@ -6817,7 +6817,27 @@ export default interface Resources {
|
||||
"switchConfirm": "Switch and abort"
|
||||
},
|
||||
"workflows": {
|
||||
"clickToEditDescription": "Click to edit description",
|
||||
"clickToRename": "Click to rename",
|
||||
"createDescription": "Description (optional)",
|
||||
"createFailed": "Failed to create workflow",
|
||||
"createName": "Name",
|
||||
"createNameRequired": "Enter a workflow name",
|
||||
"createSubmit": "Create",
|
||||
"createTitle": "New workflow",
|
||||
"created": "Created workflow \"{{name}}\"",
|
||||
"deleteFailed": "Failed to delete workflow",
|
||||
"deleteMessage": "Delete workflow \"{{name}}\"? This cannot be undone.",
|
||||
"deleteTitle": "Delete workflow?",
|
||||
"deleted": "Workflow deleted",
|
||||
"descriptionLabel": "Workflow description",
|
||||
"descriptionPlaceholder": "Add a description",
|
||||
"discardConfirm": "Discard",
|
||||
"discardMessage": "You have unsaved changes to this workflow. Discard them?",
|
||||
"discardTitle": "Discard unsaved changes?",
|
||||
"duplicateToCustomize": "Duplicate to customize",
|
||||
"nameLabel": "Workflow name",
|
||||
"newWorkflow": "New workflow",
|
||||
"readOnlyBuiltin": "Read-only built-in workflow",
|
||||
"saveFailed": "Failed to save workflow",
|
||||
"saved": "Workflow saved",
|
||||
|
||||
Reference in New Issue
Block a user