refactor(FN-6880): retire the legacy optional-steps declaration surface (U7a)
Optional steps are now graph-native optional-group nodes, so the dead declaration model is removed: the WorkflowOptionalStep type + WorkflowIrV2 .optionalSteps field + validateOptionalSteps (core), and the editor's declaration AUTHORING surface — WorkflowOptionalStepsPanel, optionalStepsOf, and the flowToIr/serializeGraph optionalSteps threading (dashboard). A legacy persisted optionalSteps key is tolerated (ignored) at parse. The per-task TOGGLE surfaces (dropdown, inline card, modal, Workflow tab) are unchanged — they consume ResolvedWorkflowOptionalStep, which stays. The workflow-step seam infrastructure removal remains a separate documented follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/retire-optional-steps-declaration.md
Normal file
5
.changeset/retire-optional-steps-declaration.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Retire the legacy optional-step DECLARATION model now that optional steps are graph-native `optional-group` nodes. Remove the `WorkflowOptionalStep` type and the `WorkflowIrV2.optionalSteps` IR field, drop the workflow node editor's optional-step declaration authoring panel (sidebar section, mobile tab, and collapse state), and stop threading an `optionalSteps` array through `flowToIr`/`serializeGraph`. A legacy persisted `optionalSteps` key on an old v2 workflow row is now tolerated (ignored, not validated) at parse so old rows still load as v2, and the rollback-downgrade heuristic still treats such a row as v2. The per-task optional-step toggle surfaces are unchanged — they continue to list and toggle optional steps sourced from `optional-group` nodes via `resolveWorkflowOptionalSteps` (`ResolvedWorkflowOptionalStep`).
|
||||||
@@ -155,7 +155,12 @@ describe("parseWorkflowIr — v2 columns & placement", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseWorkflowIr — optionalSteps", () => {
|
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||||
|
// The legacy `optionalSteps` declaration field is retired. A legacy persisted
|
||||||
|
// `optionalSteps` key on an old v2 row is now TOLERATED — no longer validated or
|
||||||
|
// required — so old rows still parse as v2 (optional steps are graph-native
|
||||||
|
// `optional-group` nodes now).
|
||||||
|
describe("parseWorkflowIr — legacy optionalSteps tolerated", () => {
|
||||||
const columns = DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] }));
|
const columns = DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] }));
|
||||||
const base = (): WorkflowIrV2 => v2(
|
const base = (): WorkflowIrV2 => v2(
|
||||||
columns,
|
columns,
|
||||||
@@ -166,27 +171,25 @@ describe("parseWorkflowIr — optionalSteps", () => {
|
|||||||
[{ from: "start", to: "end" }],
|
[{ from: "start", to: "end" }],
|
||||||
);
|
);
|
||||||
|
|
||||||
it("parses and serializes optionalSteps deterministically", () => {
|
it("parses a legacy v2 row carrying an optionalSteps key without throwing", () => {
|
||||||
const ir: WorkflowIrV2 = {
|
const ir = {
|
||||||
...base(),
|
...base(),
|
||||||
|
// Legacy declaration shapes — including ones the old validator rejected —
|
||||||
|
// are now ignored, not validated.
|
||||||
optionalSteps: [
|
optionalSteps: [
|
||||||
{ templateId: "browser-verification" },
|
{ templateId: "browser-verification" },
|
||||||
{ templateId: "plugin:example:step", defaultOn: true },
|
{ defaultOn: "yes" },
|
||||||
|
"nope",
|
||||||
],
|
],
|
||||||
};
|
} as unknown as WorkflowIr;
|
||||||
|
|
||||||
|
expect(() => parseWorkflowIr(ir)).not.toThrow();
|
||||||
const parsed = parseWorkflowIr(ir);
|
const parsed = parseWorkflowIr(ir);
|
||||||
expect(parsed).toEqual(ir);
|
expect(parsed.version).toBe("v2");
|
||||||
|
// The key passes through untouched (round-trips through serialize/parse).
|
||||||
expect(JSON.parse(serializeWorkflowIr(parsed))).toEqual(ir);
|
expect(JSON.parse(serializeWorkflowIr(parsed))).toEqual(ir);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects malformed optionalSteps", () => {
|
|
||||||
expect(() => parseWorkflowIr({ ...base(), optionalSteps: "nope" } as unknown as WorkflowIr)).toThrow(WorkflowIrError);
|
|
||||||
expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{}] } as unknown as WorkflowIr)).toThrow(/non-empty templateId/);
|
|
||||||
expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{ templateId: "" }] } as unknown as WorkflowIr)).toThrow(/non-empty templateId/);
|
|
||||||
expect(() => parseWorkflowIr({ ...base(), optionalSteps: [{ templateId: "browser-verification", defaultOn: "yes" }] } as unknown as WorkflowIr)).toThrow(/defaultOn must be a boolean/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("upgrades v1 graphs without optionalSteps", () => {
|
it("upgrades v1 graphs without optionalSteps", () => {
|
||||||
const parsed = parseWorkflowIr({
|
const parsed = parseWorkflowIr({
|
||||||
version: "v1",
|
version: "v1",
|
||||||
@@ -196,7 +199,7 @@ describe("parseWorkflowIr — optionalSteps", () => {
|
|||||||
});
|
});
|
||||||
expect(parsed.version).toBe("v2");
|
expect(parsed.version).toBe("v2");
|
||||||
if (parsed.version !== "v2") throw new Error("expected v2");
|
if (parsed.version !== "v2") throw new Error("expected v2");
|
||||||
expect(parsed.optionalSteps).toBeUndefined();
|
expect((parsed as { optionalSteps?: unknown }).optionalSteps).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ export type {
|
|||||||
WorkflowFieldOption,
|
WorkflowFieldOption,
|
||||||
WorkflowFieldRender,
|
WorkflowFieldRender,
|
||||||
// Workflow-settings (U1): typed setting declaration IR types.
|
// Workflow-settings (U1): typed setting declaration IR types.
|
||||||
WorkflowOptionalStep,
|
|
||||||
WorkflowSettingDefinition,
|
WorkflowSettingDefinition,
|
||||||
WorkflowSettingType,
|
WorkflowSettingType,
|
||||||
WorkflowSettingOption,
|
WorkflowSettingOption,
|
||||||
|
|||||||
@@ -332,13 +332,10 @@ export interface WorkflowIrV1 {
|
|||||||
edges: WorkflowIrEdge[];
|
edges: WorkflowIrEdge[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Workflow-declared optional step backed by a workflow-step template.
|
/*
|
||||||
* Execution-inert: consumed by create/edit UI to seed per-task
|
FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||||
* `enabledWorkflowSteps`, never by the graph executor. Absent on legacy graphs. */
|
Retired the legacy declaration-based optional-steps model. The `WorkflowOptionalStep` interface and the `WorkflowIrV2.optionalSteps` field are removed — optional steps are now graph-native `optional-group` NODES (see `WorkflowOptionalGroupConfig` above), resolved by `resolveWorkflowOptionalSteps`. A legacy persisted `optionalSteps` key on an old v2 row is TOLERATED at parse (ignored, not validated) so old rows still load as v2.
|
||||||
export interface WorkflowOptionalStep {
|
*/
|
||||||
templateId: string;
|
|
||||||
defaultOn?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A v2 workflow IR graph: v1 plus workflow-defined columns and node placement.
|
/** A v2 workflow IR graph: v1 plus workflow-defined columns and node placement.
|
||||||
* Step-inversion adds optional `artifacts` (KTD-12) and `fields` (KTD-13)
|
* Step-inversion adds optional `artifacts` (KTD-12) and `fields` (KTD-13)
|
||||||
@@ -354,9 +351,6 @@ export interface WorkflowIrV2 {
|
|||||||
/** Workflow-settings (U1, R1): typed setting declarations. Additive; absent on
|
/** Workflow-settings (U1, R1): typed setting declarations. Additive; absent on
|
||||||
* legacy graphs. Values persist per-`(workflowId, projectId)` (U2), not here. */
|
* legacy graphs. Values persist per-`(workflowId, projectId)` (U2), not here. */
|
||||||
settings?: WorkflowSettingDefinition[];
|
settings?: WorkflowSettingDefinition[];
|
||||||
/** Optional workflow-step templates tasks may independently enable/disable via
|
|
||||||
* `enabledWorkflowSteps`. Execution-inert; the graph executor ignores this facet. */
|
|
||||||
optionalSteps?: WorkflowOptionalStep[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */
|
/** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import type {
|
|||||||
WorkflowFieldType,
|
WorkflowFieldType,
|
||||||
WorkflowSettingDefinition,
|
WorkflowSettingDefinition,
|
||||||
WorkflowSettingType,
|
WorkflowSettingType,
|
||||||
WorkflowOptionalStep,
|
|
||||||
} from "./workflow-ir-types.js";
|
} from "./workflow-ir-types.js";
|
||||||
import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js";
|
import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js";
|
||||||
import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js";
|
import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js";
|
||||||
@@ -1174,29 +1173,6 @@ function validateSettings(settings: WorkflowSettingDefinition[] | undefined): vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateOptionalSteps(optionalSteps: WorkflowOptionalStep[] | undefined): void {
|
|
||||||
if (optionalSteps === undefined) return;
|
|
||||||
if (!Array.isArray(optionalSteps)) {
|
|
||||||
throw new WorkflowIrError("Workflow IR optionalSteps must be an array");
|
|
||||||
}
|
|
||||||
for (const optionalStep of optionalSteps) {
|
|
||||||
if (!optionalStep || typeof optionalStep !== "object" || Array.isArray(optionalStep)) {
|
|
||||||
throw new WorkflowIrError("Workflow optional step must be an object");
|
|
||||||
}
|
|
||||||
if (typeof optionalStep.templateId !== "string" || optionalStep.templateId === "") {
|
|
||||||
throw new WorkflowIrError("Workflow optional step must have a non-empty templateId");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
optionalStep.defaultOn !== undefined &&
|
|
||||||
typeof optionalStep.defaultOn !== "boolean"
|
|
||||||
) {
|
|
||||||
throw new WorkflowIrError(
|
|
||||||
`Workflow optional step '${optionalStep.templateId}' defaultOn must be a boolean`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateColumns(ir: WorkflowIrV2): void {
|
function validateColumns(ir: WorkflowIrV2): void {
|
||||||
if (!Array.isArray(ir.columns)) {
|
if (!Array.isArray(ir.columns)) {
|
||||||
throw new WorkflowIrError("Workflow IR v2 columns must be an array");
|
throw new WorkflowIrError("Workflow IR v2 columns must be an array");
|
||||||
@@ -1376,7 +1352,11 @@ function validateV2(ir: WorkflowIrV2): void {
|
|||||||
validateNotifyNodes(ir.nodes);
|
validateNotifyNodes(ir.nodes);
|
||||||
validateFields(ir.fields);
|
validateFields(ir.fields);
|
||||||
validateSettings(ir.settings);
|
validateSettings(ir.settings);
|
||||||
validateOptionalSteps(ir.optionalSteps);
|
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||||
|
// The legacy `optionalSteps` declaration field is retired (optional steps are
|
||||||
|
// now graph-native `optional-group` nodes). A legacy persisted `optionalSteps`
|
||||||
|
// key on an old v2 row is TOLERATED — no longer validated/required — so old
|
||||||
|
// rows still parse as v2.
|
||||||
|
|
||||||
// Rework edges are legal intra-template (foreach, KTD-5) and — since U6
|
// Rework edges are legal intra-template (foreach, KTD-5) and — since U6
|
||||||
// generalized the bounded-rework mechanism to the top-level walk — for a
|
// generalized the bounded-rework mechanism to the top-level walk — for a
|
||||||
@@ -1485,12 +1465,17 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step-inversion declarations (artifacts/fields), workflow settings (U1), and
|
// Step-inversion declarations (artifacts/fields), workflow settings (U1), and
|
||||||
// optional workflow-step declarations are v2-only features.
|
// any legacy persisted optional-step declarations are v2-only features.
|
||||||
|
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||||
|
// `optionalSteps` is no longer a typed IR field (retired declaration model), but
|
||||||
|
// a legacy v2 row may still carry the key. Read it via an untyped cast so such a
|
||||||
|
// row is still treated as v2 (kept on v2, never silently downgraded).
|
||||||
|
const legacyOptionalSteps = (ir as { optionalSteps?: unknown[] }).optionalSteps;
|
||||||
if (
|
if (
|
||||||
(ir.artifacts && ir.artifacts.length > 0) ||
|
(ir.artifacts && ir.artifacts.length > 0) ||
|
||||||
(ir.fields && ir.fields.length > 0) ||
|
(ir.fields && ir.fields.length > 0) ||
|
||||||
(ir.settings && ir.settings.length > 0) ||
|
(ir.settings && ir.settings.length > 0) ||
|
||||||
(ir.optionalSteps && ir.optionalSteps.length > 0)
|
(Array.isArray(legacyOptionalSteps) && legacyOptionalSteps.length > 0)
|
||||||
) {
|
) {
|
||||||
return ir;
|
return ir;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface ResolvedWorkflowOptionalStep {
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:WorkflowOptionalGroup 2026-06-21-14:05:
|
FNXC:WorkflowOptionalGroup 2026-06-21-14:05:
|
||||||
Re-pointed the per-task optional-step toggle SOURCE from the execution-inert `ir.optionalSteps` declaration to v2 `optional-group` NODES (one resolved entry per group). The legacy `WorkflowOptionalStep`/`optionalSteps` type stays in place for now — only the resolution + seeding source moved here (U3); the type removal is a later unit (U7).
|
Re-pointed the per-task optional-step toggle SOURCE from the execution-inert `ir.optionalSteps` declaration to v2 `optional-group` NODES (one resolved entry per group). The legacy `WorkflowOptionalStep` type + `optionalSteps` IR field are now REMOVED (FNXC:WorkflowOptionalGroup 2026-06-21-18:00); a legacy persisted `optionalSteps` key on an old v2 row is tolerated/ignored at parse.
|
||||||
KEYING: the resolved entry is keyed by the group node `id`. The output field is still named `templateId` (not renamed) so the four consuming UI surfaces — inline quick-create card, New Task modal/TaskForm, task-detail Workflow tab, and the optional-steps dropdown — keep reading the same shape unchanged; they now toggle group ids into `enabledWorkflowSteps` instead of template ids. Renaming/recreating a group resets per-task state, identical to the prior `templateId` keying.
|
KEYING: the resolved entry is keyed by the group node `id`. The output field is still named `templateId` (not renamed) so the four consuming UI surfaces — inline quick-create card, New Task modal/TaskForm, task-detail Workflow tab, and the optional-steps dropdown — keep reading the same shape unchanged; they now toggle group ids into `enabledWorkflowSteps` instead of template ids. Renaming/recreating a group resets per-task state, identical to the prior `templateId` keying.
|
||||||
Display metadata: `name` comes from `config.name` (falling back to the node id), `defaultOn` from `config.defaultOn ?? false`. The group node carries no description/icon/phase, so `description` is "" and `phase` defaults to "pre-merge" — keeping every field the consumers read populated and non-blank.
|
Display metadata: `name` comes from `config.name` (falling back to the node id), `defaultOn` from `config.defaultOn ?? false`. The group node carries no description/icon/phase, so `description` is "" and `phase` defaults to "pre-merge" — keeping every field the consumers read populated and non-blank.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ToggleRight, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react";
|
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ToggleRight, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react";
|
||||||
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowOptionalStep, WorkflowIrNodeKind } from "@fusion/core";
|
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowIrNodeKind } from "@fusion/core";
|
||||||
import { getErrorMessage } from "@fusion/core";
|
import { getErrorMessage } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
fetchWorkflows,
|
fetchWorkflows,
|
||||||
@@ -66,7 +66,6 @@ import {
|
|||||||
columnsOf,
|
columnsOf,
|
||||||
fieldsOf,
|
fieldsOf,
|
||||||
settingsOf,
|
settingsOf,
|
||||||
optionalStepsOf,
|
|
||||||
columnsToBandNodes,
|
columnsToBandNodes,
|
||||||
reconcileNodeColumns,
|
reconcileNodeColumns,
|
||||||
strictColumnForY,
|
strictColumnForY,
|
||||||
@@ -90,7 +89,6 @@ import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api";
|
|||||||
import { WorkflowColumnPanel } from "./WorkflowColumnPanel";
|
import { WorkflowColumnPanel } from "./WorkflowColumnPanel";
|
||||||
import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
|
import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
|
||||||
import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel";
|
import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel";
|
||||||
import { WorkflowOptionalStepsPanel } from "./WorkflowOptionalStepsPanel";
|
|
||||||
import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api";
|
import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView";
|
import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView";
|
||||||
@@ -102,7 +100,9 @@ import {
|
|||||||
} from "./workflow-mobile-graph";
|
} from "./workflow-mobile-graph";
|
||||||
|
|
||||||
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
|
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
|
||||||
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "optional-steps" | "columns" | "actions";
|
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: dropped the "optional-steps" mobile
|
||||||
|
// panel — the declaration authoring surface is retired (optional-group nodes now).
|
||||||
|
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions";
|
||||||
|
|
||||||
function builtinSeamPrompt(config: Record<string, unknown> | undefined): string {
|
function builtinSeamPrompt(config: Record<string, unknown> | undefined): string {
|
||||||
const seam = typeof config?.seam === "string" ? config.seam : "";
|
const seam = typeof config?.seam === "string" ? config.seam : "";
|
||||||
@@ -169,7 +169,6 @@ function serializeGraph(
|
|||||||
columns: WorkflowIrColumn[],
|
columns: WorkflowIrColumn[],
|
||||||
fields: WorkflowFieldDefinition[],
|
fields: WorkflowFieldDefinition[],
|
||||||
settings: WorkflowSettingDefinition[],
|
settings: WorkflowSettingDefinition[],
|
||||||
optionalSteps: WorkflowOptionalStep[],
|
|
||||||
): string {
|
): string {
|
||||||
const { ir, layout } = flowToIr(
|
const { ir, layout } = flowToIr(
|
||||||
name,
|
name,
|
||||||
@@ -178,7 +177,6 @@ function serializeGraph(
|
|||||||
columns.length ? columns : undefined,
|
columns.length ? columns : undefined,
|
||||||
fields.length ? fields : undefined,
|
fields.length ? fields : undefined,
|
||||||
settings.length ? settings : undefined,
|
settings.length ? settings : undefined,
|
||||||
optionalSteps.length ? optionalSteps : undefined,
|
|
||||||
);
|
);
|
||||||
return JSON.stringify({ name, description, ir, layout });
|
return JSON.stringify({ name, description, ir, layout });
|
||||||
}
|
}
|
||||||
@@ -749,7 +747,10 @@ function InnerEditor({
|
|||||||
// VALUES live per-project in the workflow_settings table (KTD-2) and are
|
// VALUES live per-project in the workflow_settings table (KTD-2) and are
|
||||||
// managed by the panel's Values tab, not this declaration array.
|
// managed by the panel's Values tab, not this declaration array.
|
||||||
const [settings, setSettings] = useState<WorkflowSettingDefinition[]>([]);
|
const [settings, setSettings] = useState<WorkflowSettingDefinition[]>([]);
|
||||||
const [optionalSteps, setOptionalSteps] = useState<WorkflowOptionalStep[]>([]);
|
/* FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||||
|
The legacy optional-step DECLARATION authoring state/panel is removed. Optional
|
||||||
|
steps are graph-native `optional-group` nodes authored through the canvas; the
|
||||||
|
editor no longer carries a separate `optionalSteps` declaration array. */
|
||||||
// Ref to the settings panel so a `?panel=settings` deep link can scroll it
|
// Ref to the settings panel so a `?panel=settings` deep link can scroll it
|
||||||
// into view on mount (U6/U9 redirect stubs).
|
// into view on mount (U6/U9 redirect stubs).
|
||||||
const settingsPanelRef = useRef<HTMLDivElement | null>(null);
|
const settingsPanelRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -788,7 +789,6 @@ function InnerEditor({
|
|||||||
const columnsCollapsedStorageKey = "fusion:wf-sidebar-columns-collapsed";
|
const columnsCollapsedStorageKey = "fusion:wf-sidebar-columns-collapsed";
|
||||||
const fieldsCollapsedStorageKey = "fusion:wf-sidebar-fields-collapsed";
|
const fieldsCollapsedStorageKey = "fusion:wf-sidebar-fields-collapsed";
|
||||||
const settingsCollapsedStorageKey = "fusion:wf-sidebar-settings-collapsed";
|
const settingsCollapsedStorageKey = "fusion:wf-sidebar-settings-collapsed";
|
||||||
const optionalStepsCollapsedStorageKey = "fusion:wf-sidebar-optional-steps-collapsed";
|
|
||||||
const [columnsCollapsed, setColumnsCollapsed] = useState<boolean>(() => {
|
const [columnsCollapsed, setColumnsCollapsed] = useState<boolean>(() => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(columnsCollapsedStorageKey) === "1";
|
return localStorage.getItem(columnsCollapsedStorageKey) === "1";
|
||||||
@@ -810,13 +810,6 @@ function InnerEditor({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const [optionalStepsCollapsed, setOptionalStepsCollapsed] = useState<boolean>(() => {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(optionalStepsCollapsedStorageKey) === "1";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(columnsCollapsedStorageKey, columnsCollapsed ? "1" : "0");
|
localStorage.setItem(columnsCollapsedStorageKey, columnsCollapsed ? "1" : "0");
|
||||||
@@ -838,13 +831,6 @@ function InnerEditor({
|
|||||||
// localStorage unavailable (private mode / SSR): non-fatal.
|
// localStorage unavailable (private mode / SSR): non-fatal.
|
||||||
}
|
}
|
||||||
}, [settingsCollapsed]);
|
}, [settingsCollapsed]);
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(optionalStepsCollapsedStorageKey, optionalStepsCollapsed ? "1" : "0");
|
|
||||||
} catch {
|
|
||||||
// localStorage unavailable (private mode / SSR): non-fatal.
|
|
||||||
}
|
|
||||||
}, [optionalStepsCollapsed]);
|
|
||||||
// React Flow instance for programmatic viewport control (auto-layout on load).
|
// React Flow instance for programmatic viewport control (auto-layout on load).
|
||||||
const { setViewport } = useReactFlow();
|
const { setViewport } = useReactFlow();
|
||||||
// Wrapper around <ReactFlow> so keyboard deletion can return focus to the
|
// Wrapper around <ReactFlow> so keyboard deletion can return focus to the
|
||||||
@@ -1034,10 +1020,10 @@ function InnerEditor({
|
|||||||
if (isBuiltin) return false;
|
if (isBuiltin) return false;
|
||||||
if (!activeWorkflow || loadedSnapshotRef.current === null) return false;
|
if (!activeWorkflow || loadedSnapshotRef.current === null) return false;
|
||||||
return (
|
return (
|
||||||
serializeGraph(name, description, nodes, edges, columns, fields, settings, optionalSteps) !==
|
serializeGraph(name, description, nodes, edges, columns, fields, settings) !==
|
||||||
loadedSnapshotRef.current
|
loadedSnapshotRef.current
|
||||||
);
|
);
|
||||||
}, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings, optionalSteps]);
|
}, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings]);
|
||||||
|
|
||||||
const loadWorkflows = useCallback(async () => {
|
const loadWorkflows = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -1185,7 +1171,6 @@ function InnerEditor({
|
|||||||
setColumns([]);
|
setColumns([]);
|
||||||
setFields([]);
|
setFields([]);
|
||||||
setSettings([]);
|
setSettings([]);
|
||||||
setOptionalSteps([]);
|
|
||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
loadedSnapshotRef.current = null;
|
loadedSnapshotRef.current = null;
|
||||||
@@ -1195,7 +1180,6 @@ function InnerEditor({
|
|||||||
const loadedColumns = columnsOf(activeWorkflow);
|
const loadedColumns = columnsOf(activeWorkflow);
|
||||||
const loadedFields = fieldsOf(activeWorkflow);
|
const loadedFields = fieldsOf(activeWorkflow);
|
||||||
const loadedSettings = settingsOf(activeWorkflow);
|
const loadedSettings = settingsOf(activeWorkflow);
|
||||||
const loadedOptionalSteps = optionalStepsOf(activeWorkflow);
|
|
||||||
// Auto-layout on load: compute tidy positions and apply them before the
|
// Auto-layout on load: compute tidy positions and apply them before the
|
||||||
// first render so nodes are visible in the top-left viewport.
|
// first render so nodes are visible in the top-left viewport.
|
||||||
const layoutPositions = autoLayout(flow.nodes, flow.edges, loadedColumns);
|
const layoutPositions = autoLayout(flow.nodes, flow.edges, loadedColumns);
|
||||||
@@ -1205,7 +1189,6 @@ function InnerEditor({
|
|||||||
setColumns(loadedColumns);
|
setColumns(loadedColumns);
|
||||||
setFields(loadedFields);
|
setFields(loadedFields);
|
||||||
setSettings(loadedSettings);
|
setSettings(loadedSettings);
|
||||||
setOptionalSteps(loadedOptionalSteps);
|
|
||||||
setName(activeWorkflow.name);
|
setName(activeWorkflow.name);
|
||||||
setDescription(activeWorkflow.description ?? "");
|
setDescription(activeWorkflow.description ?? "");
|
||||||
setEditingName(false);
|
setEditingName(false);
|
||||||
@@ -1220,7 +1203,6 @@ function InnerEditor({
|
|||||||
loadedColumns,
|
loadedColumns,
|
||||||
loadedFields,
|
loadedFields,
|
||||||
loadedSettings,
|
loadedSettings,
|
||||||
loadedOptionalSteps,
|
|
||||||
);
|
);
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
@@ -1555,11 +1537,12 @@ function InnerEditor({
|
|||||||
setEdges(flow.edges);
|
setEdges(flow.edges);
|
||||||
setColumns(columnsOf({ ...targetWorkflow, ir: result.ir }));
|
setColumns(columnsOf({ ...targetWorkflow, ir: result.ir }));
|
||||||
setFields(fieldsOf({ ...targetWorkflow, ir: result.ir }));
|
setFields(fieldsOf({ ...targetWorkflow, ir: result.ir }));
|
||||||
// Hydrate settings + optionalSteps on the fragment/generate path too — it
|
// Hydrate settings on the fragment/generate path too — it previously dropped
|
||||||
// previously dropped both, which silently lost the declarations on the next
|
// them, which silently lost the declarations on the next save (the round-trip
|
||||||
// save (the round-trip data loss U2 fixes for the primary load path).
|
// data loss U2 fixes for the primary load path). Optional steps need no
|
||||||
|
// separate hydration: they are graph-native `optional-group` nodes carried by
|
||||||
|
// the node/edge mapping above (FNXC:WorkflowOptionalGroup 2026-06-21-18:00).
|
||||||
setSettings(settingsOf({ ...targetWorkflow, ir: result.ir }));
|
setSettings(settingsOf({ ...targetWorkflow, ir: result.ir }));
|
||||||
setOptionalSteps(optionalStepsOf({ ...targetWorkflow, ir: result.ir }));
|
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
setValidationError(null);
|
setValidationError(null);
|
||||||
@@ -1871,7 +1854,6 @@ function InnerEditor({
|
|||||||
columns.length ? columns : undefined,
|
columns.length ? columns : undefined,
|
||||||
fields.length ? fields : undefined,
|
fields.length ? fields : undefined,
|
||||||
settings.length ? settings : undefined,
|
settings.length ? settings : undefined,
|
||||||
optionalSteps.length ? optionalSteps : undefined,
|
|
||||||
);
|
);
|
||||||
// Include name/description in the PATCH only when they changed from the
|
// Include name/description in the PATCH only when they changed from the
|
||||||
// loaded workflow (KTD-10 inline rename/description persist here).
|
// loaded workflow (KTD-10 inline rename/description persist here).
|
||||||
@@ -1889,7 +1871,6 @@ function InnerEditor({
|
|||||||
columns,
|
columns,
|
||||||
fields,
|
fields,
|
||||||
settings,
|
settings,
|
||||||
optionalSteps,
|
|
||||||
);
|
);
|
||||||
setName(updated.name);
|
setName(updated.name);
|
||||||
setDescription(updated.description ?? "");
|
setDescription(updated.description ?? "");
|
||||||
@@ -1959,7 +1940,7 @@ function InnerEditor({
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, optionalSteps, unplaced, blockingViolationCount, projectId, addToast, t]);
|
}, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, unplaced, blockingViolationCount, projectId, addToast, t]);
|
||||||
|
|
||||||
// Stamp the shared error-state badge onto offending nodes: unplaced step
|
// Stamp the shared error-state badge onto offending nodes: unplaced step
|
||||||
// nodes and any node the server flagged (seam-in-branch). One component
|
// nodes and any node the server flagged (seam-in-branch). One component
|
||||||
@@ -2527,26 +2508,9 @@ function InnerEditor({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="wf-sidebar-section" data-testid="wf-sidebar-optional-steps-section">
|
{/* FNXC:WorkflowOptionalGroup 2026-06-21-18:00: The optional-step
|
||||||
<button
|
DECLARATION authoring sidebar section is removed. Optional steps
|
||||||
type="button"
|
are authored as graph-native `optional-group` nodes on the canvas. */}
|
||||||
className="wf-sidebar-section-toggle"
|
|
||||||
aria-expanded={!optionalStepsCollapsed}
|
|
||||||
data-testid="wf-sidebar-optional-steps-toggle"
|
|
||||||
onClick={() => setOptionalStepsCollapsed((c) => !c)}
|
|
||||||
>
|
|
||||||
{optionalStepsCollapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
|
|
||||||
<span>{t("workflowOptionalSteps.title", "Optional steps")}</span>
|
|
||||||
</button>
|
|
||||||
{!optionalStepsCollapsed && (
|
|
||||||
<WorkflowOptionalStepsPanel
|
|
||||||
optionalSteps={optionalSteps}
|
|
||||||
onChange={setOptionalSteps}
|
|
||||||
readOnly={isBuiltin}
|
|
||||||
pluginTemplates={pluginTemplates.map((p) => p.template)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
@@ -2669,7 +2633,6 @@ function InnerEditor({
|
|||||||
["add", t("workflowNodes.mobileAdd", "Add")],
|
["add", t("workflowNodes.mobileAdd", "Add")],
|
||||||
["settings", t("workflowSettings.title", "Settings")],
|
["settings", t("workflowSettings.title", "Settings")],
|
||||||
["fields", t("workflowFields.title", "Fields")],
|
["fields", t("workflowFields.title", "Fields")],
|
||||||
["optional-steps", t("workflowOptionalSteps.title", "Optional steps")],
|
|
||||||
["columns", t("workflowColumns.title", "Columns")],
|
["columns", t("workflowColumns.title", "Columns")],
|
||||||
["actions", t("workflowNodes.mobileActions", "Actions")],
|
["actions", t("workflowNodes.mobileActions", "Actions")],
|
||||||
] as Array<[MobileWorkflowPanel, string]>).map(([panel, label]) => (
|
] as Array<[MobileWorkflowPanel, string]>).map(([panel, label]) => (
|
||||||
@@ -2866,16 +2829,6 @@ function InnerEditor({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mobilePanel === "optional-steps" && (
|
|
||||||
<div className="wf-mobile-destination">
|
|
||||||
<WorkflowOptionalStepsPanel
|
|
||||||
optionalSteps={optionalSteps}
|
|
||||||
onChange={setOptionalSteps}
|
|
||||||
readOnly={isBuiltin}
|
|
||||||
pluginTemplates={pluginTemplates.map((p) => p.template)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mobilePanel === "columns" && (
|
{mobilePanel === "columns" && (
|
||||||
<div className="wf-mobile-destination">
|
<div className="wf-mobile-destination">
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
/* WorkflowOptionalStepsPanel — sibling of WorkflowFieldsPanel; mirrors its layout
|
|
||||||
* so the optional-steps panel reads consistently alongside Fields/Settings. */
|
|
||||||
|
|
||||||
.wf-optional-steps-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
padding: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-optional-steps-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-optional-steps-hint,
|
|
||||||
.wf-optional-steps-empty {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-optional-steps-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-optional-step-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: var(--space-sm);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm, 6px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-optional-step-item.is-unknown {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-optional-step-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-optional-step-title {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-optional-step-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-optional-step-name--unknown {
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-optional-step-description {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-optional-step-default {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-optional-step-remove {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-optional-step-remove:hover:not(:disabled) {
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-optional-steps-add {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wf-optional-steps-add-label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
/**
|
|
||||||
* FNXC:WorkflowOptionalSteps 2026-06-21-00:00:
|
|
||||||
* Workflow authors need to declare which step templates are optional and set each
|
|
||||||
* one's defaultOn from the visual editor (persisted on the IR's `optionalSteps`
|
|
||||||
* array) so optional steps are authorable without hand-editing IR.
|
|
||||||
*
|
|
||||||
* WorkflowOptionalStepsPanel — the workflow editor's optional-step authoring
|
|
||||||
* surface. Sibling to {@link WorkflowFieldsPanel} / WorkflowSettingsPanel: lives
|
|
||||||
* alongside the canvas in {@link WorkflowNodeEditor} and mutates the IR's
|
|
||||||
* `optionalSteps` array through the same state/save flow (preserved across the
|
|
||||||
* round-trip by `flowToIr`).
|
|
||||||
*
|
|
||||||
* A declaration is just `{ templateId, defaultOn? }`. Display metadata
|
|
||||||
* (name/description/phase) is resolved from the built-in step-template catalog at
|
|
||||||
* render time — never duplicated into the IR — so the resolver stays the single
|
|
||||||
* source of truth. Unknown/stale template ids render a muted, still-removable row
|
|
||||||
* rather than being silently dropped.
|
|
||||||
*/
|
|
||||||
import { useCallback, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
|
||||||
import { WORKFLOW_STEP_TEMPLATES, type WorkflowOptionalStep, type WorkflowStepTemplate } from "@fusion/core";
|
|
||||||
import { phaseBadge } from "./workflow-phase-badge";
|
|
||||||
import "./WorkflowOptionalStepsPanel.css";
|
|
||||||
|
|
||||||
interface WorkflowOptionalStepsPanelProps {
|
|
||||||
optionalSteps: WorkflowOptionalStep[];
|
|
||||||
onChange: (next: WorkflowOptionalStep[]) => void;
|
|
||||||
readOnly: boolean;
|
|
||||||
/** Plugin-contributed templates, merged into the catalog when available. */
|
|
||||||
pluginTemplates?: WorkflowStepTemplate[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkflowOptionalStepsPanel({
|
|
||||||
optionalSteps,
|
|
||||||
onChange,
|
|
||||||
readOnly,
|
|
||||||
pluginTemplates = [],
|
|
||||||
}: WorkflowOptionalStepsPanelProps) {
|
|
||||||
const { t } = useTranslation("app");
|
|
||||||
|
|
||||||
const templatesById = useMemo(() => {
|
|
||||||
const map = new Map<string, WorkflowStepTemplate>();
|
|
||||||
for (const tpl of [...WORKFLOW_STEP_TEMPLATES, ...pluginTemplates]) map.set(tpl.id, tpl);
|
|
||||||
return map;
|
|
||||||
}, [pluginTemplates]);
|
|
||||||
|
|
||||||
const declaredIds = useMemo(() => new Set(optionalSteps.map((s) => s.templateId)), [optionalSteps]);
|
|
||||||
|
|
||||||
// Catalog entries not already declared — the "Add optional step" picker source.
|
|
||||||
const available = useMemo(
|
|
||||||
() => [...templatesById.values()].filter((tpl) => !declaredIds.has(tpl.id)),
|
|
||||||
[templatesById, declaredIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const addStep = useCallback(
|
|
||||||
(templateId: string) => {
|
|
||||||
if (!templateId || declaredIds.has(templateId)) return;
|
|
||||||
onChange([...optionalSteps, { templateId, defaultOn: false }]);
|
|
||||||
},
|
|
||||||
[optionalSteps, onChange, declaredIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeStep = useCallback(
|
|
||||||
(templateId: string) => onChange(optionalSteps.filter((s) => s.templateId !== templateId)),
|
|
||||||
[optionalSteps, onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleDefaultOn = useCallback(
|
|
||||||
(templateId: string, defaultOn: boolean) =>
|
|
||||||
onChange(optionalSteps.map((s) => (s.templateId === templateId ? { ...s, defaultOn } : s))),
|
|
||||||
[optionalSteps, onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className="wf-optional-steps-panel" data-testid="wf-optional-steps-panel">
|
|
||||||
<header className="wf-optional-steps-header">
|
|
||||||
<h3>{t("workflowOptionalSteps.title", "Optional steps")}</h3>
|
|
||||||
<p className="wf-optional-steps-hint">
|
|
||||||
{t(
|
|
||||||
"workflowOptionalSteps.hint",
|
|
||||||
"Steps a task can toggle on or off. Default sets the initial state for new tasks.",
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{optionalSteps.length === 0 ? (
|
|
||||||
<p className="wf-optional-steps-empty">
|
|
||||||
{t("workflowOptionalSteps.empty", "No optional steps. Add one to let tasks opt in or out.")}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="wf-optional-steps-list">
|
|
||||||
{optionalSteps.map((step) => {
|
|
||||||
const tpl = templatesById.get(step.templateId);
|
|
||||||
const defaultOn = step.defaultOn ?? tpl?.defaultOn ?? false;
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={step.templateId}
|
|
||||||
className={`wf-optional-step-item${tpl ? "" : " is-unknown"}`}
|
|
||||||
data-testid={`wf-optional-step-${step.templateId}`}
|
|
||||||
>
|
|
||||||
<div className="wf-optional-step-head">
|
|
||||||
<div className="wf-optional-step-title">
|
|
||||||
{tpl ? (
|
|
||||||
<>
|
|
||||||
<span className="wf-optional-step-name">{tpl.name}</span>
|
|
||||||
{phaseBadge(tpl.phase ?? "pre-merge", step.templateId, "wf-optional-step-phase", t)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="wf-optional-step-name wf-optional-step-name--unknown">
|
|
||||||
{t("workflowOptionalSteps.unknown", "Unknown step ({{id}})", { id: step.templateId })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="wf-optional-step-remove"
|
|
||||||
aria-label={t("workflowOptionalSteps.remove", "Remove optional step")}
|
|
||||||
disabled={readOnly}
|
|
||||||
onClick={() => removeStep(step.templateId)}
|
|
||||||
>
|
|
||||||
<Trash2 size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{tpl?.description && (
|
|
||||||
<p className="wf-optional-step-description">{tpl.description}</p>
|
|
||||||
)}
|
|
||||||
<label className="wf-optional-step-default">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={defaultOn}
|
|
||||||
disabled={readOnly}
|
|
||||||
aria-label={t("workflowOptionalSteps.defaultOnFor", "Default on for {{name}}", {
|
|
||||||
name: tpl?.name ?? step.templateId,
|
|
||||||
})}
|
|
||||||
onChange={(e) => toggleDefaultOn(step.templateId, e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>{t("workflowOptionalSteps.defaultOn", "Default on")}</span>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{available.length > 0 && (
|
|
||||||
<div className="wf-optional-steps-add">
|
|
||||||
{/* Picker resets to placeholder after each add (value stays ""). */}
|
|
||||||
<label className="wf-optional-steps-add-label" htmlFor="wf-optional-steps-add-select">
|
|
||||||
<Plus size={13} /> {t("workflowOptionalSteps.add", "Add optional step")}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="wf-optional-steps-add-select"
|
|
||||||
data-testid="wf-optional-steps-add-select"
|
|
||||||
value=""
|
|
||||||
disabled={readOnly}
|
|
||||||
onChange={(e) => {
|
|
||||||
addStep(e.target.value);
|
|
||||||
e.target.value = "";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
{t("workflowOptionalSteps.addPlaceholder", "Select a step…")}
|
|
||||||
</option>
|
|
||||||
{available.map((tpl) => (
|
|
||||||
<option key={tpl.id} value={tpl.id}>
|
|
||||||
{tpl.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WorkflowOptionalStepsPanel;
|
|
||||||
@@ -164,13 +164,9 @@ function v2Def(): WorkflowDefinition {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function v2DefWithOptional(): WorkflowDefinition {
|
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00: `v2DefWithOptional` and its
|
||||||
const base = v2Def();
|
// optional-step DECLARATION hydration/save test are removed — the declaration
|
||||||
return {
|
// authoring panel is retired (optional-group nodes now).
|
||||||
...base,
|
|
||||||
ir: { ...(base.ir as object), optionalSteps: [{ templateId: "browser-verification" }] } as WorkflowDefinition["ir"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function builtinDef(): WorkflowDefinition {
|
function builtinDef(): WorkflowDefinition {
|
||||||
return {
|
return {
|
||||||
@@ -751,34 +747,6 @@ describe("WorkflowNodeEditor", () => {
|
|||||||
expect(start?.column).toBe("done");
|
expect(start?.column).toBe("done");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hydrates declared optional steps and preserves them through a dirty save (round-trip)", async () => {
|
|
||||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2DefWithOptional()]);
|
|
||||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({
|
|
||||||
...v2DefWithOptional(),
|
|
||||||
...(updates as object),
|
|
||||||
}));
|
|
||||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
|
||||||
|
|
||||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
|
||||||
|
|
||||||
await screen.findByText("Save");
|
|
||||||
// The declared optional step is hydrated into the panel (optionalStepsOf).
|
|
||||||
const row = await screen.findByTestId("wf-optional-step-browser-verification");
|
|
||||||
expect(within(row).getByText("Browser Verification")).toBeTruthy();
|
|
||||||
|
|
||||||
// Toggling defaultOn must mark the editor dirty (serializeGraph threading) so
|
|
||||||
// the Save button enables and persists the change.
|
|
||||||
fireEvent.click(within(row).getByRole("checkbox"));
|
|
||||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
|
||||||
|
|
||||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
|
||||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
|
||||||
const ir = (updates as { ir: WorkflowDefinition["ir"] }).ir as {
|
|
||||||
optionalSteps?: { templateId: string; defaultOn?: boolean }[];
|
|
||||||
};
|
|
||||||
expect(ir.optionalSteps).toEqual([{ templateId: "browser-verification", defaultOn: true }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the start inspector without the entry-column select for v1 workflows", async () => {
|
it("renders the start inspector without the entry-column select for v1 workflows", async () => {
|
||||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
||||||
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import type { WorkflowOptionalStep } from "@fusion/core";
|
|
||||||
import { WorkflowOptionalStepsPanel } from "../WorkflowOptionalStepsPanel";
|
|
||||||
|
|
||||||
// Controlled host mirroring how WorkflowNodeEditor drives the panel.
|
|
||||||
function Host({
|
|
||||||
initial,
|
|
||||||
readOnly = false,
|
|
||||||
onState,
|
|
||||||
}: {
|
|
||||||
initial: WorkflowOptionalStep[];
|
|
||||||
readOnly?: boolean;
|
|
||||||
onState?: (s: WorkflowOptionalStep[]) => void;
|
|
||||||
}) {
|
|
||||||
const [optionalSteps, setOptionalSteps] = useState<WorkflowOptionalStep[]>(initial);
|
|
||||||
return (
|
|
||||||
<WorkflowOptionalStepsPanel
|
|
||||||
optionalSteps={optionalSteps}
|
|
||||||
readOnly={readOnly}
|
|
||||||
onChange={(next) => {
|
|
||||||
setOptionalSteps(next);
|
|
||||||
onState?.(next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("WorkflowOptionalStepsPanel", () => {
|
|
||||||
it("renders the empty state and an add picker when no steps are declared", () => {
|
|
||||||
render(<Host initial={[]} />);
|
|
||||||
expect(screen.getByText(/No optional steps/i)).toBeTruthy();
|
|
||||||
const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement;
|
|
||||||
// browser-verification is in the catalog and not yet declared → available.
|
|
||||||
expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds a step from the picker (defaultOn false) and removes it from the picker", () => {
|
|
||||||
const onState = vi.fn();
|
|
||||||
render(<Host initial={[]} onState={onState} />);
|
|
||||||
fireEvent.change(screen.getByTestId("wf-optional-steps-add-select"), {
|
|
||||||
target: { value: "browser-verification" },
|
|
||||||
});
|
|
||||||
expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: false }]);
|
|
||||||
// The declared row is shown with the resolved template name…
|
|
||||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
|
||||||
expect(within(row).getByText("Browser Verification")).toBeTruthy();
|
|
||||||
// …and the picker no longer offers it.
|
|
||||||
const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement;
|
|
||||||
expect(within(select).queryByRole("option", { name: "Browser Verification" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("toggles defaultOn for a declared step", () => {
|
|
||||||
const onState = vi.fn();
|
|
||||||
render(<Host initial={[{ templateId: "browser-verification", defaultOn: false }]} onState={onState} />);
|
|
||||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
|
||||||
fireEvent.click(within(row).getByRole("checkbox"));
|
|
||||||
expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: true }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes a declared step and returns it to the picker", () => {
|
|
||||||
render(<Host initial={[{ templateId: "browser-verification" }]} />);
|
|
||||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
|
||||||
fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i }));
|
|
||||||
expect(screen.queryByTestId("wf-optional-step-browser-verification")).toBeNull();
|
|
||||||
const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement;
|
|
||||||
expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders an unknown/stale templateId as a muted, still-removable row", () => {
|
|
||||||
const onState = vi.fn();
|
|
||||||
render(<Host initial={[{ templateId: "does-not-exist" }]} onState={onState} />);
|
|
||||||
const row = screen.getByTestId("wf-optional-step-does-not-exist");
|
|
||||||
expect(row.className).toContain("is-unknown");
|
|
||||||
expect(within(row).getByText(/Unknown step/i)).toBeTruthy();
|
|
||||||
fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i }));
|
|
||||||
expect(onState).toHaveBeenCalledWith([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disables editing when readOnly", () => {
|
|
||||||
render(<Host initial={[{ templateId: "browser-verification" }]} readOnly />);
|
|
||||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
|
||||||
expect((within(row).getByRole("checkbox") as HTMLInputElement).disabled).toBe(true);
|
|
||||||
expect((within(row).getByRole("button", { name: /Remove optional step/i }) as HTMLButtonElement).disabled).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
fragmentSeamConflicts,
|
fragmentSeamConflicts,
|
||||||
copyIrWithFreshIds,
|
copyIrWithFreshIds,
|
||||||
columnsOf,
|
columnsOf,
|
||||||
optionalStepsOf,
|
|
||||||
columnForY,
|
columnForY,
|
||||||
bandTop,
|
bandTop,
|
||||||
columnsToBandNodes,
|
columnsToBandNodes,
|
||||||
@@ -1636,58 +1635,12 @@ describe("copyIrWithFreshIds", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("optionalSteps round-trip (U2)", () => {
|
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||||
const v2WithOptional = (optionalSteps?: { templateId: string; defaultOn?: boolean }[]) =>
|
// The legacy optional-step DECLARATION authoring surface is retired: `optionalStepsOf`
|
||||||
makeDef(
|
// is removed and `flowToIr` no longer accepts/emits an `optionalSteps` array. Optional
|
||||||
parseWorkflowIr({
|
// steps are graph-native `optional-group` nodes carried by the normal node/edge mapping.
|
||||||
version: "v2",
|
describe("optionalSteps declaration authoring removed (U7)", () => {
|
||||||
name: "wf-opt",
|
it("flowToIr never emits a legacy optionalSteps key", () => {
|
||||||
columns: [
|
|
||||||
{ id: "triage", name: "Triage", traits: [] },
|
|
||||||
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
|
||||||
],
|
|
||||||
nodes: [
|
|
||||||
{ id: "start", kind: "start", column: "triage" },
|
|
||||||
{ id: "end", kind: "end", column: "done" },
|
|
||||||
],
|
|
||||||
edges: [{ from: "start", to: "end" }],
|
|
||||||
...(optionalSteps ? { optionalSteps } : {}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
it("optionalStepsOf reads declarations from a v2 IR and returns a copy", () => {
|
|
||||||
const def = v2WithOptional([{ templateId: "browser-verification", defaultOn: true }]);
|
|
||||||
const read = optionalStepsOf(def);
|
|
||||||
expect(read).toEqual([{ templateId: "browser-verification", defaultOn: true }]);
|
|
||||||
// mutating the result does not mutate the source IR
|
|
||||||
read[0].defaultOn = false;
|
|
||||||
expect(optionalStepsOf(def)).toEqual([{ templateId: "browser-verification", defaultOn: true }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("optionalStepsOf returns [] for v1 and for v2 without optionalSteps", () => {
|
|
||||||
const v1 = makeDef({
|
|
||||||
version: "v1",
|
|
||||||
name: "legacy",
|
|
||||||
nodes: [
|
|
||||||
{ id: "start", kind: "start" },
|
|
||||||
{ id: "end", kind: "end" },
|
|
||||||
],
|
|
||||||
edges: [{ from: "start", to: "end" }],
|
|
||||||
});
|
|
||||||
expect(optionalStepsOf(v1)).toEqual([]);
|
|
||||||
expect(optionalStepsOf(v2WithOptional())).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flowToIr preserves optionalSteps across a full irToFlow round-trip", () => {
|
|
||||||
const def = v2WithOptional([{ templateId: "browser-verification", defaultOn: true }]);
|
|
||||||
const { nodes, edges } = irToFlow(def);
|
|
||||||
const { ir: out } = flowToIr("wf-opt", nodes, edges, columnsOf(def), [], [], optionalStepsOf(def));
|
|
||||||
expect((out as { optionalSteps?: unknown }).optionalSteps).toEqual([
|
|
||||||
{ templateId: "browser-verification", defaultOn: true },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes as v2 when optionalSteps present but no custom columns/fields/settings", () => {
|
|
||||||
const { ir: out } = flowToIr(
|
const { ir: out } = flowToIr(
|
||||||
"opt-only",
|
"opt-only",
|
||||||
[
|
[
|
||||||
@@ -1698,21 +1651,7 @@ describe("optionalSteps round-trip (U2)", () => {
|
|||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
[],
|
[],
|
||||||
[{ templateId: "browser-verification" }],
|
|
||||||
);
|
);
|
||||||
expect(out.version).toBe("v2");
|
|
||||||
expect((out as { optionalSteps?: unknown }).optionalSteps).toEqual([
|
|
||||||
{ templateId: "browser-verification" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits the optionalSteps key entirely when empty (R6 byte-identity)", () => {
|
|
||||||
const def = v2WithOptional();
|
|
||||||
const { nodes, edges } = irToFlow(def);
|
|
||||||
const { ir: out } = flowToIr("wf-opt", nodes, edges, columnsOf(def), [], [], []);
|
|
||||||
expect("optionalSteps" in out).toBe(false);
|
expect("optionalSteps" in out).toBe(false);
|
||||||
// and with the arg omitted entirely
|
|
||||||
const { ir: out2 } = flowToIr("wf-opt", nodes, edges, columnsOf(def));
|
|
||||||
expect("optionalSteps" in out2).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type {
|
|||||||
WorkflowDefinition,
|
WorkflowDefinition,
|
||||||
WorkflowFieldDefinition,
|
WorkflowFieldDefinition,
|
||||||
WorkflowSettingDefinition,
|
WorkflowSettingDefinition,
|
||||||
WorkflowOptionalStep,
|
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
|
import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
|
||||||
|
|
||||||
@@ -453,7 +452,6 @@ export function flowToIr(
|
|||||||
columns?: WorkflowIrColumn[],
|
columns?: WorkflowIrColumn[],
|
||||||
fields?: WorkflowFieldDefinition[],
|
fields?: WorkflowFieldDefinition[],
|
||||||
settings?: WorkflowSettingDefinition[],
|
settings?: WorkflowSettingDefinition[],
|
||||||
optionalSteps?: WorkflowOptionalStep[],
|
|
||||||
): { ir: WorkflowIr; layout: Record<string, { x: number; y: number }> } {
|
): { ir: WorkflowIr; layout: Record<string, { x: number; y: number }> } {
|
||||||
const realNodes = nodes.filter((n) => !isColumnBandNode(n.id));
|
const realNodes = nodes.filter((n) => !isColumnBandNode(n.id));
|
||||||
// Partition by parentId: foreach group children reassemble into that group's
|
// Partition by parentId: foreach group children reassemble into that group's
|
||||||
@@ -475,15 +473,14 @@ export function flowToIr(
|
|||||||
);
|
);
|
||||||
const hasFields = Array.isArray(fields) && fields.length > 0;
|
const hasFields = Array.isArray(fields) && fields.length > 0;
|
||||||
const hasSettings = Array.isArray(settings) && settings.length > 0;
|
const hasSettings = Array.isArray(settings) && settings.length > 0;
|
||||||
const hasOptionalSteps = Array.isArray(optionalSteps) && optionalSteps.length > 0;
|
// FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||||
// FNXC:WorkflowOptionalSteps 2026-06-21-00:00:
|
// The editor no longer AUTHORS legacy `optionalSteps` declarations — optional
|
||||||
// Optional steps must round-trip through the node editor without data loss, yet
|
// steps are graph-native `optional-group` nodes carried through the normal
|
||||||
// must never upgrade a legacy v1 graph. Fields, settings, and optional steps are
|
// node/edge mapping. Fields and settings remain v2-only declarations: a workflow
|
||||||
// v2-only declarations: a workflow with any of them but no custom columns still
|
// with either but no custom columns still serializes as v2 (with the synthesized
|
||||||
// serializes as v2 (with the synthesized default columns). Empty/absent → not a
|
// default columns). Empty/absent → not a v2 signal (R6 byte-identity for legacy).
|
||||||
// v2 signal, and the key is omitted entirely (R6 byte-identity for legacy graphs).
|
|
||||||
const v2 =
|
const v2 =
|
||||||
(Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings || hasOptionalSteps;
|
(Array.isArray(columns) && columns.length > 0) || hasFields || hasSettings;
|
||||||
const layout: Record<string, { x: number; y: number }> = {};
|
const layout: Record<string, { x: number; y: number }> = {};
|
||||||
|
|
||||||
/** Project one flow node (top-level or template child) into an IR node. */
|
/** Project one flow node (top-level or template child) into an IR node. */
|
||||||
@@ -595,12 +592,6 @@ export function flowToIr(
|
|||||||
render: s.render ? { ...s.render } : undefined,
|
render: s.render ? { ...s.render } : undefined,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (hasOptionalSteps) {
|
|
||||||
// Optional-step DECLARATIONS round-trip through the editor opaquely (they are
|
|
||||||
// not graph nodes; the resolver + server validator are the source of truth).
|
|
||||||
// Omitted entirely when empty so legacy graphs stay byte-identical (R6).
|
|
||||||
(ir as { optionalSteps?: unknown }).optionalSteps = optionalSteps!.map((o) => ({ ...o }));
|
|
||||||
}
|
|
||||||
return { ir, layout };
|
return { ir, layout };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1013,15 +1004,11 @@ export function settingsOf(def: WorkflowDefinition): WorkflowSettingDefinition[]
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract the editor's working optional-step declaration list from a definition.
|
/* FNXC:WorkflowOptionalGroup 2026-06-21-18:00:
|
||||||
* v2 with `optionalSteps` → a shallow copy; v1 or none → empty. Display metadata
|
`optionalStepsOf` (the editor's legacy `optionalSteps` declaration extractor)
|
||||||
* (name/icon/phase) is NOT carried here — it is resolved from the step-template
|
is removed. Optional steps are graph-native `optional-group` nodes now; the
|
||||||
* catalog at render time so the resolver stays the single source of truth. */
|
editor reads/writes them through the normal node/edge mapping, and the per-task
|
||||||
export function optionalStepsOf(def: WorkflowDefinition): WorkflowOptionalStep[] {
|
toggle surfaces resolve them via `resolveWorkflowOptionalSteps`. */
|
||||||
const ir = def.ir as { optionalSteps?: WorkflowOptionalStep[] };
|
|
||||||
if (!isV2(def.ir) || !Array.isArray(ir.optionalSteps)) return [];
|
|
||||||
return ir.optionalSteps.map((o) => ({ ...o }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Seed graph for a brand-new workflow: start → end with room to insert steps. */
|
/** Seed graph for a brand-new workflow: start → end with room to insert steps. */
|
||||||
export function emptyWorkflowIr(name: string): WorkflowIr {
|
export function emptyWorkflowIr(name: string): WorkflowIr {
|
||||||
|
|||||||
Reference in New Issue
Block a user