refactor(FN-1289): extract AppInner logic into hooks and modal component
- Extract view state, project actions, and task handler concerns from AppInner into dedicated hooks - Add AppModals component to centralize modal rendering and related props wiring - Inline thin aliases and complete AppInner cleanup after moving responsibilities out - Add unit tests for useViewState, useProjectActions, and useTaskHandlers hooks
This commit is contained in:
169
packages/dashboard/app/hooks/__tests__/useProjectActions.test.ts
Normal file
169
packages/dashboard/app/hooks/__tests__/useProjectActions.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useProjectActions } from "../useProjectActions";
|
||||
import * as api from "../../api";
|
||||
import type { ProjectInfo } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
updateProject: vi.fn(),
|
||||
unregisterProject: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUpdateProject = vi.mocked(api.updateProject);
|
||||
const mockUnregisterProject = vi.mocked(api.unregisterProject);
|
||||
|
||||
const PROJECT: ProjectInfo = {
|
||||
id: "proj_123",
|
||||
name: "Demo",
|
||||
path: "/demo",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
|
||||
function createOptions(overrides: Partial<Parameters<typeof useProjectActions>[0]> = {}): Parameters<typeof useProjectActions>[0] {
|
||||
return {
|
||||
setCurrentProject: vi.fn(),
|
||||
clearCurrentProject: vi.fn(),
|
||||
setViewMode: vi.fn(),
|
||||
currentProject: PROJECT,
|
||||
refreshProjects: vi.fn().mockResolvedValue(undefined),
|
||||
toggleFavoriteProvider: vi.fn().mockResolvedValue(undefined),
|
||||
toggleFavoriteModel: vi.fn().mockResolvedValue(undefined),
|
||||
addToast: vi.fn(),
|
||||
openSettings: vi.fn(),
|
||||
openSetupWizard: vi.fn(),
|
||||
closeSetupWizard: vi.fn(),
|
||||
closeModelOnboarding: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useProjectActions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUpdateProject.mockResolvedValue(PROJECT);
|
||||
mockUnregisterProject.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("handleSelectProject sets current project and view mode", () => {
|
||||
const options = createOptions();
|
||||
const { result } = renderHook(() => useProjectActions(options));
|
||||
|
||||
act(() => {
|
||||
result.current.handleSelectProject(PROJECT);
|
||||
});
|
||||
|
||||
expect(options.setCurrentProject).toHaveBeenCalledWith(PROJECT);
|
||||
expect(options.setViewMode).toHaveBeenCalledWith("project");
|
||||
});
|
||||
|
||||
it("handleViewAllProjects clears current project and sets overview", () => {
|
||||
const options = createOptions();
|
||||
const { result } = renderHook(() => useProjectActions(options));
|
||||
|
||||
act(() => {
|
||||
result.current.handleViewAllProjects();
|
||||
});
|
||||
|
||||
expect(options.clearCurrentProject).toHaveBeenCalledTimes(1);
|
||||
expect(options.setViewMode).toHaveBeenCalledWith("overview");
|
||||
});
|
||||
|
||||
it("handleSetupComplete closes wizard, sets project, toasts, and refreshes", async () => {
|
||||
const options = createOptions();
|
||||
const { result } = renderHook(() => useProjectActions(options));
|
||||
|
||||
act(() => {
|
||||
result.current.handleSetupComplete(PROJECT);
|
||||
});
|
||||
|
||||
expect(options.closeSetupWizard).toHaveBeenCalledTimes(1);
|
||||
expect(options.setCurrentProject).toHaveBeenCalledWith(PROJECT);
|
||||
expect(options.setViewMode).toHaveBeenCalledWith("project");
|
||||
expect(options.addToast).toHaveBeenCalledWith("Project Demo registered successfully", "success");
|
||||
expect(options.refreshProjects).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handlePauseProject calls updateProject and shows success toast", async () => {
|
||||
const options = createOptions();
|
||||
const { result } = renderHook(() => useProjectActions(options));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handlePauseProject(PROJECT);
|
||||
});
|
||||
|
||||
expect(mockUpdateProject).toHaveBeenCalledWith(PROJECT.id, { status: "paused" });
|
||||
expect(options.addToast).toHaveBeenCalledWith("Project Demo paused", "success");
|
||||
expect(options.refreshProjects).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handleResumeProject calls updateProject and shows success toast", async () => {
|
||||
const options = createOptions();
|
||||
const { result } = renderHook(() => useProjectActions(options));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleResumeProject(PROJECT);
|
||||
});
|
||||
|
||||
expect(mockUpdateProject).toHaveBeenCalledWith(PROJECT.id, { status: "active" });
|
||||
expect(options.addToast).toHaveBeenCalledWith("Project Demo resumed", "success");
|
||||
expect(options.refreshProjects).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handleRemoveProject unregisters, clears current selection, toasts, and refreshes", async () => {
|
||||
const options = createOptions({ currentProject: PROJECT });
|
||||
const { result } = renderHook(() => useProjectActions(options));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRemoveProject(PROJECT);
|
||||
});
|
||||
|
||||
expect(mockUnregisterProject).toHaveBeenCalledWith(PROJECT.id);
|
||||
expect(options.addToast).toHaveBeenCalledWith("Project Demo removed", "success");
|
||||
expect(options.clearCurrentProject).toHaveBeenCalledTimes(1);
|
||||
expect(options.setViewMode).toHaveBeenCalledWith("overview");
|
||||
expect(options.refreshProjects).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handleRemoveProject shows error toast on API failure", async () => {
|
||||
mockUnregisterProject.mockRejectedValueOnce(new Error("boom"));
|
||||
const options = createOptions();
|
||||
const { result } = renderHook(() => useProjectActions(options));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRemoveProject(PROJECT);
|
||||
});
|
||||
|
||||
expect(options.addToast).toHaveBeenCalledWith("Failed to remove project Demo", "error");
|
||||
});
|
||||
|
||||
it("handleToggleFavorite delegates and shows error toast on failure", async () => {
|
||||
const options = createOptions({
|
||||
toggleFavoriteProvider: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
});
|
||||
const { result } = renderHook(() => useProjectActions(options));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleToggleFavorite("anthropic");
|
||||
});
|
||||
|
||||
expect(options.toggleFavoriteProvider).toHaveBeenCalledWith("anthropic");
|
||||
expect(options.addToast).toHaveBeenCalledWith("Failed to update favorites", "error");
|
||||
});
|
||||
|
||||
it("handleToggleModelFavorite delegates and shows error toast on failure", async () => {
|
||||
const options = createOptions({
|
||||
toggleFavoriteModel: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
});
|
||||
const { result } = renderHook(() => useProjectActions(options));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleToggleModelFavorite("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
expect(options.toggleFavoriteModel).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
expect(options.addToast).toHaveBeenCalledWith("Failed to update model favorites", "error");
|
||||
});
|
||||
});
|
||||
106
packages/dashboard/app/hooks/__tests__/useTaskHandlers.test.ts
Normal file
106
packages/dashboard/app/hooks/__tests__/useTaskHandlers.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useTaskHandlers } from "../useTaskHandlers";
|
||||
import type { Task, TaskCreateInput } from "@fusion/core";
|
||||
|
||||
const CREATED_TASK: Task = {
|
||||
id: "FN-123",
|
||||
title: "Test",
|
||||
description: "Created task",
|
||||
status: "pending",
|
||||
column: "triage",
|
||||
steps: [],
|
||||
dependencies: [],
|
||||
log: [],
|
||||
attachments: [],
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
size: "M",
|
||||
reviewLevel: 0,
|
||||
};
|
||||
|
||||
function createOptions(overrides: Partial<Parameters<typeof useTaskHandlers>[0]> = {}): Parameters<typeof useTaskHandlers>[0] {
|
||||
return {
|
||||
createTask: vi.fn().mockResolvedValue(CREATED_TASK),
|
||||
onPlanningTaskCreated: vi.fn(),
|
||||
onPlanningTasksCreated: vi.fn(),
|
||||
onSubtaskTasksCreated: vi.fn(),
|
||||
addToast: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useTaskHandlers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("handleBoardQuickCreate calls createTask with triage column", async () => {
|
||||
const options = createOptions();
|
||||
const { result } = renderHook(() => useTaskHandlers(options));
|
||||
const input: TaskCreateInput = { description: "Do work" };
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleBoardQuickCreate(input);
|
||||
});
|
||||
|
||||
expect(options.createTask).toHaveBeenCalledWith({ description: "Do work", column: "triage" });
|
||||
});
|
||||
|
||||
it("handleModalCreate calls createTask with triage column and returns task", async () => {
|
||||
const options = createOptions();
|
||||
const { result } = renderHook(() => useTaskHandlers(options));
|
||||
|
||||
let created: Task | null = null;
|
||||
await act(async () => {
|
||||
created = await result.current.handleModalCreate({ description: "From modal" });
|
||||
});
|
||||
|
||||
expect(options.createTask).toHaveBeenCalledWith({ description: "From modal", column: "triage" });
|
||||
expect(created).toEqual(CREATED_TASK);
|
||||
});
|
||||
|
||||
it("handlePlanningTaskCreated delegates with addToast", () => {
|
||||
const options = createOptions();
|
||||
const { result } = renderHook(() => useTaskHandlers(options));
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlanningTaskCreated(CREATED_TASK);
|
||||
});
|
||||
|
||||
expect(options.onPlanningTaskCreated).toHaveBeenCalledWith(CREATED_TASK, options.addToast);
|
||||
});
|
||||
|
||||
it("handlePlanningTasksCreated delegates with addToast", () => {
|
||||
const options = createOptions();
|
||||
const { result } = renderHook(() => useTaskHandlers(options));
|
||||
|
||||
act(() => {
|
||||
result.current.handlePlanningTasksCreated([CREATED_TASK]);
|
||||
});
|
||||
|
||||
expect(options.onPlanningTasksCreated).toHaveBeenCalledWith([CREATED_TASK], options.addToast);
|
||||
});
|
||||
|
||||
it("handleSubtaskTasksCreated delegates with addToast", () => {
|
||||
const options = createOptions();
|
||||
const { result } = renderHook(() => useTaskHandlers(options));
|
||||
|
||||
act(() => {
|
||||
result.current.handleSubtaskTasksCreated([CREATED_TASK]);
|
||||
});
|
||||
|
||||
expect(options.onSubtaskTasksCreated).toHaveBeenCalledWith([CREATED_TASK], options.addToast);
|
||||
});
|
||||
|
||||
it("handleGitHubImport shows success toast with task ID", () => {
|
||||
const options = createOptions();
|
||||
const { result } = renderHook(() => useTaskHandlers(options));
|
||||
|
||||
act(() => {
|
||||
result.current.handleGitHubImport(CREATED_TASK);
|
||||
});
|
||||
|
||||
expect(options.addToast).toHaveBeenCalledWith("Imported FN-123 from GitHub", "success");
|
||||
});
|
||||
});
|
||||
167
packages/dashboard/app/hooks/__tests__/useViewState.test.ts
Normal file
167
packages/dashboard/app/hooks/__tests__/useViewState.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useViewState } from "../useViewState";
|
||||
import type { ProjectInfo } from "../../api";
|
||||
import type { ThemeMode } from "@fusion/core";
|
||||
|
||||
const PROJECT: ProjectInfo = {
|
||||
id: "proj_123",
|
||||
name: "Demo Project",
|
||||
path: "/demo",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
|
||||
function createOptions(overrides: Partial<Parameters<typeof useViewState>[0]> = {}): Parameters<typeof useViewState>[0] {
|
||||
return {
|
||||
projectsLoading: false,
|
||||
currentProjectLoading: false,
|
||||
currentProject: null,
|
||||
projectsLength: 1,
|
||||
setupWizardOpen: false,
|
||||
openSetupWizard: vi.fn(),
|
||||
themeMode: "dark",
|
||||
setThemeMode: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useViewState", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("returns default viewMode and taskView when no localStorage exists", async () => {
|
||||
const { result } = renderHook(() => useViewState(createOptions()));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.viewMode).toBe("overview");
|
||||
expect(result.current.taskView).toBe("board");
|
||||
});
|
||||
});
|
||||
|
||||
it("reads saved viewMode from localStorage on init", async () => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||
|
||||
const { result } = renderHook(() => useViewState(createOptions()));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.viewMode).toBe("project");
|
||||
});
|
||||
});
|
||||
|
||||
it("reads saved taskView from localStorage on init", async () => {
|
||||
localStorage.setItem("kb-dashboard-task-view", "list");
|
||||
|
||||
const { result } = renderHook(() => useViewState(createOptions()));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.taskView).toBe("list");
|
||||
});
|
||||
});
|
||||
|
||||
it("persists viewMode changes to localStorage", async () => {
|
||||
const { result } = renderHook(() => useViewState(createOptions()));
|
||||
|
||||
await act(async () => {
|
||||
result.current.setViewMode("project");
|
||||
});
|
||||
|
||||
expect(localStorage.getItem("kb-dashboard-view-mode")).toBe("project");
|
||||
});
|
||||
|
||||
it("persists taskView changes to localStorage", async () => {
|
||||
const { result } = renderHook(() => useViewState(createOptions()));
|
||||
|
||||
await act(async () => {
|
||||
result.current.setTaskView("list");
|
||||
});
|
||||
|
||||
expect(localStorage.getItem("kb-dashboard-task-view")).toBe("list");
|
||||
});
|
||||
|
||||
it("handleChangeTaskView updates taskView state", async () => {
|
||||
const { result } = renderHook(() => useViewState(createOptions()));
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleChangeTaskView("agents");
|
||||
});
|
||||
|
||||
expect(result.current.taskView).toBe("agents");
|
||||
});
|
||||
|
||||
it("handleToggleTheme cycles dark → light → system → dark", async () => {
|
||||
let themeMode: ThemeMode = "dark";
|
||||
const setThemeMode = vi.fn((mode: ThemeMode) => {
|
||||
themeMode = mode;
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useViewState(
|
||||
createOptions({
|
||||
themeMode,
|
||||
setThemeMode,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleToggleTheme();
|
||||
});
|
||||
expect(setThemeMode).toHaveBeenLastCalledWith("light");
|
||||
|
||||
rerender();
|
||||
await act(async () => {
|
||||
result.current.handleToggleTheme();
|
||||
});
|
||||
expect(setThemeMode).toHaveBeenLastCalledWith("system");
|
||||
|
||||
rerender();
|
||||
await act(async () => {
|
||||
result.current.handleToggleTheme();
|
||||
});
|
||||
expect(setThemeMode).toHaveBeenLastCalledWith("dark");
|
||||
});
|
||||
|
||||
it("syncs viewMode to project when currentProject is restored after loading", async () => {
|
||||
localStorage.setItem("kb-dashboard-view-mode", "overview");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useViewState(
|
||||
createOptions({
|
||||
currentProject: PROJECT,
|
||||
projectsLength: 1,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.viewMode).toBe("project");
|
||||
});
|
||||
});
|
||||
|
||||
it("calls openSetupWizard when no projects and no current project after loading", async () => {
|
||||
vi.useFakeTimers();
|
||||
const openSetupWizard = vi.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useViewState(
|
||||
createOptions({
|
||||
projectsLength: 0,
|
||||
currentProject: null,
|
||||
openSetupWizard,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(openSetupWizard).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
147
packages/dashboard/app/hooks/useProjectActions.ts
Normal file
147
packages/dashboard/app/hooks/useProjectActions.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useCallback } from "react";
|
||||
import { updateProject, unregisterProject } from "../api";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import type { ViewMode } from "./useViewState";
|
||||
import type { ToastType } from "./useToast";
|
||||
|
||||
interface UseProjectActionsOptions {
|
||||
setCurrentProject: (project: ProjectInfo) => void;
|
||||
clearCurrentProject: () => void;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
currentProject: ProjectInfo | null;
|
||||
refreshProjects: () => Promise<void>;
|
||||
toggleFavoriteProvider: (provider: string) => Promise<void>;
|
||||
toggleFavoriteModel: (modelId: string) => Promise<void>;
|
||||
addToast: (message: string, type: ToastType) => void;
|
||||
openSettings: () => void;
|
||||
openSetupWizard: () => void;
|
||||
closeSetupWizard: () => void;
|
||||
closeModelOnboarding: () => void;
|
||||
}
|
||||
|
||||
export interface UseProjectActionsResult {
|
||||
handleSelectProject: (project: ProjectInfo) => void;
|
||||
handleViewAllProjects: () => void;
|
||||
handleOpenSettings: () => void;
|
||||
handleAddProject: () => void;
|
||||
handleSetupComplete: (project: ProjectInfo) => void;
|
||||
handleModelOnboardingComplete: () => void;
|
||||
handlePauseProject: (project: ProjectInfo) => Promise<void>;
|
||||
handleResumeProject: (project: ProjectInfo) => Promise<void>;
|
||||
handleRemoveProject: (project: ProjectInfo) => Promise<void>;
|
||||
handleToggleFavorite: (provider: string) => Promise<void>;
|
||||
handleToggleModelFavorite: (modelId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useProjectActions(options: UseProjectActionsOptions): UseProjectActionsResult {
|
||||
const {
|
||||
setCurrentProject,
|
||||
clearCurrentProject,
|
||||
setViewMode,
|
||||
currentProject,
|
||||
refreshProjects,
|
||||
toggleFavoriteProvider,
|
||||
toggleFavoriteModel,
|
||||
addToast,
|
||||
openSettings,
|
||||
openSetupWizard,
|
||||
closeSetupWizard,
|
||||
closeModelOnboarding,
|
||||
} = options;
|
||||
|
||||
const handleSelectProject = useCallback((project: ProjectInfo) => {
|
||||
setCurrentProject(project);
|
||||
setViewMode("project");
|
||||
}, [setCurrentProject, setViewMode]);
|
||||
|
||||
const handleViewAllProjects = useCallback(() => {
|
||||
clearCurrentProject();
|
||||
setViewMode("overview");
|
||||
}, [clearCurrentProject, setViewMode]);
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
openSettings();
|
||||
}, [openSettings]);
|
||||
|
||||
const handleAddProject = useCallback(() => {
|
||||
openSetupWizard();
|
||||
}, [openSetupWizard]);
|
||||
|
||||
const handleSetupComplete = useCallback((project: ProjectInfo) => {
|
||||
closeSetupWizard();
|
||||
setCurrentProject(project);
|
||||
setViewMode("project");
|
||||
addToast(`Project ${project.name} registered successfully`, "success");
|
||||
void refreshProjects();
|
||||
}, [closeSetupWizard, setCurrentProject, setViewMode, addToast, refreshProjects]);
|
||||
|
||||
const handleModelOnboardingComplete = useCallback(() => {
|
||||
closeModelOnboarding();
|
||||
}, [closeModelOnboarding]);
|
||||
|
||||
const handlePauseProject = useCallback(async (project: ProjectInfo) => {
|
||||
try {
|
||||
await updateProject(project.id, { status: "paused" });
|
||||
addToast(`Project ${project.name} paused`, "success");
|
||||
await refreshProjects();
|
||||
} catch {
|
||||
addToast(`Failed to pause project ${project.name}`, "error");
|
||||
}
|
||||
}, [addToast, refreshProjects]);
|
||||
|
||||
const handleResumeProject = useCallback(async (project: ProjectInfo) => {
|
||||
try {
|
||||
await updateProject(project.id, { status: "active" });
|
||||
addToast(`Project ${project.name} resumed`, "success");
|
||||
await refreshProjects();
|
||||
} catch {
|
||||
addToast(`Failed to resume project ${project.name}`, "error");
|
||||
}
|
||||
}, [addToast, refreshProjects]);
|
||||
|
||||
const handleRemoveProject = useCallback(async (project: ProjectInfo) => {
|
||||
try {
|
||||
await unregisterProject(project.id);
|
||||
addToast(`Project ${project.name} removed`, "success");
|
||||
|
||||
if (currentProject?.id === project.id) {
|
||||
clearCurrentProject();
|
||||
setViewMode("overview");
|
||||
}
|
||||
|
||||
await refreshProjects();
|
||||
} catch {
|
||||
addToast(`Failed to remove project ${project.name}`, "error");
|
||||
}
|
||||
}, [currentProject, clearCurrentProject, setViewMode, addToast, refreshProjects]);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
try {
|
||||
await toggleFavoriteProvider(provider);
|
||||
} catch {
|
||||
addToast("Failed to update favorites", "error");
|
||||
}
|
||||
}, [toggleFavoriteProvider, addToast]);
|
||||
|
||||
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
|
||||
try {
|
||||
await toggleFavoriteModel(modelId);
|
||||
} catch {
|
||||
addToast("Failed to update model favorites", "error");
|
||||
}
|
||||
}, [toggleFavoriteModel, addToast]);
|
||||
|
||||
return {
|
||||
handleSelectProject,
|
||||
handleViewAllProjects,
|
||||
handleOpenSettings,
|
||||
handleAddProject,
|
||||
handleSetupComplete,
|
||||
handleModelOnboardingComplete,
|
||||
handlePauseProject,
|
||||
handleResumeProject,
|
||||
handleRemoveProject,
|
||||
handleToggleFavorite,
|
||||
handleToggleModelFavorite,
|
||||
};
|
||||
}
|
||||
70
packages/dashboard/app/hooks/useTaskHandlers.ts
Normal file
70
packages/dashboard/app/hooks/useTaskHandlers.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Task, TaskCreateInput } from "@fusion/core";
|
||||
import type { ToastType } from "./useToast";
|
||||
|
||||
interface UseTaskHandlersOptions {
|
||||
createTask: (input: TaskCreateInput) => Promise<Task>;
|
||||
onPlanningTaskCreated: (task: Task, addToast: (msg: string, type?: ToastType) => void) => void;
|
||||
onPlanningTasksCreated: (tasks: Task[], addToast: (msg: string, type?: ToastType) => void) => void;
|
||||
onSubtaskTasksCreated: (tasks: Task[], addToast: (msg: string, type?: ToastType) => void) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export interface UseTaskHandlersResult {
|
||||
handleBoardQuickCreate: (input: TaskCreateInput) => Promise<void>;
|
||||
handleModalCreate: (input: TaskCreateInput) => Promise<Task>;
|
||||
handlePlanningTaskCreated: (task: Task) => void;
|
||||
handlePlanningTasksCreated: (tasks: Task[]) => void;
|
||||
handleSubtaskTasksCreated: (tasks: Task[]) => void;
|
||||
handleGitHubImport: (task: Task) => void;
|
||||
}
|
||||
|
||||
export function useTaskHandlers(options: UseTaskHandlersOptions): UseTaskHandlersResult {
|
||||
const {
|
||||
createTask,
|
||||
onPlanningTaskCreated,
|
||||
onPlanningTasksCreated,
|
||||
onSubtaskTasksCreated,
|
||||
addToast,
|
||||
} = options;
|
||||
|
||||
const handleBoardQuickCreate = useCallback(
|
||||
async (input: TaskCreateInput): Promise<void> => {
|
||||
await createTask({ ...input, column: "triage" });
|
||||
},
|
||||
[createTask],
|
||||
);
|
||||
|
||||
const handleModalCreate = useCallback(
|
||||
async (input: TaskCreateInput): Promise<Task> => {
|
||||
const task = await createTask({ ...input, column: "triage" });
|
||||
return task;
|
||||
},
|
||||
[createTask],
|
||||
);
|
||||
|
||||
const handlePlanningTaskCreated = useCallback((task: Task) => {
|
||||
onPlanningTaskCreated(task, addToast);
|
||||
}, [onPlanningTaskCreated, addToast]);
|
||||
|
||||
const handlePlanningTasksCreated = useCallback((tasks: Task[]) => {
|
||||
onPlanningTasksCreated(tasks, addToast);
|
||||
}, [onPlanningTasksCreated, addToast]);
|
||||
|
||||
const handleSubtaskTasksCreated = useCallback((tasks: Task[]) => {
|
||||
onSubtaskTasksCreated(tasks, addToast);
|
||||
}, [onSubtaskTasksCreated, addToast]);
|
||||
|
||||
const handleGitHubImport = useCallback((task: Task) => {
|
||||
addToast(`Imported ${task.id} from GitHub`, "success");
|
||||
}, [addToast]);
|
||||
|
||||
return {
|
||||
handleBoardQuickCreate,
|
||||
handleModalCreate,
|
||||
handlePlanningTaskCreated,
|
||||
handlePlanningTasksCreated,
|
||||
handleSubtaskTasksCreated,
|
||||
handleGitHubImport,
|
||||
};
|
||||
}
|
||||
118
packages/dashboard/app/hooks/useViewState.ts
Normal file
118
packages/dashboard/app/hooks/useViewState.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { ThemeMode } from "@fusion/core";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
|
||||
export type ViewMode = "overview" | "project";
|
||||
export type TaskView = "board" | "list" | "agents";
|
||||
|
||||
interface UseViewStateOptions {
|
||||
projectsLoading: boolean;
|
||||
currentProjectLoading: boolean;
|
||||
currentProject: ProjectInfo | null;
|
||||
projectsLength: number;
|
||||
setupWizardOpen: boolean;
|
||||
openSetupWizard: () => void;
|
||||
themeMode: ThemeMode;
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
}
|
||||
|
||||
export interface UseViewStateResult {
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
taskView: TaskView;
|
||||
setTaskView: (view: TaskView) => void;
|
||||
handleChangeTaskView: (newView: TaskView) => void;
|
||||
handleToggleTheme: () => void;
|
||||
}
|
||||
|
||||
export function useViewState(options: UseViewStateOptions): UseViewStateResult {
|
||||
const {
|
||||
projectsLoading,
|
||||
currentProjectLoading,
|
||||
currentProject,
|
||||
projectsLength,
|
||||
setupWizardOpen,
|
||||
openSetupWizard,
|
||||
themeMode,
|
||||
setThemeMode,
|
||||
} = options;
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = window.localStorage.getItem("kb-dashboard-view-mode");
|
||||
if (saved === "overview" || saved === "project") return saved;
|
||||
}
|
||||
return "overview";
|
||||
});
|
||||
|
||||
const [taskView, setTaskView] = useState<TaskView>(() => {
|
||||
const saved = getScopedItem("kb-dashboard-task-view");
|
||||
if (saved === "board" || saved === "list" || saved === "agents") return saved;
|
||||
return "board";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem("kb-dashboard-view-mode", viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = getScopedItem("kb-dashboard-task-view", currentProject?.id);
|
||||
if (saved === "board" || saved === "list" || saved === "agents") {
|
||||
setTaskView(saved);
|
||||
return;
|
||||
}
|
||||
setTaskView("board");
|
||||
}, [currentProject?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setScopedItem("kb-dashboard-task-view", taskView, currentProject?.id);
|
||||
}, [currentProject?.id, taskView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectsLoading || currentProjectLoading) return;
|
||||
|
||||
if (currentProject && viewMode === "overview") {
|
||||
setViewMode("project");
|
||||
}
|
||||
}, [projectsLoading, currentProjectLoading, currentProject, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectsLoading || currentProjectLoading) return;
|
||||
if (setupWizardOpen) return;
|
||||
if (projectsLength > 0 || currentProject) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
openSetupWizard();
|
||||
}, 500);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
projectsLoading,
|
||||
projectsLength,
|
||||
currentProjectLoading,
|
||||
currentProject,
|
||||
setupWizardOpen,
|
||||
openSetupWizard,
|
||||
]);
|
||||
|
||||
const handleChangeTaskView = useCallback((newView: TaskView) => {
|
||||
setTaskView(newView);
|
||||
}, []);
|
||||
|
||||
const handleToggleTheme = useCallback(() => {
|
||||
const cycle: ThemeMode[] = ["dark", "light", "system"];
|
||||
const currentIndex = cycle.indexOf(themeMode);
|
||||
const nextMode = cycle[(currentIndex + 1) % cycle.length];
|
||||
setThemeMode(nextMode);
|
||||
}, [themeMode, setThemeMode]);
|
||||
|
||||
return {
|
||||
viewMode,
|
||||
setViewMode,
|
||||
taskView,
|
||||
setTaskView,
|
||||
handleChangeTaskView,
|
||||
handleToggleTheme,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user