diff --git a/apps/web/src/components/workspace/InviteMember.test.tsx b/apps/web/src/components/workspace/InviteMember.test.tsx new file mode 100644 index 0000000..05b737f --- /dev/null +++ b/apps/web/src/components/workspace/InviteMember.test.tsx @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { WorkspaceMemberRole } from "@mosaic/shared"; +import { InviteMember } from "./InviteMember"; + +/** + * Helper to get the invite form element from the rendered component. + * The form wraps the submit button, so we locate it via the button. + */ +function getForm(): HTMLFormElement { + const button = screen.getByRole("button", { name: /send invitation/i }); + const form = button.closest("form"); + if (!form) { + throw new Error("Could not locate
element in InviteMember"); + } + return form; +} + +describe("InviteMember", (): void => { + const mockOnInvite = vi.fn<(email: string, role: WorkspaceMemberRole) => Promise>(); + + beforeEach((): void => { + mockOnInvite.mockReset(); + mockOnInvite.mockResolvedValue(undefined); + vi.spyOn(window, "alert").mockImplementation((): undefined => undefined); + }); + + it("should render the invite form", (): void => { + render(); + expect(screen.getByLabelText(/email address/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/role/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /send invitation/i })).toBeInTheDocument(); + }); + + it("should show error for empty email", async (): Promise => { + render(); + + fireEvent.submit(getForm()); + + expect(await screen.findByText("Email is required")).toBeInTheDocument(); + expect(mockOnInvite).not.toHaveBeenCalled(); + }); + + it("should show error for invalid email without domain", async (): Promise => { + render(); + + const emailInput = screen.getByLabelText(/email address/i); + fireEvent.change(emailInput, { target: { value: "notanemail" } }); + fireEvent.submit(getForm()); + + expect(await screen.findByText("Please enter a valid email address")).toBeInTheDocument(); + expect(mockOnInvite).not.toHaveBeenCalled(); + }); + + it("should show error for email with only @ sign", async (): Promise => { + render(); + + const emailInput = screen.getByLabelText(/email address/i); + fireEvent.change(emailInput, { target: { value: "user@" } }); + fireEvent.submit(getForm()); + + expect(await screen.findByText("Please enter a valid email address")).toBeInTheDocument(); + expect(mockOnInvite).not.toHaveBeenCalled(); + }); + + it("should accept valid email and invoke onInvite", async (): Promise => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText(/email address/i), "valid@example.com"); + await user.click(screen.getByRole("button", { name: /send invitation/i })); + + expect(mockOnInvite).toHaveBeenCalledWith("valid@example.com", WorkspaceMemberRole.MEMBER); + }); + + it("should allow selecting a different role", async (): Promise => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText(/email address/i), "admin@example.com"); + await user.selectOptions(screen.getByLabelText(/role/i), WorkspaceMemberRole.ADMIN); + await user.click(screen.getByRole("button", { name: /send invitation/i })); + + expect(mockOnInvite).toHaveBeenCalledWith("admin@example.com", WorkspaceMemberRole.ADMIN); + }); + + it("should show error message when onInvite rejects", async (): Promise => { + mockOnInvite.mockRejectedValueOnce(new Error("Invite failed")); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText(/email address/i), "user@example.com"); + await user.click(screen.getByRole("button", { name: /send invitation/i })); + + expect(await screen.findByText("Invite failed")).toBeInTheDocument(); + }); + + it("should reset form after successful invite", async (): Promise => { + const user = userEvent.setup(); + render(); + + const emailInput = screen.getByLabelText(/email address/i); + await user.type(emailInput, "user@example.com"); + await user.click(screen.getByRole("button", { name: /send invitation/i })); + + expect(emailInput).toHaveValue(""); + }); +}); diff --git a/apps/web/src/components/workspace/InviteMember.tsx b/apps/web/src/components/workspace/InviteMember.tsx index bd271b0..a49cf56 100644 --- a/apps/web/src/components/workspace/InviteMember.tsx +++ b/apps/web/src/components/workspace/InviteMember.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { WorkspaceMemberRole } from "@mosaic/shared"; +import { isValidEmail, toWorkspaceMemberRole } from "./validation"; interface InviteMemberProps { onInvite: (email: string, role: WorkspaceMemberRole) => Promise; @@ -22,7 +23,7 @@ export function InviteMember({ onInvite }: InviteMemberProps): React.JSX.Element return; } - if (!email.includes("@")) { + if (!isValidEmail(email.trim())) { setError("Please enter a valid email address"); return; } @@ -72,7 +73,7 @@ export function InviteMember({ onInvite }: InviteMemberProps): React.JSX.Element id="role" value={role} onChange={(e) => { - setRole(e.target.value as WorkspaceMemberRole); + setRole(toWorkspaceMemberRole(e.target.value)); }} disabled={isInviting} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100" diff --git a/apps/web/src/components/workspace/MemberList.test.tsx b/apps/web/src/components/workspace/MemberList.test.tsx new file mode 100644 index 0000000..cb2fe8b --- /dev/null +++ b/apps/web/src/components/workspace/MemberList.test.tsx @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { WorkspaceMemberRole } from "@mosaic/shared"; +import { MemberList } from "./MemberList"; +import type { WorkspaceMemberWithUser } from "./MemberList"; + +const makeMember = ( + overrides: Partial & { userId: string } +): WorkspaceMemberWithUser => ({ + workspaceId: overrides.workspaceId ?? "ws-1", + userId: overrides.userId, + role: overrides.role ?? WorkspaceMemberRole.MEMBER, + joinedAt: overrides.joinedAt ?? new Date("2025-01-01"), + user: overrides.user ?? { + id: overrides.userId, + name: `User ${overrides.userId}`, + email: `${overrides.userId}@example.com`, + emailVerified: true, + image: null, + authProviderId: `auth-${overrides.userId}`, + preferences: {}, + createdAt: new Date("2025-01-01"), + updatedAt: new Date("2025-01-01"), + }, +}); + +describe("MemberList", (): void => { + const mockOnRoleChange = vi.fn<(userId: string, newRole: WorkspaceMemberRole) => Promise>(); + const mockOnRemove = vi.fn<(userId: string) => Promise>(); + + const defaultProps = { + currentUserId: "user-1", + currentUserRole: WorkspaceMemberRole.ADMIN, + workspaceOwnerId: "owner-1", + onRoleChange: mockOnRoleChange, + onRemove: mockOnRemove, + }; + + beforeEach((): void => { + mockOnRoleChange.mockReset(); + mockOnRoleChange.mockResolvedValue(undefined); + mockOnRemove.mockReset(); + mockOnRemove.mockResolvedValue(undefined); + }); + + it("should render member list with correct count", (): void => { + const members = [makeMember({ userId: "user-1" }), makeMember({ userId: "user-2" })]; + render(); + expect(screen.getByText("Members (2)")).toBeInTheDocument(); + }); + + it("should display member name and email", (): void => { + const members = [ + makeMember({ + userId: "user-2", + user: { + id: "user-2", + name: "Jane Doe", + email: "jane@example.com", + emailVerified: true, + image: null, + authProviderId: "auth-2", + preferences: {}, + createdAt: new Date("2025-01-01"), + updatedAt: new Date("2025-01-01"), + }, + }), + ]; + render(); + expect(screen.getByText("Jane Doe")).toBeInTheDocument(); + expect(screen.getByText("jane@example.com")).toBeInTheDocument(); + }); + + it("should show (you) indicator for current user", (): void => { + const members = [makeMember({ userId: "user-1" })]; + render(); + expect(screen.getByText("(you)")).toBeInTheDocument(); + }); + + it("should call onRoleChange with validated role when admin changes a member role", async (): Promise => { + const user = userEvent.setup(); + const members = [ + makeMember({ userId: "user-1" }), + makeMember({ userId: "user-2", role: WorkspaceMemberRole.MEMBER }), + ]; + render(); + + const roleSelect = screen.getByDisplayValue("Member"); + await user.selectOptions(roleSelect, WorkspaceMemberRole.GUEST); + + expect(mockOnRoleChange).toHaveBeenCalledWith("user-2", WorkspaceMemberRole.GUEST); + }); + + it("should not show role select for the workspace owner", (): void => { + const members = [ + makeMember({ userId: "owner-1", role: WorkspaceMemberRole.OWNER }), + makeMember({ userId: "user-1", role: WorkspaceMemberRole.ADMIN }), + ]; + render(); + expect(screen.getByText("OWNER")).toBeInTheDocument(); + }); + + it("should not show remove button for the workspace owner", (): void => { + const members = [makeMember({ userId: "owner-1", role: WorkspaceMemberRole.OWNER })]; + render(); + expect(screen.queryByLabelText("Remove member")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/workspace/MemberList.tsx b/apps/web/src/components/workspace/MemberList.tsx index 199b111..19fcb68 100644 --- a/apps/web/src/components/workspace/MemberList.tsx +++ b/apps/web/src/components/workspace/MemberList.tsx @@ -2,6 +2,7 @@ import type { User, WorkspaceMember } from "@mosaic/shared"; import { WorkspaceMemberRole } from "@mosaic/shared"; +import { toWorkspaceMemberRole } from "./validation"; export interface WorkspaceMemberWithUser extends WorkspaceMember { user: User; @@ -88,7 +89,7 @@ export function MemberList({