feat(web): add user edit/invite dialogs and workspace member management (MS21-UI-002, MS21-UI-004) (#592)
All checks were successful
ci/woodpecker/push/ci 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 #592.
This commit is contained in:
2026-03-01 03:54:32 +00:00
committed by jason.woltje
parent 1df20f0e13
commit 7106512fa9
5 changed files with 1046 additions and 308 deletions

View File

@@ -1,12 +1,12 @@
import type { UserWorkspace } from "@/lib/api/workspaces";
import type { UserWorkspace, WorkspaceMemberEntry } from "@/lib/api/workspaces";
import type { ReactElement, ReactNode } from "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 { createWorkspace, fetchUserWorkspaces, fetchWorkspaceMembers } from "@/lib/api/workspaces";
import WorkspacesPage from "./page";
vi.mock("next/link", () => ({
@@ -21,33 +21,23 @@ vi.mock("next/link", () => ({
},
}));
vi.mock("@/components/workspace/WorkspaceCard", () => ({
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>
);
},
}));
vi.mock("@/lib/api/workspaces", () => ({
fetchUserWorkspaces: vi.fn(),
createWorkspace: vi.fn(),
fetchWorkspaceMembers: vi.fn(),
addWorkspaceMember: vi.fn(),
removeWorkspaceMember: vi.fn(),
}));
vi.mock("@/lib/api/admin", () => ({
fetchAdminUsers: vi.fn(),
}));
const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces);
const createWorkspaceMock = vi.mocked(createWorkspace);
const fetchWorkspaceMembersMock = vi.mocked(fetchWorkspaceMembers);
const baseWorkspace: UserWorkspace = {
const workspaceA: UserWorkspace = {
id: "workspace-1",
name: "Personal Workspace",
ownerId: "owner-1",
@@ -55,45 +45,93 @@ const baseWorkspace: UserWorkspace = {
createdAt: "2026-01-01T00:00:00.000Z",
};
const workspaceB: UserWorkspace = {
id: "workspace-2",
name: "Client Workspace",
ownerId: "owner-2",
role: WorkspaceMemberRole.ADMIN,
createdAt: "2026-01-02T00:00:00.000Z",
};
const membersA: WorkspaceMemberEntry[] = [
{
workspaceId: "workspace-1",
userId: "user-a",
role: WorkspaceMemberRole.OWNER,
joinedAt: "2026-01-03T00:00:00.000Z",
user: {
id: "user-a",
email: "alice@example.com",
name: "Alice",
image: null,
},
},
];
const membersB: WorkspaceMemberEntry[] = [
{
workspaceId: "workspace-2",
userId: "user-b",
role: WorkspaceMemberRole.MEMBER,
joinedAt: "2026-01-04T00:00:00.000Z",
user: {
id: "user-b",
email: "bob@example.com",
name: "Bob",
image: null,
},
},
];
describe("WorkspacesPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("loads and renders user workspaces from the API", async () => {
fetchUserWorkspacesMock.mockResolvedValue([baseWorkspace]);
it("loads workspaces and fetches members for the first workspace", async () => {
fetchUserWorkspacesMock.mockResolvedValue([workspaceA, workspaceB]);
fetchWorkspaceMembersMock.mockResolvedValue(membersA);
render(<WorkspacesPage />);
expect(screen.getByText("Loading workspaces...")).toBeInTheDocument();
expect(await screen.findByText("Your Workspaces (2)")).toBeInTheDocument();
expect(await screen.findByText("Personal Workspace Members")).toBeInTheDocument();
expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument();
expect(screen.getByTestId("workspace-card")).toHaveTextContent("Personal Workspace");
expect(fetchUserWorkspacesMock).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(fetchWorkspaceMembersMock).toHaveBeenCalledWith("workspace-1");
});
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
});
it("shows fetch errors in the UI", async () => {
fetchUserWorkspacesMock.mockRejectedValue(new Error("Unable to load workspaces"));
it("switches selected workspace and reloads member list", async () => {
fetchUserWorkspacesMock.mockResolvedValue([workspaceA, workspaceB]);
fetchWorkspaceMembersMock.mockResolvedValueOnce(membersA).mockResolvedValueOnce(membersB);
const user = userEvent.setup();
render(<WorkspacesPage />);
expect(await screen.findByText("Unable to load workspaces")).toBeInTheDocument();
expect(await screen.findByText("Personal Workspace Members")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /client workspace/i }));
await waitFor(() => {
expect(fetchWorkspaceMembersMock).toHaveBeenLastCalledWith("workspace-2");
});
expect(await screen.findByText("Client Workspace Members")).toBeInTheDocument();
expect(screen.getByText("bob@example.com")).toBeInTheDocument();
});
it("creates a workspace and refreshes the list", async () => {
fetchUserWorkspacesMock.mockResolvedValueOnce([baseWorkspace]).mockResolvedValueOnce([
baseWorkspace,
{
...baseWorkspace,
id: "workspace-2",
name: "New Workspace",
role: WorkspaceMemberRole.MEMBER,
},
]);
fetchUserWorkspacesMock
.mockResolvedValueOnce([workspaceA])
.mockResolvedValueOnce([workspaceA, workspaceB]);
fetchWorkspaceMembersMock.mockResolvedValue(membersA);
createWorkspaceMock.mockResolvedValue({
id: "workspace-2",
name: "New Workspace",
ownerId: "owner-1",
name: "Client Workspace",
ownerId: "owner-2",
settings: {},
createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
@@ -105,31 +143,17 @@ describe("WorkspacesPage", () => {
expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument();
await user.type(screen.getByPlaceholderText("Enter workspace name..."), "New Workspace");
await user.type(screen.getByPlaceholderText("Enter workspace name..."), "Client Workspace");
await user.click(screen.getByRole("button", { name: "Create Workspace" }));
await waitFor(() => {
expect(createWorkspaceMock).toHaveBeenCalledWith({ name: "New Workspace" });
expect(createWorkspaceMock).toHaveBeenCalledWith({ name: "Client 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();
});
});