feat(web): wire workspaces settings page to real API (MS21-UI-003) (#574)
All checks were successful
ci/woodpecker/push/web Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #574.
This commit is contained in:
2026-02-28 20:48:24 +00:00
committed by jason.woltje
parent 127bf61fe2
commit 20c9e68e1b
4 changed files with 207 additions and 117 deletions

View File

@@ -1,60 +1,135 @@
/** import type { UserWorkspace } from "@/lib/api/workspaces";
* Workspaces Page Tests import type { ReactElement, ReactNode } from "react";
* Tests for page structure and component integration
*/
import { describe, it, expect, vi } from "vitest"; import { WorkspaceMemberRole } from "@mosaic/shared";
import { render, screen } from "@testing-library/react"; 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", () => ({ vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }): React.JSX.Element => ( default: function LinkMock({
<a href={href}>{children}</a> children,
), href,
}: {
children: ReactNode;
href: string;
}): ReactElement {
return <a href={href}>{children}</a>;
},
})); }));
// Mock the WorkspaceCard component
vi.mock("@/components/workspace/WorkspaceCard", () => ({ 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 => { vi.mock("@/lib/api/workspaces", () => ({
// Note: NODE_ENV is "test" during test runs, which triggers the Coming Soon view fetchUserWorkspaces: vi.fn(),
// This tests the production-like behavior where mock data is hidden createWorkspace: vi.fn(),
}));
it("should render the Coming Soon view in non-development environments", async (): Promise<void> => { const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces);
const { default: WorkspacesPage } = await import("./page"); const createWorkspaceMock = vi.mocked(createWorkspace);
render(<WorkspacesPage />);
// In test mode (non-development), should show Coming Soon const baseWorkspace: UserWorkspace = {
expect(screen.getByText("Coming Soon")).toBeInTheDocument(); id: "workspace-1",
expect(screen.getByText("Workspace Management")).toBeInTheDocument(); 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> => { it("loads and renders user workspaces from the API", async () => {
const { default: WorkspacesPage } = await import("./page"); fetchUserWorkspacesMock.mockResolvedValue([baseWorkspace]);
render(<WorkspacesPage />); render(<WorkspacesPage />);
expect( expect(screen.getByText("Loading workspaces...")).toBeInTheDocument();
screen.getByText(/create and manage workspaces to organize your projects/i)
).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> => { it("shows fetch errors in the UI", async () => {
const { default: WorkspacesPage } = await import("./page"); fetchUserWorkspacesMock.mockRejectedValue(new Error("Unable to load workspaces"));
render(<WorkspacesPage />); render(<WorkspacesPage />);
// Should not show workspace cards or create form in non-development mode expect(await screen.findByText("Unable to load workspaces")).toBeInTheDocument();
expect(screen.queryByTestId("workspace-card")).not.toBeInTheDocument();
expect(screen.queryByText("Create New Workspace")).not.toBeInTheDocument();
}); });
it("should include link back to settings", async (): Promise<void> => { it("creates a workspace and refreshes the list", async () => {
const { default: WorkspacesPage } = await import("./page"); 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 />); render(<WorkspacesPage />);
const link = screen.getByRole("link", { name: /back to settings/i }); expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument();
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/settings"); 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"; "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 { WorkspaceCard } from "@/components/workspace/WorkspaceCard";
import { ComingSoon } from "@/components/ui/ComingSoon"; import { createWorkspace, fetchUserWorkspaces, type UserWorkspace } from "@/lib/api/workspaces";
import { WorkspaceMemberRole } from "@mosaic/shared";
import Link from "next/link"; import Link from "next/link";
// Check if we're in development mode function getErrorMessage(error: unknown, fallback: string): string {
const isDevelopment = process.env.NODE_ENV === "development"; if (error instanceof Error) {
return error.message;
}
// Mock data - TODO: Replace with real API calls (development only) return fallback;
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 },
];
/** /**
* Workspaces Page Content - Development Only * Workspaces Page
* Shows mock workspace data for development purposes * 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 [isCreating, setIsCreating] = useState(false);
const [newWorkspaceName, setNewWorkspaceName] = useState(""); const [newWorkspaceName, setNewWorkspaceName] = useState("");
const [createError, setCreateError] = useState<string | null>(null);
// TODO: Replace with real API call const loadWorkspaces = useCallback(async (): Promise<void> => {
const workspacesWithRoles = mockWorkspaces.map((workspace) => { setIsLoading(true);
const membership = mockMemberships.find((m) => m.workspaceId === workspace.id);
return { 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, ...workspace,
userRole: membership?.role ?? WorkspaceMemberRole.GUEST, settings: {},
memberCount: membership?.memberCount ?? 0, createdAt: new Date(workspace.createdAt),
}; updatedAt: new Date(workspace.createdAt),
}); userRole: workspace.role,
memberCount: 1,
}));
const handleCreateWorkspace = async (e: React.SyntheticEvent<HTMLFormElement>): Promise<void> => { const handleCreateWorkspace = async (e: SyntheticEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault(); e.preventDefault();
if (!newWorkspaceName.trim()) return;
const workspaceName = newWorkspaceName.trim();
if (!workspaceName) return;
setIsCreating(true); setIsCreating(true);
setCreateError(null);
try { try {
// TODO: Replace with real API call await createWorkspace({ name: workspaceName });
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
alert(`Workspace "${newWorkspaceName}" created successfully!`);
setNewWorkspaceName(""); setNewWorkspaceName("");
} catch (_error) { await loadWorkspaces();
console.error("Failed to create workspace:", _error); } catch (error) {
alert("Failed to create workspace"); setCreateError(getErrorMessage(error, "Failed to create workspace"));
} finally { } finally {
setIsCreating(false); setIsCreating(false);
} }
@@ -106,14 +108,27 @@ function WorkspacesPageContent(): ReactElement {
{isCreating ? "Creating..." : "Create Workspace"} {isCreating ? "Creating..." : "Create Workspace"}
</button> </button>
</form> </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> </div>
{/* Workspace List */} {/* Workspace List */}
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900"> <h2 className="text-xl font-semibold text-gray-900">
Your Workspaces ({workspacesWithRoles.length}) Your Workspaces ({isLoading ? "..." : workspacesWithRoles.length})
</h2> </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"> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<svg <svg
className="mx-auto h-12 w-12 text-gray-400 mb-4" className="mx-auto h-12 w-12 text-gray-400 mb-4"
@@ -147,26 +162,3 @@ function WorkspacesPageContent(): ReactElement {
</main> </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. * 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. * A workspace entry from the user's membership list.
@@ -13,10 +14,24 @@ export interface UserWorkspace {
id: string; id: string;
name: string; name: string;
ownerId: string; ownerId: string;
role: string; role: WorkspaceMemberRole;
createdAt: string; 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. * Fetch all workspaces the authenticated user is a member of.
* The API auto-provisions a default workspace if the user has none. * 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[]> { export async function fetchUserWorkspaces(): Promise<UserWorkspace[]> {
return apiGet<UserWorkspace[]>("/api/workspaces"); 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 { render, screen, waitFor } from "@testing-library/react";
import { AuthProvider, useAuth } from "./auth-context"; import { AuthProvider, useAuth } from "./auth-context";
import type { AuthUser } from "@mosaic/shared"; import type { AuthUser } from "@mosaic/shared";
import { WorkspaceMemberRole } from "@mosaic/shared";
// Mock the API client // Mock the API client
vi.mock("../api/client", () => ({ vi.mock("../api/client", () => ({
@@ -761,7 +762,7 @@ describe("AuthContext", (): void => {
id: "ws-1", id: "ws-1",
name: "My Workspace", name: "My Workspace",
ownerId: "user-1", ownerId: "user-1",
role: "OWNER", role: WorkspaceMemberRole.OWNER,
createdAt: "2026-01-01", createdAt: "2026-01-01",
}, },
]); ]);
@@ -795,14 +796,14 @@ describe("AuthContext", (): void => {
id: "ws-abc-123", id: "ws-abc-123",
name: "My Workspace", name: "My Workspace",
ownerId: "user-1", ownerId: "user-1",
role: "OWNER", role: WorkspaceMemberRole.OWNER,
createdAt: "2026-01-01", createdAt: "2026-01-01",
}, },
{ {
id: "ws-def-456", id: "ws-def-456",
name: "Second Workspace", name: "Second Workspace",
ownerId: "other", ownerId: "other",
role: "MEMBER", role: WorkspaceMemberRole.MEMBER,
createdAt: "2026-02-01", createdAt: "2026-02-01",
}, },
]); ]);
@@ -892,7 +893,7 @@ describe("AuthContext", (): void => {
id: "ws-1", id: "ws-1",
name: "My Workspace", name: "My Workspace",
ownerId: "user-1", ownerId: "user-1",
role: "OWNER", role: WorkspaceMemberRole.OWNER,
createdAt: "2026-01-01", createdAt: "2026-01-01",
}, },
]); ]);