From 307639eca02cbe24e97a7f2b365604014c4a50cc Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 28 Feb 2026 22:12:04 +0000 Subject: [PATCH] feat(web): add teams settings page (MS21-UI-005) (#576) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- .../src/app/(authenticated)/settings/page.tsx | 25 ++ .../settings/teams/page.test.tsx | 76 ++++++ .../(authenticated)/settings/teams/page.tsx | 244 +++++++++++++++++ .../workspaces/[id]/teams/[teamId]/page.tsx | 137 +--------- .../settings/workspaces/[id]/teams/page.tsx | 109 +++++--- apps/web/src/lib/api/teams.ts | 250 ++++++------------ 6 files changed, 513 insertions(+), 328 deletions(-) create mode 100644 apps/web/src/app/(authenticated)/settings/teams/page.test.tsx create mode 100644 apps/web/src/app/(authenticated)/settings/teams/page.tsx diff --git a/apps/web/src/app/(authenticated)/settings/page.tsx b/apps/web/src/app/(authenticated)/settings/page.tsx index 1138c01..403a2ed 100644 --- a/apps/web/src/app/(authenticated)/settings/page.tsx +++ b/apps/web/src/app/(authenticated)/settings/page.tsx @@ -221,6 +221,31 @@ const categories: CategoryConfig[] = [ ), }, + { + title: "Teams", + description: "Create and manage teams within your active workspace.", + href: "/settings/teams", + accent: "var(--ms-blue-400)", + iconBg: "rgba(47, 128, 255, 0.12)", + icon: ( + + ), + }, { title: "Workspaces", description: diff --git a/apps/web/src/app/(authenticated)/settings/teams/page.test.tsx b/apps/web/src/app/(authenticated)/settings/teams/page.test.tsx new file mode 100644 index 0000000..c55418c --- /dev/null +++ b/apps/web/src/app/(authenticated)/settings/teams/page.test.tsx @@ -0,0 +1,76 @@ +import type { ReactElement, ReactNode } from "react"; +import type { TeamRecord } from "@/lib/api/teams"; + +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchTeams } from "@/lib/api/teams"; + +import TeamsSettingsPage from "./page"; + +vi.mock("next/link", () => ({ + default: function LinkMock({ + children, + href, + }: { + children: ReactNode; + href: string; + }): ReactElement { + return {children}; + }, +})); + +vi.mock("@/lib/api/teams", () => ({ + fetchTeams: vi.fn(), + createTeam: vi.fn(), +})); + +const fetchTeamsMock = vi.mocked(fetchTeams); + +const baseTeam: TeamRecord = { + id: "team-1", + workspaceId: "workspace-1", + name: "Platform Team", + description: "Owns platform services", + metadata: {}, + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-01T00:00:00.000Z", + _count: { + members: 3, + }, +}; + +describe("TeamsSettingsPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loads and renders teams from the API", async () => { + fetchTeamsMock.mockResolvedValue([baseTeam]); + + render(); + + expect(screen.getByText("Loading teams...")).toBeInTheDocument(); + + expect(await screen.findByText("Your Teams (1)")).toBeInTheDocument(); + expect(screen.getByText("Platform Team")).toBeInTheDocument(); + expect(fetchTeamsMock).toHaveBeenCalledTimes(1); + }); + + it("shows empty state when the API returns no teams", async () => { + fetchTeamsMock.mockResolvedValue([]); + + render(); + + expect(await screen.findByText("Your Teams (0)")).toBeInTheDocument(); + expect(screen.getByText("No teams yet")).toBeInTheDocument(); + }); + + it("shows error state and does not show empty state", async () => { + fetchTeamsMock.mockRejectedValue(new Error("Unable to load teams")); + + render(); + + expect(await screen.findByText("Unable to load teams")).toBeInTheDocument(); + expect(screen.queryByText("No teams yet")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(authenticated)/settings/teams/page.tsx b/apps/web/src/app/(authenticated)/settings/teams/page.tsx new file mode 100644 index 0000000..29b792a --- /dev/null +++ b/apps/web/src/app/(authenticated)/settings/teams/page.tsx @@ -0,0 +1,244 @@ +"use client"; + +import type { ReactElement, SyntheticEvent } from "react"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { createTeam, fetchTeams, type CreateTeamDto, type TeamRecord } from "@/lib/api/teams"; + +function getErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error) { + return error.message; + } + + return fallback; +} + +export default function TeamsSettingsPage(): ReactElement { + const [teams, setTeams] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [newTeamName, setNewTeamName] = useState(""); + const [newTeamDescription, setNewTeamDescription] = useState(""); + const [createError, setCreateError] = useState(null); + + const loadTeams = useCallback(async (): Promise => { + setIsLoading(true); + + try { + const data = await fetchTeams(); + setTeams(data); + setLoadError(null); + } catch (error) { + setLoadError(getErrorMessage(error, "Failed to load teams")); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void loadTeams(); + }, [loadTeams]); + + const handleCreateTeam = async (e: SyntheticEvent): Promise => { + e.preventDefault(); + + const teamName = newTeamName.trim(); + if (!teamName) return; + + setIsCreating(true); + setCreateError(null); + + try { + const description = newTeamDescription.trim(); + const dto: CreateTeamDto = { name: teamName }; + if (description.length > 0) { + dto.description = description; + } + + await createTeam(dto); + setNewTeamName(""); + setNewTeamDescription(""); + setIsCreateDialogOpen(false); + await loadTeams(); + } catch (error) { + setCreateError(getErrorMessage(error, "Failed to create team")); + } finally { + setIsCreating(false); + } + }; + + return ( +
+
+
+

Teams

+ + {"<-"} Back to Settings + +
+

Manage teams in your active workspace

+
+ +
+
+
+

Create New Team

+

+ Add a team to organize members and permissions. +

+
+ +
+
+ + {isCreateDialogOpen && ( +
+
+

Create New Team

+

+ Enter a team name and optional description. +

+ +
+
+ + { + setNewTeamName(e.target.value); + }} + placeholder="Enter team name..." + disabled={isCreating} + className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100" + autoFocus + /> +
+ +
+ +