fix(FN-1981): sync routine scope toggle state and submission

- Add local scope state in RoutineEditor and sync it when the incoming scope prop changes
- Wire scope toggle buttons to update local state and invoke optional onScopeChange callbacks
- Use the selected local scope for validation, submit payload scope resolution, and helper text rendering
- Expand RoutineEditor tests to cover scope toggle interactions, disabled states, callback calls, submission, and prop-sync behavior
This commit is contained in:
Fusion
2026-04-16 23:39:09 -07:00
committed by gsxdsm
parent 0d94bc39df
commit c60fae39a5
2 changed files with 132 additions and 12 deletions

View File

@@ -166,9 +166,11 @@ interface RoutineEditorProps {
scope?: "global" | "project";
/** Project ID for project-scoped routines. */
projectId?: string;
/** Called when the user changes the scope via the toggle buttons. */
onScopeChange?: (scope: "global" | "project") => void;
}
export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, projectId }: RoutineEditorProps) {
export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, projectId, onScopeChange }: RoutineEditorProps) {
const isEditing = !!routine;
// Extract trigger fields if editing
@@ -228,9 +230,17 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
// Scope toggle state
const [localScope, setLocalScope] = useState<"global" | "project">(formScope ?? "global");
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
// Sync localScope when formScope prop changes (e.g., when parent resets)
useEffect(() => {
if (formScope) setLocalScope(formScope);
}, [formScope]);
useEffect(() => {
let cancelled = false;
setModelsLoading(true);
@@ -269,7 +279,7 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
if (!name.trim()) e.name = "Name is required";
// Scope validation: project scope requires projectId
if (formScope === "project" && !projectId) {
if (localScope === "project" && !projectId) {
e.scope = "Project-specific entries require an active project.";
}
@@ -308,7 +318,7 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
if (timeoutMs < 1000) e.timeoutMs = "Timeout must be at least 1 second (1000ms)";
setErrors(e);
return Object.keys(e).length === 0;
}, [name, triggerType, cronExpression, cronPreset, webhookPath, endpoint, formScope, projectId, actionMode, simpleActionType, command, prompt, taskDescription, modelProvider, modelId, steps, hasEditingSteps, timeoutMs]);
}, [name, triggerType, cronExpression, cronPreset, webhookPath, endpoint, localScope, projectId, actionMode, simpleActionType, command, prompt, taskDescription, modelProvider, modelId, steps, hasEditingSteps, timeoutMs]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
@@ -316,9 +326,9 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
if (!validate()) return;
setSubmitting(true);
try {
// Determine scope: use edit mode's existing scope, otherwise use formScope prop
// When formScope is "project" but no projectId provided, fall back to "global"
let effectiveScope = routine?.scope ?? formScope ?? (projectId ? "project" : "global");
// Determine scope: use edit mode's existing scope, otherwise use localScope
// When localScope is "project" but no projectId provided, fall back to "global"
let effectiveScope = routine?.scope ?? localScope ?? (projectId ? "project" : "global");
if (effectiveScope === "project" && !projectId) {
effectiveScope = "global";
}
@@ -371,7 +381,7 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
setSubmitting(false);
}
},
[validate, onSubmit, name, description, triggerType, cronExpression, webhookPath, webhookSecret, endpoint, actionMode, simpleActionType, command, prompt, modelProvider, modelId, taskTitle, taskDescription, taskColumn, steps, timeoutMs, executionPolicy, catchUpPolicy, enabled, formScope, projectId, routine?.scope, routine?.agentId],
[validate, onSubmit, name, description, triggerType, cronExpression, webhookPath, webhookSecret, endpoint, actionMode, simpleActionType, command, prompt, modelProvider, modelId, taskTitle, taskDescription, taskColumn, steps, timeoutMs, executionPolicy, catchUpPolicy, enabled, localScope, projectId, routine?.scope, routine?.agentId],
);
const nameErrorId = "routine-name-error";
@@ -431,9 +441,10 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
<div className="routine-scope-toggle" role="radiogroup" aria-label="Routine scope">
<button
type="button"
className={`routine-scope-btn${(!formScope || formScope === 'global') ? " active" : ""}`}
className={`routine-scope-btn${localScope === "global" ? " active" : ""}`}
onClick={() => { setLocalScope("global"); onScopeChange?.("global"); }}
role="radio"
aria-checked={(!formScope || formScope === 'global') ? "true" : "false"}
aria-checked={localScope === "global" ? "true" : "false"}
disabled={!!routine?.scope}
title={routine?.scope ? `Scope is locked to ${routine.scope} for existing routines` : "Global scope"}
>
@@ -442,9 +453,10 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
</button>
<button
type="button"
className={`routine-scope-btn${formScope === 'project' ? " active" : ""}`}
className={`routine-scope-btn${localScope === "project" ? " active" : ""}`}
onClick={() => { setLocalScope("project"); onScopeChange?.("project"); }}
role="radio"
aria-checked={formScope === 'project' ? "true" : "false"}
aria-checked={localScope === "project" ? "true" : "false"}
disabled={!!routine?.scope || !projectId}
title={routine?.scope ? `Scope is locked to ${routine.scope} for existing routines` : !projectId ? "Select a project to enable project scope" : "Project scope"}
>
@@ -455,7 +467,7 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
<small>
{!projectId && !routine?.scope
? "No active project. Routines will be created at global scope."
: formScope === "project" && projectId
: localScope === "project" && projectId
? `This routine will be scoped to the current project.`
: "This routine will be created at global scope."}
</small>

