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:
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user