feat(dashboard): WorkflowSettingsPanel — definitions/values tabs, setting-value routes, flow-mapping round-trip
This commit is contained in:
@@ -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), {
|
||||
|
||||
@@ -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>
|
||||
|
||||
341
packages/dashboard/app/components/WorkflowSettingsPanel.css
Normal file
341
packages/dashboard/app/components/WorkflowSettingsPanel.css
Normal 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;
|
||||
}
|
||||
862
packages/dashboard/app/components/WorkflowSettingsPanel.tsx
Normal file
862
packages/dashboard/app/components/WorkflowSettingsPanel.tsx
Normal 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;
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -190,6 +190,7 @@ const qualityAppComponentTests = [
|
||||
"TaskIdIntegrityBanner",
|
||||
"TrackingRepoSelect",
|
||||
"WorkflowFieldsPanel",
|
||||
"WorkflowSettingsPanel",
|
||||
"WorkflowNodeEditor",
|
||||
"WorkflowResultsTab",
|
||||
"WorkflowSelector",
|
||||
|
||||
Reference in New Issue
Block a user