View File

@@ -13,6 +13,11 @@ vi.mock("lucide-react", () => ({
Folder: () => <span data-testid="icon-folder">📁</span>,
}));
// Mock API
vi.mock("../api", () => ({
fetchModels: vi.fn(() => new Promise(() => {})),
}));
// Mock @fusion/core
vi.mock("@fusion/core", () => ({}));
@@ -227,6 +232,109 @@ describe("RoutineEditor", () => {
});
});
// ── Scope selector ──────────────────────────────────────────────────
describe("Scope selector", () => {
it("clicking Global sets active state", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} scope="project" projectId="proj-1" />);
const globalButton = screen.getByRole("radio", { name: /Global/i });
const projectButton = screen.getByRole("radio", { name: /Project/i });
expect(globalButton).toHaveAttribute("aria-checked", "false");
expect(projectButton).toHaveAttribute("aria-checked", "true");
fireEvent.click(globalButton);
expect(globalButton).toHaveAttribute("aria-checked", "true");
expect(projectButton).toHaveAttribute("aria-checked", "false");
});
it("clicking Project sets active state", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} scope="global" projectId="proj-1" />);
const globalButton = screen.getByRole("radio", { name: /Global/i });
const projectButton = screen.getByRole("radio", { name: /Project/i });
expect(globalButton).toHaveAttribute("aria-checked", "true");
expect(projectButton).toHaveAttribute("aria-checked", "false");
fireEvent.click(projectButton);
expect(globalButton).toHaveAttribute("aria-checked", "false");
expect(projectButton).toHaveAttribute("aria-checked", "true");
});
it("calls onScopeChange when clicking scope buttons", () => {
const onScopeChange = vi.fn();
render(
<RoutineEditor
onSubmit={onSubmit}
onCancel={onCancel}
scope="global"
projectId="proj-1"
onScopeChange={onScopeChange}
/>,
);
fireEvent.click(screen.getByRole("radio", { name: /Project/i }));
fireEvent.click(screen.getByRole("radio", { name: /Global/i }));
expect(onScopeChange).toHaveBeenNthCalledWith(1, "project");
expect(onScopeChange).toHaveBeenNthCalledWith(2, "global");
});
it("disables Project scope button when no projectId is provided", () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} scope="global" />);
expect(screen.getByRole("radio", { name: /Project/i })).toBeDisabled();
});
it("disables both scope buttons when editing an existing routine with locked scope", () => {
render(
<RoutineEditor
routine={makeRoutine({ scope: "project" })}
onSubmit={onSubmit}
onCancel={onCancel}
projectId="proj-1"
/>,
);
expect(screen.getByRole("radio", { name: /Global/i })).toBeDisabled();
expect(screen.getByRole("radio", { name: /Project/i })).toBeDisabled();
});
it("submits selected scope from the scope toggle", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} scope="global" projectId="proj-1" />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Scoped Routine" } });
fillCommand();
fireEvent.click(screen.getByRole("radio", { name: /Project/i }));
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
scope: "project",
}),
);
});
});
it("syncs local scope when parent scope prop changes", () => {
const { rerender } = render(
<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} scope="global" projectId="proj-1" />,
);
expect(screen.getByRole("radio", { name: /Global/i })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("radio", { name: /Project/i })).toHaveAttribute("aria-checked", "false");
rerender(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} scope="project" projectId="proj-1" />);
expect(screen.getByRole("radio", { name: /Global/i })).toHaveAttribute("aria-checked", "false");
expect(screen.getByRole("radio", { name: /Project/i })).toHaveAttribute("aria-checked", "true");
});
});
// ── Validation ───────────────────────────────────────────────────────
describe("Validation", () => {