feat(dashboard): WorkflowSettingsPanel — definitions/values tabs, setting-value routes, flow-mapping round-trip

This commit is contained in:
gsxdsm
2026-06-04 22:57:01 -07:00
parent e8e54bf343
commit 9089e18705
9 changed files with 1846 additions and 7 deletions

View File

@@ -83,6 +83,11 @@ import type {
WorkflowFieldType,
WorkflowFieldOption,
WorkflowFieldRender,
WorkflowSettingDefinition,
WorkflowSettingType,
WorkflowSettingOption,
WorkflowSettingRender,
WorkflowSettingRejection,
} from "@fusion/core";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import type { GithubIssueAction, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
@@ -560,6 +565,10 @@ export interface BoardWorkflowColumn {
// are re-exported from @fusion/core above (KTD-13/14).
export type { WorkflowFieldDefinition, WorkflowFieldType, WorkflowFieldOption, WorkflowFieldRender };
// Workflow-settings (U6/KTD-1) declaration types re-exported from @fusion/core so
// the WorkflowSettingsPanel imports them from `../api` like the field types.
export type { WorkflowSettingDefinition, WorkflowSettingType, WorkflowSettingOption, WorkflowSettingRender, WorkflowSettingRejection };
export interface BoardWorkflowDefinition {
id: string;
name: string;
@@ -5101,6 +5110,46 @@ export function deleteWorkflow(id: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/workflows/${encodeURIComponent(id)}`, projectId), { method: "DELETE" });
}
/** The per-`(workflowId, project)` setting-value payload returned by the
* workflow setting-value endpoints (U6/R5): the raw `stored` map, the
* `effective` map (stored ?? declaration default, drop-on-orphan), and the
* `orphaned` stored entries that no longer validate against the declarations. */
export interface WorkflowSettingValuesPayload {
stored: Record<string, unknown>;
effective: Record<string, unknown>;
orphaned: Array<{ id: string; value: unknown }>;
}
/** Read the setting VALUES (stored/effective/orphaned) for a workflow in the
* current project context (U6). The project is bound server-side to the
* scoped store. */
export function fetchWorkflowSettingValues(
id: string,
projectId?: string,
): Promise<WorkflowSettingValuesPayload> {
return api<WorkflowSettingValuesPayload>(
withProjectId(`/workflows/${encodeURIComponent(id)}/setting-values`, projectId),
);
}
/** Write setting VALUES for a workflow in the current project context (U6). The
* `values` map is validated against the named workflow's declarations; a `null`
* value deletes that key. A typed rejection surfaces as an ApiRequestError with
* `status: 400` and `details.rejections: WorkflowSettingRejection[]`. */
export function updateWorkflowSettingValues(
id: string,
values: Record<string, unknown>,
projectId?: string,
): Promise<WorkflowSettingValuesPayload> {
return api<WorkflowSettingValuesPayload>(
withProjectId(`/workflows/${encodeURIComponent(id)}/setting-values`, projectId),
{
method: "PATCH",
body: JSON.stringify({ values }),
},
);
}
/** Preview the compiled steps for a workflow. Rejects (422) for non-linear graphs. */
export function compileWorkflow(id: string, projectId?: string): Promise<{ steps: WorkflowStepInput[] }> {
return api<{ steps: WorkflowStepInput[] }>(withProjectId(`/workflows/${encodeURIComponent(id)}/compile`, projectId), {

View File

@@ -42,6 +42,7 @@ import {
emptyWorkflowLayout,
columnsOf,
fieldsOf,
settingsOf,
columnsToBandNodes,
strictColumnForY,
validateColumnsClient,
@@ -57,7 +58,8 @@ import {
import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api";
import { WorkflowColumnPanel } from "./WorkflowColumnPanel";
import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
import type { WorkflowFieldDefinition } from "../api";
import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel";
import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
type ExecutorKind = "model" | "agent" | "skill" | "cli";
@@ -85,6 +87,10 @@ interface WorkflowNodeEditorProps {
onClose: () => void;
addToast: (message: string, type?: ToastType) => void;
projectId?: string;
/** When "settings" the editor scrolls the WorkflowSettingsPanel into view on
* mount (U6/U9: redirect stubs link here via a `?panel=settings` param read by
* the editor's mount site). */
initialPanel?: "settings";
}
let nodeSeq = 0;
@@ -122,6 +128,7 @@ function InnerEditor({
onClose,
addToast,
projectId,
initialPanel,
modalRef,
}: Omit<WorkflowNodeEditorProps, "isOpen"> & { modalRef: React.RefObject<HTMLDivElement | null> }) {
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
@@ -138,6 +145,13 @@ function InnerEditor({
const [columns, setColumns] = useState<WorkflowIrColumn[]>([]);
// v2 custom field definitions the editor is authoring (KTD-13/14, U13).
const [fields, setFields] = useState<WorkflowFieldDefinition[]>([]);
// v2 typed setting declarations the editor is authoring (U6, KTD-1). Setting
// VALUES live per-project in the workflow_settings table (KTD-2) and are
// managed by the panel's Values tab, not this declaration array.
const [settings, setSettings] = useState<WorkflowSettingDefinition[]>([]);
// Ref to the settings panel so a `?panel=settings` deep link can scroll it
// into view on mount (U6/U9 redirect stubs).
const settingsPanelRef = useRef<HTMLDivElement | null>(null);
const [traitCatalog, setTraitCatalog] = useState<TraitCatalogEntry[]>([]);
// Step-parser ids for the parse-steps inspector (KTD-12). Seeded with the
// built-in pair so the select is never empty; replaced by the live catalog
@@ -213,6 +227,7 @@ function InnerEditor({
setEdges([]);
setColumns([]);
setFields([]);
setSettings([]);
return;
}
const flow = irToFlow(activeWorkflow);
@@ -220,11 +235,25 @@ function InnerEditor({
setEdges(flow.edges);
setColumns(columnsOf(activeWorkflow));
setFields(fieldsOf(activeWorkflow));
setSettings(settingsOf(activeWorkflow));
setSelectedNodeId(null);
setSelectedEdgeId(null);
setValidationError(null);
}, [activeWorkflow, setNodes, setEdges]);
// `?panel=settings` deep link (U6/U9 redirect stubs): once the active workflow
// has loaded, scroll the settings panel into view. Runs once per editor open.
const didScrollToSettings = useRef(false);
useEffect(() => {
if (initialPanel !== "settings" || didScrollToSettings.current) return;
if (!activeWorkflow) return;
const el = settingsPanelRef.current;
if (el) {
didScrollToSettings.current = true;
el.scrollIntoView({ behavior: "smooth", inline: "end", block: "nearest" });
}
}, [initialPanel, activeWorkflow]);
// Server-reported node error (e.g. seam-in-branch) attributed to a node id.
const [serverNodeError, setServerNodeError] = useState<{ nodeId: string; message: string } | null>(null);
@@ -465,6 +494,7 @@ function InnerEditor({
edges,
columns.length ? columns : undefined,
fields.length ? fields : undefined,
settings.length ? settings : undefined,
);
const updated = await updateWorkflow(activeWorkflow.id, { ir, layout }, projectId);
setWorkflows((ws) => ws.map((w) => (w.id === updated.id ? updated : w)));
@@ -491,7 +521,7 @@ function InnerEditor({
} finally {
setSaving(false);
}
}, [activeWorkflow, nodes, edges, columns, fields, unplaced, blockingViolationCount, projectId, addToast, t]);
}, [activeWorkflow, nodes, edges, columns, fields, settings, 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
@@ -734,6 +764,19 @@ function InnerEditor({
/>
)}
{activeWorkflow && (
<div ref={settingsPanelRef} className="wf-settings-panel-wrap">
<WorkflowSettingsPanel
workflowId={activeWorkflow.id}
settings={settings}
onChange={setSettings}
readOnly={isBuiltin}
projectId={projectId}
addToast={addToast}
/>
</div>
)}
{selectedNode && selectedNode.data.kind !== "start" && selectedNode.data.kind !== "end" && (
<aside className="wf-editor-inspector">
<h3>Node</h3>

View File

@@ -0,0 +1,341 @@
/* WorkflowSettingsPanel (U6 / KTD-1/KTD-2) — sibling of the fields/column panels.
* Mirrors .wf-fields-panel layout so the panels read side-by-side; adds an
* internal tab pair (Definitions / Values). Design tokens only; animations use
* --duration-* and muted text uses --text-muted. */
/* Wrapper div carries the scroll-into-view ref for `?panel=settings` deep links;
* it must not interfere with the editor's flex-row panel layout. */
.wf-settings-panel-wrap {
display: flex;
min-height: 0;
}
.wf-settings-panel {
display: flex;
flex-direction: column;
gap: var(--space-sm);
width: 320px;
min-width: 300px;
padding: var(--space-md);
border-left: 1px solid var(--border);
overflow-y: auto;
}
.wf-settings-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.wf-settings-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--border);
}
.wf-settings-tab {
flex: 1;
padding: 6px 8px;
font-size: 0.72rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted);
cursor: pointer;
transition: color var(--duration-fast) ease, border-color var(--duration-fast) ease;
}
.wf-settings-tab.is-active {
color: var(--text-primary, #fff);
border-bottom-color: var(--accent, #4f7cff);
}
.wf-settings-tabpanel {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.wf-settings-tabpanel-head {
display: flex;
align-items: center;
justify-content: flex-end;
}
.wf-settings-add,
.wf-settings-save-values,
.wf-settings-option-add {
display: inline-flex;
align-items: center;
gap: 4px;
}
.wf-settings-empty {
font-size: 0.75rem;
color: var(--text-muted);
margin: 0;
}
.wf-settings-note {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-size: 0.7rem;
}
.wf-settings-note--info {
color: var(--text-muted);
}
.wf-settings-note--muted {
color: var(--text-muted);
}
.wf-settings-note--warn {
color: var(--ws-warning, #f59e0b);
}
/* ── Definitions list ── */
.wf-settings-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.wf-setting-item {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-sm);
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.wf-setting-item-head {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.wf-setting-name {
flex: 1;
min-width: 0;
}
.wf-setting-id-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs);
}
.wf-setting-id-static {
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
color: var(--text-tertiary);
background: var(--surface-2, rgba(255, 255, 255, 0.04));
padding: 1px 6px;
border-radius: var(--radius-sm);
}
.wf-setting-id-edit {
font-size: 0.65rem;
background: none;
border: none;
color: var(--accent, #4f7cff);
cursor: pointer;
padding: 0;
}
.wf-setting-id-warn {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
margin: 0;
font-size: 0.65rem;
color: var(--ws-warning, #f59e0b);
}
.wf-setting-row {
display: flex;
align-items: flex-end;
gap: var(--space-sm);
}
.wf-setting-sub {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
font-size: 0.7rem;
color: var(--text-muted);
}
.wf-setting-sub > span {
font-size: 0.65rem;
text-transform: uppercase;
color: var(--text-tertiary);
}
.wf-setting--checkbox {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.7rem;
color: var(--text-muted);
}
.wf-setting-options {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding-top: var(--space-xs);
border-top: 1px dashed var(--border);
}
.wf-setting-options-label {
font-size: 0.65rem;
text-transform: uppercase;
color: var(--text-tertiary);
}
.wf-setting-option-row {
display: flex;
align-items: center;
gap: 4px;
}
.wf-setting-option-value,
.wf-setting-option-label {
flex: 1;
min-width: 0;
}
.wf-setting-option-colors {
display: inline-flex;
gap: 2px;
}
.wf-setting-color-swatch {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid var(--border);
padding: 0;
cursor: pointer;
}
.wf-setting-color-swatch.is-active {
outline: 2px solid var(--text-primary, #fff);
outline-offset: 1px;
}
/* ── Values tab ── */
.wf-settings-values-head {
display: flex;
align-items: center;
justify-content: flex-end;
}
.wf-settings-values-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.wf-settings-value-item {
position: relative;
display: flex;
flex-direction: column;
gap: 2px;
}
.wf-settings-customized {
align-self: flex-start;
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent, #4f7cff);
}
.wf-settings-reset-hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-size: 0.65rem;
color: var(--text-muted);
}
/* ── Orphaned disclosure ── */
.wf-settings-orphaned {
border-top: 1px dashed var(--border);
padding-top: var(--space-xs);
}
.wf-settings-orphaned-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
color: var(--text-muted);
font-size: 0.72rem;
cursor: pointer;
padding: 0;
}
.wf-settings-orphaned-body {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding-top: var(--space-xs);
}
.wf-settings-orphaned-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.wf-settings-orphaned-row {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.wf-settings-orphan-id {
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
color: var(--text-tertiary);
}
.wf-settings-orphan-value {
flex: 1;
min-width: 0;
font-size: 0.7rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wf-settings-orphan-delete {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0;
}

View File

@@ -0,0 +1,862 @@
/**
* WorkflowSettingsPanel — the workflow editor's typed-settings surface (U6, R5).
* Sibling to {@link WorkflowFieldsPanel} and {@link WorkflowColumnPanel}: lives
* alongside the canvas in {@link WorkflowNodeEditor}. It is ONE panel with an
* internal TAB PAIR (KTD-1/KTD-2):
*
* - "Definitions" — declare/edit the workflow's typed settings (id, name, type,
* default, options for enum kinds, description, widget). Edits mutate
* `ir.settings` through the editor's shared `settings`/`onChange` state and
* ride the editor's existing IR Save flow; validation runs server-side at save
* (parseWorkflowIr) and surfaces through the editor's error band. Built-in
* workflows render this tab read-only (declarations are not editable; values
* are — KTD-2).
*
* - "Values" — per-PROJECT setting values for the project active when the panel
* opened. Values batch in panel state and commit through a DEDICATED "Save
* values" button that sends ONE PATCH to the value authority route — never
* per-field writes, never fused with the IR Save (the two write authorities
* stay separate, KTD-2). Per-field typed rejections render on the matching
* rows. Below the live list, a collapsible "Orphaned values" disclosure (KTD-6
* drop-on-orphan) shows stored values that no longer validate against the
* current declarations, each with a delete affordance (null patch).
*
* The Values tab BINDS the projectId at open. If the dashboard's active project
* changes while the editor is open, it shows a stale-context notice instead of
* silently rebinding writes. With no active project it shows a requires-project
* state and no write path.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, AlertTriangle, ChevronRight, ChevronDown, Save, RotateCcw } from "lucide-react";
import type {
WorkflowSettingDefinition,
WorkflowSettingType,
WorkflowSettingOption,
WorkflowSettingRejection,
} from "../api";
import {
fetchWorkflowSettingValues,
updateWorkflowSettingValues,
ApiRequestError,
type WorkflowSettingValuesPayload,
} from "../api";
import {
SettingsToggleRow,
SettingsNumberRow,
SettingsSelectRow,
SettingsTextRow,
SettingsTextareaRow,
} from "./settings";
import type { ToastType } from "../hooks/useToast";
import "./WorkflowSettingsPanel.css";
interface WorkflowSettingsPanelProps {
/** The workflow whose settings are being authored. */
workflowId: string;
/** Setting declarations (mirrors WorkflowFieldsPanel's `fields`). */
settings: WorkflowSettingDefinition[];
/** Mutate the declarations (rides the editor's IR save flow). */
onChange: (next: WorkflowSettingDefinition[]) => void;
/** Built-in workflows: declarations read-only; values editable (KTD-2). */
readOnly: boolean;
/** The active project id, bound for the Values tab at panel open. Undefined =
* no active project (Values tab shows a requires-project state). */
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
}
const SETTING_TYPES: WorkflowSettingType[] = [
"string",
"text",
"number",
"boolean",
"enum",
"multi-enum",
];
/** Widgets valid per setting type (mirrors the SETTING_RENDER_WIDGETS whitelist
* client-side so the editor only offers legal combinations). */
const WIDGETS_BY_TYPE: Record<WorkflowSettingType, NonNullable<WorkflowSettingDefinition["render"]>["widget"][]> = {
string: ["input"],
text: ["textarea", "input"],
number: ["input"],
boolean: ["toggle"],
enum: ["select", "radio", "chips"],
"multi-enum": ["chips"],
};
/** Preset palette for enum option colors (matches WorkflowFieldsPanel). */
const PRESET_COLORS = [
"#4f7cff",
"#22c55e",
"#f59e0b",
"#ef4444",
"#a855f7",
"#06b6d4",
"#ec4899",
"#64748b",
];
function isEnumKind(type: WorkflowSettingType): boolean {
return type === "enum" || type === "multi-enum";
}
function kebab(raw: string): string {
return raw
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
let settingSeq = 0;
function newSettingId(): string {
settingSeq += 1;
return `setting-${Date.now().toString(36)}-${settingSeq}`;
}
// ─── Definitions tab ─────────────────────────────────────────────────────────
function DefinitionsTab({
settings,
onChange,
readOnly,
addToast,
}: Pick<WorkflowSettingsPanelProps, "settings" | "onChange" | "readOnly" | "addToast">) {
const { t } = useTranslation("app");
const [editingId, setEditingId] = useState<string | null>(null);
const patchSetting = useCallback(
(id: string, patch: Partial<WorkflowSettingDefinition>) => {
onChange(settings.map((s) => (s.id === id ? { ...s, ...patch } : s)));
},
[settings, onChange],
);
const addSetting = useCallback(() => {
onChange([
...settings,
{ id: newSettingId(), name: t("workflowSettings.newSettingName", "New setting"), type: "string" },
]);
}, [settings, onChange, t]);
const removeSetting = useCallback(
(id: string) => onChange(settings.filter((s) => s.id !== id)),
[settings, onChange],
);
const changeId = useCallback(
(oldId: string, raw: string) => {
const next = kebab(raw);
if (!next) return;
if (next !== oldId && settings.some((s) => s.id === next)) {
addToast(t("workflowSettings.duplicateId", "A setting with that id already exists"), "error");
return;
}
patchSetting(oldId, { id: next });
},
[settings, patchSetting, addToast, t],
);
const changeType = useCallback(
(id: string, type: WorkflowSettingType) => {
const setting = settings.find((s) => s.id === id);
if (!setting) return;
const patch: Partial<WorkflowSettingDefinition> = { type };
if (isEnumKind(type)) {
if (!setting.options || setting.options.length === 0) {
patch.options = [{ value: "option-1", label: t("workflowSettings.newOptionLabel", "Option 1") }];
}
} else {
patch.options = undefined;
}
if (setting.render?.widget && !WIDGETS_BY_TYPE[type].includes(setting.render.widget)) {
patch.render = undefined;
}
// Default value type changed — clear it to avoid a type-mismatch at save.
patch.default = undefined;
patchSetting(id, patch);
},
[settings, patchSetting, t],
);
const setOptions = useCallback(
(id: string, options: WorkflowSettingOption[]) => patchSetting(id, { options }),
[patchSetting],
);
const renderDefaultInput = (setting: WorkflowSettingDefinition) => {
const commit = (value: unknown) => patchSetting(setting.id, { default: value });
if (setting.type === "boolean") {
return (
<label className="wf-setting--checkbox">
<input
type="checkbox"
checked={setting.default === true}
disabled={readOnly}
onChange={(e) => commit(e.target.checked)}
/>
<span>{t("workflowSettings.defaultTrue", "Default on")}</span>
</label>
);
}
if (isEnumKind(setting.type)) {
const current =
setting.type === "multi-enum"
? Array.isArray(setting.default)
? (setting.default as string[])[0] ?? ""
: ""
: typeof setting.default === "string"
? setting.default
: "";
return (
<select
aria-label={t("workflowSettings.defaultLabel", "Default value")}
value={current}
disabled={readOnly}
onChange={(e) => {
const v = e.target.value;
if (v === "") return commit(undefined);
commit(setting.type === "multi-enum" ? [v] : v);
}}
>
<option value="">{t("workflowSettings.noDefault", "— none —")}</option>
{(setting.options ?? []).map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
);
}
const typeAttr = setting.type === "number" ? "number" : "text";
const currentText =
setting.type === "number"
? typeof setting.default === "number"
? String(setting.default)
: ""
: typeof setting.default === "string"
? setting.default
: "";
return (
<input
type={typeAttr}
aria-label={t("workflowSettings.defaultLabel", "Default value")}
defaultValue={currentText}
disabled={readOnly}
onBlur={(e) => {
const raw = e.target.value;
if (raw === "") return commit(undefined);
commit(setting.type === "number" ? Number(raw) : raw);
}}
/>
);
};
return (
<div className="wf-settings-tabpanel" data-testid="wf-settings-definitions">
<div className="wf-settings-tabpanel-head">
<button
className="wf-settings-add"
onClick={addSetting}
disabled={readOnly}
title={readOnly ? t("workflowSettings.readOnlyHint", "Built-in workflows are read-only — duplicate to edit") : undefined}
>
<Plus size={13} /> {t("workflowSettings.add", "Add setting")}
</button>
</div>
{readOnly && (
<p className="wf-settings-note wf-settings-note--info" role="note">
{t("workflowSettings.builtinDefinitionsReadOnly", "Built-in workflow — declarations are read-only. Values are editable below.")}
</p>
)}
{settings.length === 0 ? (
<p className="wf-settings-empty">
{t("workflowSettings.empty", "No settings declared yet. Add a setting to expose a typed, per-project knob.")}
</p>
) : (
<ul className="wf-settings-list">
{settings.map((setting) => {
const widgets = WIDGETS_BY_TYPE[setting.type];
const idEditing = editingId === setting.id;
return (
<li key={setting.id} className="wf-setting-item" data-testid={`wf-setting-${setting.id}`}>
<div className="wf-setting-item-head">
<input
className="wf-setting-name"
aria-label={t("workflowSettings.nameLabel", "Setting name")}
value={setting.name}
disabled={readOnly}
onChange={(e) => patchSetting(setting.id, { name: e.target.value })}
/>
<button
className="wf-setting-remove"
aria-label={t("workflowSettings.remove", "Remove setting")}
disabled={readOnly}
onClick={() => removeSetting(setting.id)}
>
<Trash2 size={13} />
</button>
</div>
<div className="wf-setting-id-row">
{idEditing ? (
<>
<input
className="wf-setting-id"
aria-label={t("workflowSettings.idLabel", "Setting id")}
defaultValue={setting.id}
disabled={readOnly}
onBlur={(e) => {
changeId(setting.id, e.target.value);
setEditingId(null);
}}
/>
<p className="wf-setting-id-warn" role="note">
<AlertTriangle size={11} aria-hidden />{" "}
{t("workflowSettings.idWarn", "Changing the id discards values stored under the old id (remove + add).")}
</p>
</>
) : (
<>
<code className="wf-setting-id-static">{setting.id}</code>
<button
className="wf-setting-id-edit"
disabled={readOnly}
onClick={() => setEditingId(setting.id)}
>
{t("workflowSettings.editId", "Edit id")}
</button>
</>
)}
</div>
<div className="wf-setting-row">
<label className="wf-setting-sub">
<span>{t("workflowSettings.typeLabel", "Type")}</span>
<select
value={setting.type}
disabled={readOnly}
onChange={(e) => changeType(setting.id, e.target.value as WorkflowSettingType)}
>
{SETTING_TYPES.map((ty) => (
<option key={ty} value={ty}>
{ty}
</option>
))}
</select>
</label>
<label className="wf-setting-sub">
<span>{t("workflowSettings.widget", "Widget")}</span>
<select
value={setting.render?.widget ?? ""}
disabled={readOnly}
onChange={(e) =>
patchSetting(setting.id, {
render: e.target.value
? { widget: e.target.value as NonNullable<WorkflowSettingDefinition["render"]>["widget"] }
: undefined,
})
}
>
<option value="">{t("workflowSettings.widgetDefault", "Default")}</option>
{widgets.map((w) => (
<option key={w} value={w}>
{w}
</option>
))}
</select>
</label>
</div>
<label className="wf-setting-sub">
<span>{t("workflowSettings.default", "Default")}</span>
{renderDefaultInput(setting)}
</label>
<label className="wf-setting-sub">
<span>{t("workflowSettings.description", "Description")}</span>
<input
aria-label={t("workflowSettings.descriptionLabel", "Setting description")}
value={setting.description ?? ""}
disabled={readOnly}
onChange={(e) => patchSetting(setting.id, { description: e.target.value || undefined })}
/>
</label>
{isEnumKind(setting.type) && (
<div className="wf-setting-options" data-testid={`wf-setting-options-${setting.id}`}>
<span className="wf-setting-options-label">{t("workflowSettings.options", "Options")}</span>
{(setting.options ?? []).map((opt, i) => (
<div key={i} className="wf-setting-option-row">
<input
className="wf-setting-option-value"
aria-label={t("workflowSettings.optionValue", "Option value")}
value={opt.value}
disabled={readOnly}
onChange={(e) => {
const next = [...(setting.options ?? [])];
next[i] = { ...opt, value: e.target.value };
setOptions(setting.id, next);
}}
/>
<input
className="wf-setting-option-label"
aria-label={t("workflowSettings.optionLabel", "Option label")}
value={opt.label}
disabled={readOnly}
onChange={(e) => {
const next = [...(setting.options ?? [])];
next[i] = { ...opt, label: e.target.value };
setOptions(setting.id, next);
}}
/>
<div
className="wf-setting-option-colors"
role="group"
aria-label={t("workflowSettings.optionColor", "Option color")}
>
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
className={`wf-setting-color-swatch${opt.color === c ? " is-active" : ""}`}
style={{ backgroundColor: c }}
aria-label={c}
aria-pressed={opt.color === c}
disabled={readOnly}
onClick={() => {
const next = [...(setting.options ?? [])];
next[i] = { ...opt, color: opt.color === c ? undefined : c };
setOptions(setting.id, next);
}}
/>
))}
</div>
<button
className="wf-setting-option-remove"
aria-label={t("workflowSettings.removeOption", "Remove option")}
disabled={readOnly}
onClick={() => setOptions(setting.id, (setting.options ?? []).filter((_, j) => j !== i))}
>
<Trash2 size={12} />
</button>
</div>
))}
<button
className="wf-setting-option-add"
disabled={readOnly}
onClick={() => {
const n = (setting.options ?? []).length + 1;
setOptions(setting.id, [
...(setting.options ?? []),
{ value: `option-${n}`, label: t("workflowSettings.optionN", "Option {{n}}", { n }) },
]);
}}
>
<Plus size={12} /> {t("workflowSettings.addOption", "Add option")}
</button>
</div>
)}
</li>
);
})}
</ul>
)}
</div>
);
}
// ─── Values tab ──────────────────────────────────────────────────────────────
/** Stable display string for an orphaned/raw stored value. */
function rawValueDisplay(value: unknown): string {
if (typeof value === "string") return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function ValuesTab({
workflowId,
settings,
boundProjectId,
currentProjectId,
addToast,
}: {
workflowId: string;
settings: WorkflowSettingDefinition[];
/** projectId bound at panel open. Undefined → requires-project state. */
boundProjectId: string | undefined;
/** the dashboard's currently active project (may have changed since open). */
currentProjectId: string | undefined;
addToast: (message: string, type?: ToastType) => void;
}) {
const { t } = useTranslation("app");
const [payload, setPayload] = useState<WorkflowSettingValuesPayload | null>(null);
const [loading, setLoading] = useState(false);
// Batched, per-key pending edits. `null` = clear-to-default (delete the row).
const [pending, setPending] = useState<Record<string, unknown>>({});
const [rejections, setRejections] = useState<Record<string, WorkflowSettingRejection>>({});
const [saving, setSaving] = useState(false);
const [orphanOpen, setOrphanOpen] = useState(false);
const reqSeq = useRef(0);
const staleContext =
boundProjectId !== undefined && currentProjectId !== undefined && currentProjectId !== boundProjectId;
const load = useCallback(async () => {
if (boundProjectId === undefined) return;
const seq = ++reqSeq.current;
setLoading(true);
try {
const res = await fetchWorkflowSettingValues(workflowId, boundProjectId);
if (reqSeq.current === seq) {
setPayload(res);
setPending({});
setRejections({});
}
} catch {
if (reqSeq.current === seq) addToast(t("workflowSettings.loadFailed", "Failed to load setting values"), "error");
} finally {
if (reqSeq.current === seq) setLoading(false);
}
}, [workflowId, boundProjectId, addToast, t]);
useEffect(() => {
void load();
}, [load]);
// No active project bound → requires-project state, no write path.
if (boundProjectId === undefined) {
return (
<div className="wf-settings-tabpanel" data-testid="wf-settings-values">
<p className="wf-settings-note wf-settings-note--info" role="note">
{t("workflowSettings.requiresProject", "Open a project to view and edit per-project setting values.")}
</p>
</div>
);
}
// The effective value to show for a setting: a pending edit (incl. a pending
// clear, which falls back to the declaration default) wins over the server
// effective value.
const effectiveOf = (setting: WorkflowSettingDefinition): unknown => {
if (Object.prototype.hasOwnProperty.call(pending, setting.id)) {
const p = pending[setting.id];
return p === null ? setting.default : p;
}
return payload?.effective?.[setting.id] ?? setting.default;
};
// "customized" iff a stored row holds this key (server) OR a pending non-clear
// edit exists; a pending clear removes the customized state.
const isCustomized = (setting: WorkflowSettingDefinition): boolean => {
if (Object.prototype.hasOwnProperty.call(pending, setting.id)) {
return pending[setting.id] !== null;
}
return payload ? Object.prototype.hasOwnProperty.call(payload.stored, setting.id) : false;
};
const setValue = (id: string, value: unknown) => {
setPending((prev) => ({ ...prev, [id]: value }));
setRejections((prev) => {
if (!prev[id]) return prev;
const next = { ...prev };
delete next[id];
return next;
});
};
const clearValue = (id: string) => setValue(id, null);
const dirty = Object.keys(pending).length > 0;
const save = useCallback(async () => {
if (!dirty) return;
setSaving(true);
try {
const res = await updateWorkflowSettingValues(workflowId, pending, boundProjectId);
setPayload(res);
setPending({});
setRejections({});
addToast(t("workflowSettings.valuesSaved", "Setting values saved"), "success");
} catch (err) {
if (err instanceof ApiRequestError && err.status === 400 && err.details) {
const rejList = (err.details.rejections as WorkflowSettingRejection[] | undefined) ?? [];
if (rejList.length > 0) {
const byId: Record<string, WorkflowSettingRejection> = {};
for (const r of rejList) byId[r.settingId] = r;
setRejections(byId);
// The server persisted nothing on rejection (write-boundary contract):
// keep ALL pending edits applied so the user can fix the offending
// field(s) and re-save.
addToast(t("workflowSettings.valuesRejected", "Some values were rejected — see the highlighted fields"), "error");
return;
}
}
addToast(t("workflowSettings.saveFailed", "Failed to save setting values"), "error");
} finally {
setSaving(false);
}
}, [dirty, workflowId, pending, boundProjectId, addToast, t]);
const renderValueControl = (setting: WorkflowSettingDefinition) => {
const value = effectiveOf(setting);
const error = rejections[setting.id]?.message;
const customized = isCustomized(setting);
const descriptor = {
key: setting.id,
label: setting.name,
help: setting.description,
scope: "project" as const,
};
const clearable = customized;
switch (setting.type) {
case "boolean":
return (
<SettingsToggleRow
descriptor={descriptor}
value={value === true}
error={error}
clearable={clearable}
onChange={(v) => (v === null ? clearValue(setting.id) : setValue(setting.id, v))}
/>
);
case "number":
return (
<SettingsNumberRow
descriptor={descriptor}
value={typeof value === "number" ? value : null}
error={error}
clearable={clearable}
onChange={(v) => (v === null ? clearValue(setting.id) : setValue(setting.id, v))}
/>
);
case "enum":
return (
<SettingsSelectRow
descriptor={{ ...descriptor, options: (setting.options ?? []).map((o) => ({ value: o.value, label: o.label })) }}
value={typeof value === "string" ? value : null}
error={error}
clearable={clearable}
onChange={(v) => (v === null ? clearValue(setting.id) : setValue(setting.id, v))}
/>
);
case "multi-enum": {
// No multi-select primitive in U8 yet; offer the first/clear via select.
const current = Array.isArray(value) ? (value as string[])[0] ?? null : null;
return (
<SettingsSelectRow
descriptor={{ ...descriptor, options: (setting.options ?? []).map((o) => ({ value: o.value, label: o.label })) }}
value={current}
error={error}
clearable={clearable}
onChange={(v) => (v === null ? clearValue(setting.id) : setValue(setting.id, [v]))}
/>
);
}
case "text":
return (
<SettingsTextareaRow
descriptor={descriptor}
value={typeof value === "string" ? value : null}
error={error}
clearable={clearable}
onChange={(v) => (v === null || v === "" ? clearValue(setting.id) : setValue(setting.id, v))}
/>
);
case "string":
default:
return (
<SettingsTextRow
descriptor={descriptor}
value={typeof value === "string" ? value : null}
error={error}
clearable={clearable}
onChange={(v) => (v === null || v === "" ? clearValue(setting.id) : setValue(setting.id, v))}
/>
);
}
};
const orphaned = payload?.orphaned ?? [];
const deleteOrphan = useCallback(
async (id: string) => {
try {
const res = await updateWorkflowSettingValues(workflowId, { [id]: null }, boundProjectId);
setPayload(res);
addToast(t("workflowSettings.orphanDeleted", "Orphaned value removed"), "success");
} catch {
addToast(t("workflowSettings.saveFailed", "Failed to save setting values"), "error");
}
},
[workflowId, boundProjectId, addToast, t],
);
return (
<div className="wf-settings-tabpanel" data-testid="wf-settings-values">
{staleContext && (
<p className="wf-settings-note wf-settings-note--warn" role="note" data-testid="wf-settings-stale-notice">
<AlertTriangle size={12} aria-hidden />{" "}
{t(
"workflowSettings.staleContext",
"Values shown are for project {{project}} — reopen the editor to edit values for the current project.",
{ project: boundProjectId },
)}
</p>
)}
<div className="wf-settings-values-head">
<button
className="wf-settings-save-values"
onClick={save}
disabled={!dirty || saving || staleContext}
data-testid="wf-settings-save-values"
>
<Save size={13} /> {t("workflowSettings.saveValues", "Save values")}
</button>
</div>
{settings.length === 0 ? (
<p className="wf-settings-empty">
{loading
? t("workflowSettings.loading", "Loading…")
: t("workflowSettings.noDeclarations", "This workflow declares no settings, so there are no values to edit.")}
</p>
) : (
<div className="wf-settings-values-list">
{settings.map((setting) => (
<div key={setting.id} className="wf-settings-value-item" data-testid={`wf-settings-value-${setting.id}`}>
{renderValueControl(setting)}
{isCustomized(setting) && (
<span className="wf-settings-customized" data-testid={`wf-settings-customized-${setting.id}`}>
{t("workflowSettings.customized", "Customized")}
</span>
)}
</div>
))}
</div>
)}
{orphaned.length > 0 && (
<div className="wf-settings-orphaned" data-testid="wf-settings-orphaned">
<button
className="wf-settings-orphaned-toggle"
onClick={() => setOrphanOpen((v) => !v)}
aria-expanded={orphanOpen}
>
{orphanOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}{" "}
{t("workflowSettings.orphanedTitle", "Orphaned values ({{count}})", { count: orphaned.length })}
</button>
{orphanOpen && (
<div className="wf-settings-orphaned-body">
<p className="wf-settings-note wf-settings-note--muted" role="note">
{t(
"workflowSettings.orphanedNote",
"These stored values no longer match a current declaration (the setting was retyped or removed). They are ignored by the engine; delete them to clean up.",
)}
</p>
<ul className="wf-settings-orphaned-list">
{orphaned.map((o) => (
<li key={o.id} className="wf-settings-orphaned-row" data-testid={`wf-settings-orphan-${o.id}`}>
<code className="wf-settings-orphan-id">{o.id}</code>
<span className="wf-settings-orphan-value">{rawValueDisplay(o.value)}</span>
<button
className="wf-settings-orphan-delete"
aria-label={t("workflowSettings.deleteOrphan", "Delete orphaned value")}
disabled={staleContext}
onClick={() => deleteOrphan(o.id)}
>
<Trash2 size={12} />
</button>
</li>
))}
</ul>
</div>
)}
</div>
)}
{settings.length > 0 && (
<p className="wf-settings-reset-hint" role="note">
<RotateCcw size={11} aria-hidden />{" "}
{t("workflowSettings.clearHint", "Use the reset control on a row to clear a value back to its declaration default.")}
</p>
)}
</div>
);
}
// ─── Panel shell (tab pair) ──────────────────────────────────────────────────
export function WorkflowSettingsPanel({
workflowId,
settings,
onChange,
readOnly,
projectId,
addToast,
}: WorkflowSettingsPanelProps) {
const { t } = useTranslation("app");
const [tab, setTab] = useState<"definitions" | "values">("definitions");
// Bind the projectId active when the panel first mounted for this workflow.
// The Values tab uses this bound id; a later change to `projectId` surfaces a
// stale-context notice rather than rebinding writes. Re-bind only when the
// workflow itself changes (the editor re-keys/remounts per active workflow).
const boundRef = useRef<{ workflowId: string; projectId: string | undefined }>({ workflowId, projectId });
if (boundRef.current.workflowId !== workflowId) {
boundRef.current = { workflowId, projectId };
}
const boundProjectId = boundRef.current.projectId;
return (
<aside className="wf-settings-panel" data-testid="wf-settings-panel">
<header className="wf-settings-panel-header">
<h3>{t("workflowSettings.title", "Settings")}</h3>
</header>
<div className="wf-settings-tabs" role="tablist">
<button
role="tab"
aria-selected={tab === "definitions"}
className={`wf-settings-tab${tab === "definitions" ? " is-active" : ""}`}
onClick={() => setTab("definitions")}
data-testid="wf-settings-tab-definitions"
>
{t("workflowSettings.definitionsTab", "Definitions")}
</button>
<button
role="tab"
aria-selected={tab === "values"}
className={`wf-settings-tab${tab === "values" ? " is-active" : ""}`}
onClick={() => setTab("values")}
data-testid="wf-settings-tab-values"
>
{t("workflowSettings.valuesTab", "Values")}
</button>
</div>
{tab === "definitions" ? (
<DefinitionsTab settings={settings} onChange={onChange} readOnly={readOnly} addToast={addToast} />
) : (
<ValuesTab
workflowId={workflowId}
settings={settings}
boundProjectId={boundProjectId}
currentProjectId={projectId}
addToast={addToast}
/>
)}
</aside>
);
}
export default WorkflowSettingsPanel;

View File

@@ -0,0 +1,284 @@
// @vitest-environment jsdom
/**
* WorkflowSettingsPanel (U6, R5) — declaration authoring + per-project value
* editing. Mirrors the WorkflowFieldsPanel test harness: a small stateful host
* drives the controlled `settings`/`onChange` declaration props the way
* WorkflowNodeEditor does. The value-endpoint api functions are mocked so the
* Values tab can be exercised without a server (the panel never talks to the
* store directly — only through `fetchWorkflowSettingValues` /
* `updateWorkflowSettingValues`).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, within } from "@testing-library/react";
import { useState } from "react";
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
expect.extend(jestDomMatchers);
// Keep the real module (type re-exports, ApiRequestError, every other helper)
// and override only the two value-endpoint functions.
vi.mock("../../api", async () => {
const actual = await vi.importActual<typeof import("../../api")>("../../api");
return {
...actual,
fetchWorkflowSettingValues: vi.fn(),
updateWorkflowSettingValues: vi.fn(),
};
});
import * as apiModule from "../../api";
import type { WorkflowSettingDefinition, WorkflowSettingValuesPayload } from "../../api";
import { ApiRequestError } from "../../api";
import { WorkflowSettingsPanel } from "../WorkflowSettingsPanel";
const mockFetchValues = vi.mocked(apiModule.fetchWorkflowSettingValues);
const mockUpdateValues = vi.mocked(apiModule.updateWorkflowSettingValues);
function payload(over: Partial<WorkflowSettingValuesPayload> = {}): WorkflowSettingValuesPayload {
return { stored: {}, effective: {}, orphaned: [], ...over };
}
function Host({
initial,
workflowId = "wf-1",
readOnly = false,
projectId = "proj-1",
onState,
}: {
initial: WorkflowSettingDefinition[];
workflowId?: string;
readOnly?: boolean;
projectId?: string;
onState?: (s: WorkflowSettingDefinition[]) => void;
}) {
const [settings, setSettings] = useState<WorkflowSettingDefinition[]>(initial);
return (
<WorkflowSettingsPanel
workflowId={workflowId}
settings={settings}
readOnly={readOnly}
projectId={projectId}
addToast={() => {}}
onChange={(next) => {
setSettings(next);
onState?.(next);
}}
/>
);
}
const openValues = () => fireEvent.click(screen.getByTestId("wf-settings-tab-values"));
beforeEach(() => {
mockFetchValues.mockResolvedValue(payload());
mockUpdateValues.mockResolvedValue(payload());
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("WorkflowSettingsPanel — Definitions tab", () => {
it("renders the empty state and adds a default string setting", () => {
let latest: WorkflowSettingDefinition[] = [];
render(<Host initial={[]} onState={(s) => (latest = s)} />);
expect(screen.getByText(/No settings declared yet/i)).toBeInTheDocument();
fireEvent.click(screen.getByText("Add setting").closest("button")!);
expect(latest).toHaveLength(1);
expect(latest[0].type).toBe("string");
expect(latest[0].name).toBe("New setting");
});
it("declares a setting of each supported type", () => {
let latest: WorkflowSettingDefinition[] = [];
render(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} onState={(s) => (latest = s)} />);
const typeSelect = within(screen.getByTestId("wf-setting-s1")).getByDisplayValue("string");
for (const ty of ["text", "number", "boolean", "enum", "multi-enum"]) {
fireEvent.change(typeSelect, { target: { value: ty } });
expect(latest[0].type).toBe(ty);
}
});
it("seeds options when switching to enum", () => {
let latest: WorkflowSettingDefinition[] = [];
render(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} onState={(s) => (latest = s)} />);
const typeSelect = within(screen.getByTestId("wf-setting-s1")).getByDisplayValue("string");
fireEvent.change(typeSelect, { target: { value: "enum" } });
expect(latest[0].options).toHaveLength(1);
expect(screen.getByTestId("wf-setting-options-s1")).toBeInTheDocument();
});
it("surfaces a duplicate-id error via the toast (remove+add id edit)", () => {
const addToast = vi.fn();
function H() {
const [settings, setSettings] = useState<WorkflowSettingDefinition[]>([
{ id: "alpha", name: "A", type: "string" },
{ id: "beta", name: "B", type: "string" },
]);
return (
<WorkflowSettingsPanel
workflowId="wf-1"
settings={settings}
readOnly={false}
projectId="proj-1"
addToast={addToast}
onChange={setSettings}
/>
);
}
render(<H />);
const betaItem = screen.getByTestId("wf-setting-beta");
fireEvent.click(within(betaItem).getByText("Edit id"));
const idInput = within(betaItem).getByLabelText("Setting id");
fireEvent.change(idInput, { target: { value: "alpha" } });
fireEvent.blur(idInput);
expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/already exists/i), "error");
});
it("built-in workflows render declarations read-only", () => {
render(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} readOnly />);
expect(screen.getByText(/declarations are read-only/i)).toBeInTheDocument();
const nameInput = within(screen.getByTestId("wf-setting-s1")).getByLabelText("Setting name");
expect(nameInput).toBeDisabled();
// The "Add setting" button is disabled for built-ins.
expect(screen.getByText("Add setting").closest("button")).toBeDisabled();
});
});
describe("WorkflowSettingsPanel — Values tab", () => {
const decls: WorkflowSettingDefinition[] = [
{ id: "timeout-ms", name: "Timeout", type: "number", default: 1000 },
{ id: "new-sessions", name: "New sessions", type: "boolean", default: false },
{ id: "label", name: "Label", type: "string" },
];
it("loads values on open and shows the customized indicator for stored keys", async () => {
mockFetchValues.mockResolvedValue(
payload({ stored: { "timeout-ms": 5000 }, effective: { "timeout-ms": 5000, "new-sessions": false } }),
);
render(<Host initial={decls} />);
openValues();
await waitFor(() => expect(mockFetchValues).toHaveBeenCalledWith("wf-1", "proj-1"));
await waitFor(() => expect(screen.getByTestId("wf-settings-customized-timeout-ms")).toBeInTheDocument());
// A non-stored key shows no customized indicator.
expect(screen.queryByTestId("wf-settings-customized-new-sessions")).not.toBeInTheDocument();
});
it("batches three field edits into exactly ONE patch on Save values", async () => {
mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false } }));
render(<Host initial={decls} />);
openValues();
await waitFor(() => expect(mockFetchValues).toHaveBeenCalled());
fireEvent.change(screen.getByLabelText("Timeout"), { target: { value: "5000" } });
fireEvent.click(screen.getByLabelText("New sessions"));
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "hello" } });
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
expect(mockUpdateValues).toHaveBeenCalledWith(
"wf-1",
{ "timeout-ms": 5000, "new-sessions": true, label: "hello" },
"proj-1",
);
});
it("renders a per-field rejection on the matching row and keeps other edits applied", async () => {
mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false } }));
mockUpdateValues.mockRejectedValueOnce(
new ApiRequestError("rejected", 400, {
rejections: [{ code: "type-mismatch", settingId: "timeout-ms", message: "expects a number" }],
}),
);
render(<Host initial={decls} />);
openValues();
await waitFor(() => expect(mockFetchValues).toHaveBeenCalled());
fireEvent.change(screen.getByLabelText("Timeout"), { target: { value: "5000" } });
fireEvent.click(screen.getByLabelText("New sessions"));
fireEvent.click(screen.getByTestId ? screen.getByTestId("wf-settings-save-values") : screen.getByText("Save values"));
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/expects a number/i));
// The other edited field keeps its value (write-boundary: nothing persisted,
// all pending edits stay applied so the user can fix + resave).
expect((screen.getByLabelText("New sessions") as HTMLInputElement).checked).toBe(true);
});
it("no active project → requires-project state with no write path", () => {
render(
<WorkflowSettingsPanel
workflowId="wf-1"
settings={decls}
readOnly={false}
projectId={undefined}
addToast={() => {}}
onChange={() => {}}
/>,
);
openValues();
expect(screen.getByText(/Open a project to view and edit/i)).toBeInTheDocument();
expect(screen.queryByTestId("wf-settings-save-values")).not.toBeInTheDocument();
expect(mockFetchValues).not.toHaveBeenCalled();
});
it("shows a stale-context notice when the active project changes after open", async () => {
function H() {
const [pid, setPid] = useState<string | undefined>("proj-1");
return (
<>
<button onClick={() => setPid("proj-2")}>switch</button>
<WorkflowSettingsPanel
workflowId="wf-1"
settings={decls}
readOnly={false}
projectId={pid}
addToast={() => {}}
onChange={() => {}}
/>
</>
);
}
render(<H />);
openValues();
await waitFor(() => expect(mockFetchValues).toHaveBeenCalledWith("wf-1", "proj-1"));
expect(screen.queryByTestId("wf-settings-stale-notice")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("switch"));
await waitFor(() => expect(screen.getByTestId("wf-settings-stale-notice")).toBeInTheDocument());
// Save is disabled under stale context (no writes to the new project).
expect(screen.getByTestId("wf-settings-save-values")).toBeDisabled();
});
it("renders orphaned values in a disclosure and deletes via a null patch", async () => {
mockFetchValues.mockResolvedValue(
payload({ stored: { "old-key": "stale" }, effective: {}, orphaned: [{ id: "old-key", value: "stale" }] }),
);
render(<Host initial={decls} />);
openValues();
await waitFor(() => expect(screen.getByTestId("wf-settings-orphaned")).toBeInTheDocument());
// Expand the disclosure.
fireEvent.click(within(screen.getByTestId("wf-settings-orphaned")).getByRole("button"));
const orphanRow = await screen.findByTestId("wf-settings-orphan-old-key");
expect(orphanRow).toHaveTextContent("old-key");
expect(orphanRow).toHaveTextContent("stale");
fireEvent.click(within(orphanRow).getByLabelText("Delete orphaned value"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledWith("wf-1", { "old-key": null }, "proj-1"));
});
it("clear-to-default emits a null patch for a customized value", async () => {
mockFetchValues.mockResolvedValue(payload({ stored: { "timeout-ms": 5000 }, effective: { "timeout-ms": 5000 } }));
render(<Host initial={decls} />);
openValues();
await waitFor(() => expect(screen.getByTestId("wf-settings-customized-timeout-ms")).toBeInTheDocument());
// The clear/reset affordance lives on the row (SettingsFieldRow onClear).
const row = screen.getByTestId("wf-settings-value-timeout-ms");
const clearBtn = within(row).getByRole("button");
fireEvent.click(clearBtn);
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledWith("wf-1", { "timeout-ms": null }, "proj-1"));
});
});

View File

@@ -7,6 +7,7 @@ import type {
WorkflowIrEdge,
WorkflowDefinition,
WorkflowFieldDefinition,
WorkflowSettingDefinition,
} from "@fusion/core";
import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
@@ -279,6 +280,7 @@ export function flowToIr(
edges: FlowEdge[],
columns?: WorkflowIrColumn[],
fields?: WorkflowFieldDefinition[],
settings?: WorkflowSettingDefinition[],
): { ir: WorkflowIr; layout: Record<string, { x: number; y: number }> } {
const realNodes = nodes.filter((n) => !isColumnBandNode(n.id));
// Partition by parentId: foreach group children reassemble into that group's
@@ -295,9 +297,10 @@ export function flowToIr(
}
const groupIds = new Set(topNodes.filter((n) => n.data.kind === "foreach").map((n) => n.id));
const hasFields = Array.isArray(fields) && fields.length > 0;
// Fields are a v2-only declaration: a workflow with fields but no custom
// columns still serializes as v2 (with the synthesized default columns).
const v2 = (Array.isArray(columns) && columns.length > 0) || hasFields;
const hasSettings = Array.isArray(settings) && settings.length > 0;
// Fields and settings are v2-only declarations: a workflow with either but no
// custom columns still serializes as v2 (with the synthesized default columns).
const v2 = (Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings;
const layout: Record<string, { x: number; y: number }> = {};
/** Project one flow node (top-level or template child) into an IR node. */
@@ -370,6 +373,16 @@ export function flowToIr(
// and the server validator is the source of truth, so assign via unknown.
(ir as { fields?: unknown }).fields = fields!.map((f) => ({ ...f }));
}
if (hasSettings) {
// Setting DECLARATIONS round-trip through the editor opaquely (server
// validator is the source of truth, same as fields). Values live in the
// workflow_settings table, NOT in the IR (KTD-2).
(ir as { settings?: unknown }).settings = settings!.map((s) => ({
...s,
options: s.options ? s.options.map((o) => ({ ...o })) : undefined,
render: s.render ? { ...s.render } : undefined,
}));
}
return { ir, layout };
}
@@ -542,6 +555,20 @@ export function fieldsOf(def: WorkflowDefinition): WorkflowFieldDefinition[] {
}));
}
/** Extract the editor's working setting-declaration list from a definition (U6,
* KTD-1). v2 with `settings` → a deep-ish copy; v1 or no settings → empty.
* Setting VALUES are not carried here — they live per-`(workflowId, projectId)`
* in the workflow_settings table and are fetched separately (KTD-2). */
export function settingsOf(def: WorkflowDefinition): WorkflowSettingDefinition[] {
const ir = def.ir as { settings?: WorkflowSettingDefinition[] };
if (!isV2(def.ir) || !Array.isArray(ir.settings)) return [];
return ir.settings.map((s) => ({
...s,
options: s.options ? s.options.map((o) => ({ ...o })) : undefined,
render: s.render ? { ...s.render } : undefined,
}));
}
/** Seed graph for a brand-new workflow: start → end with room to insert steps. */
export function emptyWorkflowIr(name: string): WorkflowIr {
return {

View File

@@ -86,6 +86,8 @@ describe("workflow routes (U4)", () => {
const put = (path: string, body: unknown) =>
request(app, "PUT", path, JSON.stringify(body), { "content-type": "application/json" });
const get = (path: string) => request(app, "GET", path);
const patch = (path: string, body: unknown) =>
request(app, "PATCH", path, JSON.stringify(body), { "content-type": "application/json" });
it("POST /workflows creates with valid IR and rejects malformed IR", async () => {
const ok = await post("/api/workflows", { name: "QA", ir: linearIr() });
@@ -445,4 +447,136 @@ describe("workflow routes (U4)", () => {
expect((await store.getTask(t.id)).column).toBe("intake");
});
});
// ── Workflow setting VALUES (U6, R5) ───────────────────────────────────────
describe("setting-values routes (U6)", () => {
// A v2 IR declaring one of each value-relevant type.
function settingsIr(name: string): WorkflowIr {
return {
version: "v2",
name,
columns: [],
nodes: [
{ id: "start", kind: "start" },
{ id: "end", kind: "end" },
],
edges: [{ from: "start", to: "end", condition: "success" }],
settings: [
{ id: "timeout-ms", name: "Timeout", type: "number", default: 1000 },
{ id: "new-sessions", name: "New sessions", type: "boolean", default: false },
{
id: "review-policy",
name: "Review policy",
type: "enum",
default: "strict",
options: [
{ value: "strict", label: "Strict" },
{ value: "lenient", label: "Lenient" },
],
},
{ id: "label", name: "Label", type: "string" },
],
} as WorkflowIr;
}
async function createSettingsWorkflow(): Promise<string> {
const wf = await post("/api/workflows", { name: "sw-settings", ir: settingsIr("sw-settings") });
expect(wf.status).toBe(201);
return (wf.body as { id: string }).id;
}
it("GET returns stored/effective/orphaned (defaults until a value is stored)", async () => {
const id = await createSettingsWorkflow();
const res = await get(`/api/workflows/${encodeURIComponent(id)}/setting-values`);
expect(res.status).toBe(200);
const body = res.body as {
stored: Record<string, unknown>;
effective: Record<string, unknown>;
orphaned: Array<{ id: string }>;
};
expect(body.stored).toEqual({});
// Declaration defaults fill the effective map (drop-on-orphan, KTD-6).
expect(body.effective["timeout-ms"]).toBe(1000);
expect(body.effective["new-sessions"]).toBe(false);
expect(body.effective["review-policy"]).toBe("strict");
expect(body.orphaned).toEqual([]);
});
it("PATCH writes a valid batch (one request, multiple keys) and reflects it", async () => {
const id = await createSettingsWorkflow();
const res = await patch(`/api/workflows/${encodeURIComponent(id)}/setting-values`, {
values: { "timeout-ms": 5000, "new-sessions": true, "review-policy": "lenient" },
});
expect(res.status).toBe(200);
const body = res.body as { stored: Record<string, unknown>; effective: Record<string, unknown> };
expect(body.stored).toEqual({ "timeout-ms": 5000, "new-sessions": true, "review-policy": "lenient" });
expect(body.effective["timeout-ms"]).toBe(5000);
expect(body.effective["label"]).toBeUndefined(); // no default, no stored
});
it("PATCH null deletes a key (clear-to-default)", async () => {
const id = await createSettingsWorkflow();
await patch(`/api/workflows/${encodeURIComponent(id)}/setting-values`, { values: { "timeout-ms": 5000 } });
const res = await patch(`/api/workflows/${encodeURIComponent(id)}/setting-values`, {
values: { "timeout-ms": null },
});
expect(res.status).toBe(200);
const body = res.body as { stored: Record<string, unknown>; effective: Record<string, unknown> };
expect(body.stored["timeout-ms"]).toBeUndefined();
expect(body.effective["timeout-ms"]).toBe(1000); // back to declaration default
});
it("PATCH rejects an invalid value with 400 + typed rejections; nothing persisted", async () => {
const id = await createSettingsWorkflow();
const res = await patch(`/api/workflows/${encodeURIComponent(id)}/setting-values`, {
values: { "timeout-ms": "not-a-number", "review-policy": "bogus" },
});
expect(res.status).toBe(400);
const details = (res.body as { details?: { rejections?: Array<{ settingId: string; code: string }> } }).details;
const rejections = details?.rejections ?? [];
const byId = Object.fromEntries(rejections.map((r) => [r.settingId, r.code]));
expect(byId["timeout-ms"]).toBe("type-mismatch");
expect(byId["review-policy"]).toBe("enum-violation");
// Write-boundary contract: nothing persisted.
const after = await get(`/api/workflows/${encodeURIComponent(id)}/setting-values`);
expect((after.body as { stored: Record<string, unknown> }).stored).toEqual({});
});
it("PATCH accepts a value write for a built-in workflow (R4)", async () => {
const res = await patch("/api/workflows/builtin:coding/setting-values", {
values: { workflowStepTimeoutMs: 123_456 },
});
// Built-in coding declares the moved-key catalog; a valid numeric write
// succeeds even though built-in DECLARATIONS are not editable.
expect(res.status).toBe(200);
const body = res.body as { stored: Record<string, unknown> };
expect(body.stored["workflowStepTimeoutMs"]).toBe(123_456);
});
it("GET surfaces orphaned stored values after a declaration retype", async () => {
const id = await createSettingsWorkflow();
// Store a valid number for timeout-ms.
await patch(`/api/workflows/${encodeURIComponent(id)}/setting-values`, { values: { "timeout-ms": 5000 } });
// Retype timeout-ms to a string via an IR save → the stored number orphans.
const retyped = settingsIr("sw-settings");
(retyped as { settings?: Array<{ id: string; type: string; default?: unknown }> }).settings![0] = {
id: "timeout-ms",
name: "Timeout",
type: "string",
} as never;
await patch(`/api/workflows/${encodeURIComponent(id)}`, { ir: retyped });
const res = await get(`/api/workflows/${encodeURIComponent(id)}/setting-values`);
expect(res.status).toBe(200);
const body = res.body as { effective: Record<string, unknown>; orphaned: Array<{ id: string; value: unknown }> };
expect(body.orphaned.some((o) => o.id === "timeout-ms" && o.value === 5000)).toBe(true);
// Effective drops the orphan (no string default declared) → undefined.
expect(body.effective["timeout-ms"]).toBeUndefined();
});
it("PATCH 400 when values is missing/not an object", async () => {
const id = await createSettingsWorkflow();
const res = await patch(`/api/workflows/${encodeURIComponent(id)}/setting-values`, { values: [1, 2, 3] });
expect(res.status).toBe(400);
});
});
});

View File

@@ -1,5 +1,20 @@
import type { WorkflowIr, WorkflowIrNode } from "@fusion/core";
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, compileWorkflowToSteps, listTraits, listStepParsers } from "@fusion/core";
import type { WorkflowIr, WorkflowIrNode, WorkflowSettingDefinition, TaskStore } from "@fusion/core";
import {
ColumnTraitValidationError,
OccupiedColumnsError,
InvalidRehomeTargetError,
WorkflowCompileError,
WorkflowIrError,
WorkflowSettingRejectionError,
compileWorkflowToSteps,
listTraits,
listStepParsers,
resolveWorkflowIrById,
resolveEffectiveSettingValues,
findOrphanedSettingValues,
isBuiltinWorkflowId,
BUILTIN_WORKFLOW_SETTINGS,
} from "@fusion/core";
import { validateCodeNodeSources } from "@fusion/engine";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import { emitWorkflowSseEvent } from "../sse.js";
@@ -41,6 +56,23 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
}
}
/**
* Resolve the setting DECLARATIONS for a workflow (U6). Mirrors the store's
* private `resolveWorkflowSettingDeclarations`: the resolved IR's `settings`
* when present, else the built-in catalog for built-in ids (the defensive belt
* for graphs that predate the embedded declarations).
*/
async function resolveSettingDeclarations(
store: TaskStore,
workflowId: string,
): Promise<WorkflowSettingDefinition[] | undefined> {
const ir = await resolveWorkflowIrById(store, workflowId);
const declared = ir.version === "v2" ? ir.settings : undefined;
if (declared && declared.length > 0) return declared;
if (isBuiltinWorkflowId(workflowId)) return BUILTIN_WORKFLOW_SETTINGS;
return declared;
}
// GET /api/traits — trait catalog for the node editor's trait picker (U10).
// Returns the registry's listTraits() (built-ins + any registered plugin
// traits): id, name, description, flags, hook descriptors, and config schema.
@@ -217,6 +249,72 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
}
});
// GET /api/workflows/:id/setting-values — read the per-`(workflowId, project)`
// setting values for the workflow node editor's Values tab (U6, R5). Returns
// the raw `stored` map, the `effective` map (stored ?? declaration default,
// drop-on-orphan KTD-6), and the `orphaned` entries (stored values that no
// longer validate against the current declarations) for the disclosure.
router.get("/workflows/:id/setting-values", async (req, res) => {
try {
const { store } = await getProjectContext(req);
const workflowId = req.params.id;
const projectId = store.getWorkflowSettingsProjectId();
const declarations = await resolveSettingDeclarations(store, workflowId);
const stored = store.getWorkflowSettingValues(workflowId, projectId);
res.json({
stored,
effective: resolveEffectiveSettingValues(declarations, stored),
orphaned: findOrphanedSettingValues(declarations, stored),
});
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// PATCH /api/workflows/:id/setting-values — write the per-`(workflowId,
// project)` setting values (U6, R5). Body: { values: Record<string, unknown> }
// where a `null` value deletes that key. The store authority validates the
// patch against the NAMED workflow's declarations; on rejection it throws a
// typed WorkflowSettingRejectionError → 400 carrying the structured
// rejections array so the client renders per-field errors. This write path is
// SEPARATE from the IR save (PATCH /workflows/:id): declarations and values
// are two distinct authorities (KTD-2).
router.patch("/workflows/:id/setting-values", async (req, res) => {
try {
const { store } = await getProjectContext(req);
const workflowId = req.params.id;
const values = (req.body ?? {}).values;
if (!values || typeof values !== "object" || Array.isArray(values)) {
throw badRequest("values is required and must be an object map of setting id → value (null to delete)");
}
const projectId = store.getWorkflowSettingsProjectId();
try {
const stored = await store.updateWorkflowSettingValues(
workflowId,
projectId,
values as Record<string, unknown>,
);
const declarations = await resolveSettingDeclarations(store, workflowId);
res.json({
stored,
effective: resolveEffectiveSettingValues(declarations, stored),
orphaned: findOrphanedSettingValues(declarations, stored),
});
} catch (writeErr: unknown) {
// Typed rejection → 400 with the structured rejections so the client can
// render per-field errors and keep the accepted edits applied.
if (writeErr instanceof WorkflowSettingRejectionError) {
throw badRequest(writeErr.message, { rejections: writeErr.rejections });
}
throw writeErr;
}
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
// GET /api/tasks/:taskId/workflow — current selection for a task.
router.get("/tasks/:taskId/workflow", async (req, res) => {
try {

View File

@@ -190,6 +190,7 @@ const qualityAppComponentTests = [
"TaskIdIntegrityBanner",
"TrackingRepoSelect",
"WorkflowFieldsPanel",
"WorkflowSettingsPanel",
"WorkflowNodeEditor",
"WorkflowResultsTab",
"WorkflowSelector",