feat(web): wire workspaces settings page to real API (MS21-UI-003) #574

Merged
jason.woltje merged 1 commits from feat/ms21-ui-workspaces into main 2026-02-28 20:48:25 +00:00
4 changed files with 207 additions and 117 deletions

View File

@@ -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 => (
<a href={href}>{children}</a>
),
default: function LinkMock({
children,
href,
}: {
children: ReactNode;
href: string;
}): ReactElement {
return <a href={href}>{children}</a>;
},
}));
// Mock the WorkspaceCard component
vi.mock("@/components/workspace/WorkspaceCard", () => ({
WorkspaceCard: (): React.JSX.Element => <div data-testid="workspace-card">WorkspaceCard</div>,
WorkspaceCard: function WorkspaceCardMock({
workspace,
userRole,
memberCount,
}: {
workspace: { name: string };
userRole: WorkspaceMemberRole;
memberCount: number;
}): ReactElement {
return (
<div data-testid="workspace-card">
{workspace.name} | {userRole} | {String(memberCount)}
</div>
);
},
}));
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<void> => {
const { default: WorkspacesPage } = await import("./page");
render(<WorkspacesPage />);
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<void> => {
const { default: WorkspacesPage } = await import("./page");
it("loads and renders user workspaces from the API", async () => {
fetchUserWorkspacesMock.mockResolvedValue([baseWorkspace]);
render(<WorkspacesPage />);
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<void> => {
const { default: WorkspacesPage } = await import("./page");
it("shows fetch errors in the UI", async () => {
fetchUserWorkspacesMock.mockRejectedValue(new Error("Unable to load workspaces"));
render(<WorkspacesPage />);
// 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<void> => {
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(<WorkspacesPage />);
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(<WorkspacesPage />);
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();
});
});

View File

@@ -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<UserWorkspace[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [newWorkspaceName, setNewWorkspaceName] = useState("");
const [createError, setCreateError] = useState<string | null>(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<void> => {
setIsLoading(true);
const handleCreateWorkspace = async (e: React.SyntheticEvent<HTMLFormElement>): Promise<void> => {
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<HTMLFormElement>): Promise<void> => {
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"}
</button>
</form>
{createError !== null && (
<div className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{createError}
</div>
)}
</div>
{/* Workspace List */}
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900">
Your Workspaces ({workspacesWithRoles.length})
Your Workspaces ({isLoading ? "..." : workspacesWithRoles.length})
</h2>
{workspacesWithRoles.length === 0 ? (
{loadError !== null ? (
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-red-700">
{loadError}
</div>
) : isLoading ? (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center text-gray-600">
Loading workspaces...
</div>
) : workspacesWithRoles.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400 mb-4"
@@ -147,26 +162,3 @@ function WorkspacesPageContent(): ReactElement {
</main>
);
}
/**
* 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 (
<ComingSoon
feature="Workspace Management"
description="Create and manage workspaces to organize your projects and collaborate with your team. This feature is currently under development."
>
<Link href="/settings" className="text-sm text-blue-600 hover:text-blue-700">
Back to Settings
</Link>
</ComingSoon>
);
}
// In development, show the full page with mock data
return <WorkspacesPageContent />;
}

View File

@@ -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<string, unknown>;
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<UserWorkspace[]> {
return apiGet<UserWorkspace[]>("/api/workspaces");
}
/**
* Create a workspace through the admin endpoint.
*/
export async function createWorkspace(dto: CreateWorkspaceDto): Promise<CreatedWorkspace> {
return apiPost<CreatedWorkspace>("/api/admin/workspaces", dto);
}

View File

@@ -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",
},
]);