Merge pull request #236 from multica-ai/forrestchang/analyze-next-steps
feat(web): add multi-workspace switching and creation
This commit is contained in:
commit
4a273bcb83
3 changed files with 290 additions and 10 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Inbox,
|
||||
ListTodo,
|
||||
|
|
@ -12,11 +12,10 @@ import {
|
|||
Settings,
|
||||
LogOut,
|
||||
Plus,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { MulticaIcon } from "@multica/ui/components/multica-icon";
|
||||
import { useAuth } from "../../lib/auth-context";
|
||||
import type { Workspace } from "@multica/types";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/inbox", label: "Inbox", icon: Inbox },
|
||||
|
|
@ -32,8 +31,33 @@ export default function DashboardLayout({
|
|||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, workspace, isLoading, logout } = useAuth();
|
||||
const { user, workspace, workspaces, isLoading, logout, switchWorkspace, createWorkspace } = useAuth();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newSlug, setNewSlug] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setNewName(value);
|
||||
setNewSlug(value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""));
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = async () => {
|
||||
if (!newName.trim() || !newSlug.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const ws = await createWorkspace({ name: newName.trim(), slug: newSlug.trim() });
|
||||
setShowCreateDialog(false);
|
||||
setNewName("");
|
||||
setNewSlug("");
|
||||
await switchWorkspace(ws.id);
|
||||
} catch (err) {
|
||||
console.error("Failed to create workspace:", err);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
|
|
@ -79,6 +103,40 @@ export default function DashboardLayout({
|
|||
{user.email}
|
||||
</div>
|
||||
<div className="my-1 border-t" />
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
Workspaces
|
||||
</div>
|
||||
{workspaces.map((ws) => (
|
||||
<button
|
||||
key={ws.id}
|
||||
onClick={() => {
|
||||
setShowMenu(false);
|
||||
if (ws.id !== workspace?.id) {
|
||||
switchWorkspace(ws.id);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-[10px] font-semibold">
|
||||
{ws.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-left">{ws.name}</span>
|
||||
{ws.id === workspace?.id && (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMenu(false);
|
||||
setShowCreateDialog(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create workspace
|
||||
</button>
|
||||
<div className="my-1 border-t" />
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => setShowMenu(false)}
|
||||
|
|
@ -148,6 +206,62 @@ export default function DashboardLayout({
|
|||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Create Workspace Dialog */}
|
||||
{showCreateDialog && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/10 backdrop-blur-xs"
|
||||
onClick={() => setShowCreateDialog(false)}
|
||||
/>
|
||||
<div className="fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl bg-background p-6 shadow-lg ring-1 ring-foreground/10">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-lg font-semibold leading-none">Create workspace</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a new workspace for your team.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Name</label>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Workspace"
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSlug}
|
||||
onChange={(e) => setNewSlug(e.target.value)}
|
||||
placeholder="my-workspace"
|
||||
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreateDialog(false)}
|
||||
className="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateWorkspace}
|
||||
disabled={creating || !newName.trim() || !newSlug.trim()}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const mockApi = vi.hoisted(() => ({
|
|||
listWorkspaces: vi.fn(),
|
||||
listMembers: vi.fn(),
|
||||
listAgents: vi.fn(),
|
||||
createWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./api", () => ({
|
||||
|
|
@ -305,4 +306,134 @@ describe("AuthContext", () => {
|
|||
expect(result.current.user).toBeNull();
|
||||
expect(localStorage.getItem("multica_token")).toBeNull();
|
||||
});
|
||||
|
||||
it("initialization prefers stored workspace ID from list", async () => {
|
||||
const mockWorkspace2: Workspace = {
|
||||
id: "ws-2",
|
||||
name: "Second WS",
|
||||
slug: "second",
|
||||
description: null,
|
||||
settings: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
localStorage.setItem("multica_token", "stored-token");
|
||||
localStorage.setItem("multica_workspace_id", "ws-2");
|
||||
|
||||
mockApi.getMe.mockResolvedValueOnce(mockUser);
|
||||
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace, mockWorkspace2]);
|
||||
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
|
||||
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.workspace).toEqual(mockWorkspace2);
|
||||
expect(result.current.workspaces).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("initialization falls back to first workspace when stored ID not in list", async () => {
|
||||
localStorage.setItem("multica_token", "stored-token");
|
||||
localStorage.setItem("multica_workspace_id", "ws-deleted");
|
||||
|
||||
mockApi.getMe.mockResolvedValueOnce(mockUser);
|
||||
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
|
||||
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
|
||||
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.workspace).toEqual(mockWorkspace);
|
||||
});
|
||||
|
||||
it("createWorkspace calls API and adds to workspaces list", async () => {
|
||||
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
|
||||
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
|
||||
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
|
||||
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
|
||||
|
||||
const newWs: Workspace = {
|
||||
id: "ws-new",
|
||||
name: "New WS",
|
||||
slug: "new-ws",
|
||||
description: null,
|
||||
settings: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
mockApi.createWorkspace.mockResolvedValueOnce(newWs);
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.login("test@multica.ai");
|
||||
});
|
||||
|
||||
let created: Workspace | undefined;
|
||||
await act(async () => {
|
||||
created = await result.current.createWorkspace({ name: "New WS", slug: "new-ws" });
|
||||
});
|
||||
|
||||
expect(mockApi.createWorkspace).toHaveBeenCalledWith({ name: "New WS", slug: "new-ws" });
|
||||
expect(created).toEqual(newWs);
|
||||
expect(result.current.workspaces).toHaveLength(2);
|
||||
expect(result.current.workspaces[1]).toEqual(newWs);
|
||||
});
|
||||
|
||||
it("switchWorkspace updates context and calls setWorkspaceId", async () => {
|
||||
const reloadMock = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { ...window.location, reload: reloadMock },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mockWorkspace2: Workspace = {
|
||||
id: "ws-2",
|
||||
name: "Second WS",
|
||||
slug: "second",
|
||||
description: null,
|
||||
settings: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
|
||||
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace, mockWorkspace2]);
|
||||
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
|
||||
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.login("test@multica.ai");
|
||||
});
|
||||
|
||||
// Setup mocks for the switch
|
||||
mockApi.listMembers.mockResolvedValueOnce([]);
|
||||
mockApi.listAgents.mockResolvedValueOnce([]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchWorkspace("ws-2");
|
||||
});
|
||||
|
||||
expect(mockApi.setWorkspaceId).toHaveBeenCalledWith("ws-2");
|
||||
expect(localStorage.getItem("multica_workspace_id")).toBe("ws-2");
|
||||
expect(reloadMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,11 +15,14 @@ import { api } from "./api";
|
|||
interface AuthContextValue {
|
||||
user: User | null;
|
||||
workspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
members: MemberWithUser[];
|
||||
agents: Agent[];
|
||||
isLoading: boolean;
|
||||
login: (email: string, name?: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
switchWorkspace: (workspaceId: string) => Promise<void>;
|
||||
createWorkspace: (data: { name: string; slug: string; description?: string }) => Promise<Workspace>;
|
||||
updateWorkspace: (ws: Workspace) => void;
|
||||
refreshMembers: () => Promise<void>;
|
||||
refreshAgents: () => Promise<void>;
|
||||
|
|
@ -35,6 +38,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
const [user, setUser] = useState<User | null>(null);
|
||||
const [workspace, setWorkspace] = useState<Workspace | null>(null);
|
||||
const [members, setMembers] = useState<MemberWithUser[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
|
@ -56,9 +60,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
const me = await api.getMe();
|
||||
setUser(me);
|
||||
|
||||
const workspaces = await api.listWorkspaces();
|
||||
if (workspaces.length > 0) {
|
||||
const ws = workspaces[0]!;
|
||||
const wsList = await api.listWorkspaces();
|
||||
setWorkspaces(wsList);
|
||||
if (wsList.length > 0) {
|
||||
const stored = wsId ? wsList.find(w => w.id === wsId) : null;
|
||||
const ws = stored ?? wsList[0]!;
|
||||
setWorkspace(ws);
|
||||
api.setWorkspaceId(ws.id);
|
||||
localStorage.setItem("multica_workspace_id", ws.id);
|
||||
|
|
@ -87,9 +93,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
setUser(u);
|
||||
|
||||
// Load workspace
|
||||
const workspaces = await api.listWorkspaces();
|
||||
if (workspaces.length > 0) {
|
||||
const ws = workspaces[0]!;
|
||||
const wsList = await api.listWorkspaces();
|
||||
setWorkspaces(wsList);
|
||||
if (wsList.length > 0) {
|
||||
const ws = wsList[0]!;
|
||||
setWorkspace(ws);
|
||||
api.setWorkspaceId(ws.id);
|
||||
localStorage.setItem("multica_workspace_id", ws.id);
|
||||
|
|
@ -110,11 +117,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
localStorage.removeItem("multica_workspace_id");
|
||||
setUser(null);
|
||||
setWorkspace(null);
|
||||
setWorkspaces([]);
|
||||
setMembers([]);
|
||||
setAgents([]);
|
||||
router.push("/login");
|
||||
}, [router]);
|
||||
|
||||
const switchWorkspace = useCallback(async (workspaceId: string) => {
|
||||
const ws = workspaces.find(w => w.id === workspaceId);
|
||||
if (!ws) return;
|
||||
|
||||
api.setWorkspaceId(ws.id);
|
||||
localStorage.setItem("multica_workspace_id", ws.id);
|
||||
setWorkspace(ws);
|
||||
|
||||
const [m, a] = await Promise.all([
|
||||
api.listMembers(ws.id),
|
||||
api.listAgents({ workspace_id: ws.id }),
|
||||
]);
|
||||
setMembers(m);
|
||||
setAgents(a);
|
||||
|
||||
window.location.reload();
|
||||
}, [workspaces]);
|
||||
|
||||
const createNewWorkspace = useCallback(async (data: { name: string; slug: string; description?: string }) => {
|
||||
const ws = await api.createWorkspace(data);
|
||||
setWorkspaces(prev => [...prev, ws]);
|
||||
return ws;
|
||||
}, []);
|
||||
|
||||
const updateWorkspaceState = useCallback((ws: Workspace) => {
|
||||
setWorkspace(ws);
|
||||
}, []);
|
||||
|
|
@ -174,11 +206,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
value={{
|
||||
user,
|
||||
workspace,
|
||||
workspaces,
|
||||
members,
|
||||
agents,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
switchWorkspace,
|
||||
createWorkspace: createNewWorkspace,
|
||||
updateWorkspace: updateWorkspaceState,
|
||||
refreshMembers,
|
||||
refreshAgents,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue