diff --git a/apps/web/src/app/(authenticated)/settings/workspaces/page.test.tsx b/apps/web/src/app/(authenticated)/settings/workspaces/page.test.tsx index f968643..c8310f1 100644 --- a/apps/web/src/app/(authenticated)/settings/workspaces/page.test.tsx +++ b/apps/web/src/app/(authenticated)/settings/workspaces/page.test.tsx @@ -1,60 +1,135 @@ -/** - * Workspaces Page Tests - * Tests for page structure and component integration - */ +import type { UserWorkspace } from "@/lib/api/workspaces"; +import type { ReactElement, ReactNode } from "react"; -import { describe, it, expect, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { WorkspaceMemberRole } from "@mosaic/shared"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createWorkspace, fetchUserWorkspaces } from "@/lib/api/workspaces"; + +import WorkspacesPage from "./page"; -// Mock next/link vi.mock("next/link", () => ({ - default: ({ children, href }: { children: React.ReactNode; href: string }): React.JSX.Element => ( - {children} - ), + default: function LinkMock({ + children, + href, + }: { + children: ReactNode; + href: string; + }): ReactElement { + return {children}; + }, })); -// Mock the WorkspaceCard component vi.mock("@/components/workspace/WorkspaceCard", () => ({ - WorkspaceCard: (): React.JSX.Element =>
WorkspaceCard
, + WorkspaceCard: function WorkspaceCardMock({ + workspace, + userRole, + memberCount, + }: { + workspace: { name: string }; + userRole: WorkspaceMemberRole; + memberCount: number; + }): ReactElement { + return ( +
+ {workspace.name} | {userRole} | {String(memberCount)} +
+ ); + }, })); -describe("WorkspacesPage", (): void => { - // Note: NODE_ENV is "test" during test runs, which triggers the Coming Soon view - // This tests the production-like behavior where mock data is hidden +vi.mock("@/lib/api/workspaces", () => ({ + fetchUserWorkspaces: vi.fn(), + createWorkspace: vi.fn(), +})); - it("should render the Coming Soon view in non-development environments", async (): Promise => { - const { default: WorkspacesPage } = await import("./page"); - render(); +const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces); +const createWorkspaceMock = vi.mocked(createWorkspace); - // In test mode (non-development), should show Coming Soon - expect(screen.getByText("Coming Soon")).toBeInTheDocument(); - expect(screen.getByText("Workspace Management")).toBeInTheDocument(); +const baseWorkspace: UserWorkspace = { + id: "workspace-1", + name: "Personal Workspace", + ownerId: "owner-1", + role: WorkspaceMemberRole.OWNER, + createdAt: "2026-01-01T00:00:00.000Z", +}; + +describe("WorkspacesPage", () => { + beforeEach(() => { + vi.clearAllMocks(); }); - it("should display appropriate description for workspace feature", async (): Promise => { - const { default: WorkspacesPage } = await import("./page"); + it("loads and renders user workspaces from the API", async () => { + fetchUserWorkspacesMock.mockResolvedValue([baseWorkspace]); + render(); - expect( - screen.getByText(/create and manage workspaces to organize your projects/i) - ).toBeInTheDocument(); + expect(screen.getByText("Loading workspaces...")).toBeInTheDocument(); + + expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument(); + expect(screen.getByTestId("workspace-card")).toHaveTextContent("Personal Workspace"); + expect(fetchUserWorkspacesMock).toHaveBeenCalledTimes(1); }); - it("should not render mock workspace data in Coming Soon view", async (): Promise => { - const { default: WorkspacesPage } = await import("./page"); + it("shows fetch errors in the UI", async () => { + fetchUserWorkspacesMock.mockRejectedValue(new Error("Unable to load workspaces")); + render(); - // Should not show workspace cards or create form in non-development mode - expect(screen.queryByTestId("workspace-card")).not.toBeInTheDocument(); - expect(screen.queryByText("Create New Workspace")).not.toBeInTheDocument(); + expect(await screen.findByText("Unable to load workspaces")).toBeInTheDocument(); }); - it("should include link back to settings", async (): Promise => { - const { default: WorkspacesPage } = await import("./page"); + it("creates a workspace and refreshes the list", async () => { + fetchUserWorkspacesMock.mockResolvedValueOnce([baseWorkspace]).mockResolvedValueOnce([ + baseWorkspace, + { + ...baseWorkspace, + id: "workspace-2", + name: "New Workspace", + role: WorkspaceMemberRole.MEMBER, + }, + ]); + createWorkspaceMock.mockResolvedValue({ + id: "workspace-2", + name: "New Workspace", + ownerId: "owner-1", + settings: {}, + createdAt: "2026-01-02T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + memberCount: 1, + }); + + const user = userEvent.setup(); render(); - const link = screen.getByRole("link", { name: /back to settings/i }); - expect(link).toBeInTheDocument(); - expect(link).toHaveAttribute("href", "/settings"); + expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText("Enter workspace name..."), "New Workspace"); + await user.click(screen.getByRole("button", { name: "Create Workspace" })); + + await waitFor(() => { + expect(createWorkspaceMock).toHaveBeenCalledWith({ name: "New Workspace" }); + }); + await waitFor(() => { + expect(fetchUserWorkspacesMock).toHaveBeenCalledTimes(2); + }); + + expect(await screen.findByText("Your Workspaces (2)")).toBeInTheDocument(); + }); + + it("shows create errors in the UI", async () => { + fetchUserWorkspacesMock.mockResolvedValue([baseWorkspace]); + createWorkspaceMock.mockRejectedValue(new Error("Workspace creation failed")); + + const user = userEvent.setup(); + render(); + + expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText("Enter workspace name..."), "Bad Workspace"); + await user.click(screen.getByRole("button", { name: "Create Workspace" })); + + expect(await screen.findByText("Workspace creation failed")).toBeInTheDocument(); }); }); diff --git a/apps/web/src/app/(authenticated)/settings/workspaces/page.tsx b/apps/web/src/app/(authenticated)/settings/workspaces/page.tsx index e4bf5f5..d50761e 100644 --- a/apps/web/src/app/(authenticated)/settings/workspaces/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/workspaces/page.tsx @@ -1,72 +1,74 @@ "use client"; -import type { ReactElement } from "react"; +import type { ReactElement, SyntheticEvent } from "react"; -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { WorkspaceCard } from "@/components/workspace/WorkspaceCard"; -import { ComingSoon } from "@/components/ui/ComingSoon"; -import { WorkspaceMemberRole } from "@mosaic/shared"; +import { createWorkspace, fetchUserWorkspaces, type UserWorkspace } from "@/lib/api/workspaces"; import Link from "next/link"; -// Check if we're in development mode -const isDevelopment = process.env.NODE_ENV === "development"; +function getErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error) { + return error.message; + } -// Mock data - TODO: Replace with real API calls (development only) -const mockWorkspaces = [ - { - id: "ws-1", - name: "Personal Workspace", - ownerId: "user-1", - settings: {}, - createdAt: new Date("2024-01-15"), - updatedAt: new Date("2024-01-15"), - }, - { - id: "ws-2", - name: "Team Alpha", - ownerId: "user-2", - settings: {}, - createdAt: new Date("2024-01-20"), - updatedAt: new Date("2024-01-20"), - }, -]; - -const mockMemberships = [ - { workspaceId: "ws-1", role: WorkspaceMemberRole.OWNER, memberCount: 1 }, - { workspaceId: "ws-2", role: WorkspaceMemberRole.MEMBER, memberCount: 5 }, -]; + return fallback; +} /** - * Workspaces Page Content - Development Only - * Shows mock workspace data for development purposes + * Workspaces Page + * Fetches and creates workspaces through the real API. */ -function WorkspacesPageContent(): ReactElement { +export default function WorkspacesPage(): ReactElement { + const [workspaces, setWorkspaces] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); const [isCreating, setIsCreating] = useState(false); const [newWorkspaceName, setNewWorkspaceName] = useState(""); + const [createError, setCreateError] = useState(null); - // TODO: Replace with real API call - const workspacesWithRoles = mockWorkspaces.map((workspace) => { - const membership = mockMemberships.find((m) => m.workspaceId === workspace.id); - return { - ...workspace, - userRole: membership?.role ?? WorkspaceMemberRole.GUEST, - memberCount: membership?.memberCount ?? 0, - }; - }); + const loadWorkspaces = useCallback(async (): Promise => { + setIsLoading(true); - const handleCreateWorkspace = async (e: React.SyntheticEvent): Promise => { + try { + const data = await fetchUserWorkspaces(); + setWorkspaces(data); + setLoadError(null); + } catch (error) { + setLoadError(getErrorMessage(error, "Failed to load workspaces")); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void loadWorkspaces(); + }, [loadWorkspaces]); + + const workspacesWithRoles = workspaces.map((workspace) => ({ + ...workspace, + settings: {}, + createdAt: new Date(workspace.createdAt), + updatedAt: new Date(workspace.createdAt), + userRole: workspace.role, + memberCount: 1, + })); + + const handleCreateWorkspace = async (e: SyntheticEvent): Promise => { e.preventDefault(); - if (!newWorkspaceName.trim()) return; + + const workspaceName = newWorkspaceName.trim(); + if (!workspaceName) return; setIsCreating(true); + setCreateError(null); + try { - // TODO: Replace with real API call - await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call - alert(`Workspace "${newWorkspaceName}" created successfully!`); + await createWorkspace({ name: workspaceName }); setNewWorkspaceName(""); - } catch (_error) { - console.error("Failed to create workspace:", _error); - alert("Failed to create workspace"); + await loadWorkspaces(); + } catch (error) { + setCreateError(getErrorMessage(error, "Failed to create workspace")); } finally { setIsCreating(false); } @@ -106,14 +108,27 @@ function WorkspacesPageContent(): ReactElement { {isCreating ? "Creating..." : "Create Workspace"} + {createError !== null && ( +
+ {createError} +
+ )} {/* Workspace List */}

- Your Workspaces ({workspacesWithRoles.length}) + Your Workspaces ({isLoading ? "..." : workspacesWithRoles.length})

- {workspacesWithRoles.length === 0 ? ( + {loadError !== null ? ( +
+ {loadError} +
+ ) : isLoading ? ( +
+ Loading workspaces... +
+ ) : workspacesWithRoles.length === 0 ? (
); } - -/** - * Workspaces Page Entry Point - * Shows development content or Coming Soon based on environment - */ -export default function WorkspacesPage(): ReactElement { - // In production, show Coming Soon placeholder - if (!isDevelopment) { - return ( - - - Back to Settings - - - ); - } - - // In development, show the full page with mock data - return ; -} diff --git a/apps/web/src/lib/api/workspaces.ts b/apps/web/src/lib/api/workspaces.ts index fe38a61..02fcd33 100644 --- a/apps/web/src/lib/api/workspaces.ts +++ b/apps/web/src/lib/api/workspaces.ts @@ -3,7 +3,8 @@ * User-scoped workspace discovery — does NOT require X-Workspace-Id header. */ -import { apiGet } from "./client"; +import type { WorkspaceMemberRole } from "@mosaic/shared"; +import { apiGet, apiPost } from "./client"; /** * A workspace entry from the user's membership list. @@ -13,10 +14,24 @@ export interface UserWorkspace { id: string; name: string; ownerId: string; - role: string; + role: WorkspaceMemberRole; createdAt: string; } +export interface CreateWorkspaceDto { + name: string; +} + +export interface CreatedWorkspace { + id: string; + name: string; + ownerId: string; + settings: Record; + createdAt: string; + updatedAt: string; + memberCount: number; +} + /** * Fetch all workspaces the authenticated user is a member of. * The API auto-provisions a default workspace if the user has none. @@ -24,3 +39,10 @@ export interface UserWorkspace { export async function fetchUserWorkspaces(): Promise { return apiGet("/api/workspaces"); } + +/** + * Create a workspace through the admin endpoint. + */ +export async function createWorkspace(dto: CreateWorkspaceDto): Promise { + return apiPost("/api/admin/workspaces", dto); +} diff --git a/apps/web/src/lib/auth/auth-context.test.tsx b/apps/web/src/lib/auth/auth-context.test.tsx index fd15c70..7783e6e 100644 --- a/apps/web/src/lib/auth/auth-context.test.tsx +++ b/apps/web/src/lib/auth/auth-context.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { AuthProvider, useAuth } from "./auth-context"; import type { AuthUser } from "@mosaic/shared"; +import { WorkspaceMemberRole } from "@mosaic/shared"; // Mock the API client vi.mock("../api/client", () => ({ @@ -761,7 +762,7 @@ describe("AuthContext", (): void => { id: "ws-1", name: "My Workspace", ownerId: "user-1", - role: "OWNER", + role: WorkspaceMemberRole.OWNER, createdAt: "2026-01-01", }, ]); @@ -795,14 +796,14 @@ describe("AuthContext", (): void => { id: "ws-abc-123", name: "My Workspace", ownerId: "user-1", - role: "OWNER", + role: WorkspaceMemberRole.OWNER, createdAt: "2026-01-01", }, { id: "ws-def-456", name: "Second Workspace", ownerId: "other", - role: "MEMBER", + role: WorkspaceMemberRole.MEMBER, createdAt: "2026-02-01", }, ]); @@ -892,7 +893,7 @@ describe("AuthContext", (): void => { id: "ws-1", name: "My Workspace", ownerId: "user-1", - role: "OWNER", + role: WorkspaceMemberRole.OWNER, createdAt: "2026-01-01", }, ]);