Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/web/src/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,14 @@ export const createAuthenticatedRoutes = (
element: <GameDetailLayoutWrapper />,
children: [
{ index: true, element: <GameIndexRoute /> },
{
path: "overview",
element: (
<Suspense fallback={<RouteFallback />}>
<GameDetail.OverviewScreen />
</Suspense>
),
},
{
path: "orders",
element: (
Expand Down
20 changes: 12 additions & 8 deletions packages/web/src/api/generated/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ export interface Channel {
readonly private: boolean;
readonly messages: readonly ChannelMessage[];
readonly unreadMessageCount: number;
readonly memberIds: readonly number[];
}

export interface ChannelCreate {
memberIds: number[];
}

Expand Down Expand Up @@ -7225,14 +7229,14 @@ method that returns the game object. Also adds game to the serializer context.
*/
export const gamesChannelsCreateCreate = (
gameId: string,
channel: NonReadonly<Channel>,
channelCreate: ChannelCreate,
signal?: AbortSignal
) => {
return customInstance<Channel>({
url: `/games/${gameId}/channels/create/`,
method: "POST",
headers: { "Content-Type": "application/json" },
data: channel,
data: channelCreate,
signal,
});
};
Expand All @@ -7244,13 +7248,13 @@ export const getGamesChannelsCreateCreateMutationOptions = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof gamesChannelsCreateCreate>>,
TError,
{ gameId: string; data: NonReadonly<Channel> },
{ gameId: string; data: ChannelCreate },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof gamesChannelsCreateCreate>>,
TError,
{ gameId: string; data: NonReadonly<Channel> },
{ gameId: string; data: ChannelCreate },
TContext
> => {
const mutationKey = ["gamesChannelsCreateCreate"];
Expand All @@ -7264,7 +7268,7 @@ export const getGamesChannelsCreateCreateMutationOptions = <

const mutationFn: MutationFunction<
Awaited<ReturnType<typeof gamesChannelsCreateCreate>>,
{ gameId: string; data: NonReadonly<Channel> }
{ gameId: string; data: ChannelCreate }
> = props => {
const { gameId, data } = props ?? {};

Expand All @@ -7277,7 +7281,7 @@ export const getGamesChannelsCreateCreateMutationOptions = <
export type GamesChannelsCreateCreateMutationResult = NonNullable<
Awaited<ReturnType<typeof gamesChannelsCreateCreate>>
>;
export type GamesChannelsCreateCreateMutationBody = NonReadonly<Channel>;
export type GamesChannelsCreateCreateMutationBody = ChannelCreate;
export type GamesChannelsCreateCreateMutationError = unknown;

export const useGamesChannelsCreateCreate = <
Expand All @@ -7288,15 +7292,15 @@ export const useGamesChannelsCreateCreate = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof gamesChannelsCreateCreate>>,
TError,
{ gameId: string; data: NonReadonly<Channel> },
{ gameId: string; data: ChannelCreate },
TContext
>;
},
queryClient?: QueryClient
): UseMutationResult<
Awaited<ReturnType<typeof gamesChannelsCreateCreate>>,
TError,
{ gameId: string; data: NonReadonly<Channel> },
{ gameId: string; data: ChannelCreate },
TContext
> => {
return useMutation(
Expand Down
29 changes: 29 additions & 0 deletions packages/web/src/components/DrawProposalNotificationBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from "react";

import type { DrawProposal } from "@/api/generated/endpoints";
import { Badge } from "@/components/ui/badge";

export const getDrawProposalNotificationCount = (
proposals: readonly DrawProposal[] | undefined
) =>
proposals?.filter(
proposal =>
proposal.status === "pending" &&
proposal.myVote !== null &&
proposal.myVote.accepted === null
).length ?? 0;

export const DrawProposalNotificationBadge: React.FC<{ count: number }> = ({
count,
}) => {
if (count === 0) return null;

return (
<Badge
variant="destructive"
className="absolute -right-1 -top-1 h-4 w-4 justify-center p-0 text-[10px]"
>
{count}
</Badge>
);
};
101 changes: 101 additions & 0 deletions packages/web/src/components/GameDetailLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router";
import { describe, expect, it, vi } from "vitest";

import { GameDetailLayout } from "./GameDetailLayout";

const mockGameData = vi.fn();
const mockDrawProposalsData = vi.fn();

vi.mock("@/api/generated/endpoints", () => ({
useGameRetrieve: () => ({ data: mockGameData() }),
useGamesDrawProposalsList: () => ({ data: mockDrawProposalsData() }),
}));

vi.mock("@/components/Navigation", () => ({
Navigation: ({
items,
}: {
items: Array<{ label: string; badge?: string; isActive?: boolean }>;
}) => (
<div>
{items.map(item => (
<div
key={item.label}
data-testid={`nav-${item.label}`}
data-badge={item.badge ?? ""}
data-active={item.isActive ? "true" : "false"}
/>
))}
</div>
),
}));

vi.mock("@/components/GameMap", () => ({ GameMap: () => null }));
vi.mock("@/components/OfflineBanner", () => ({ OfflineBanner: () => null }));
vi.mock("@/components/SafeAreaView", () => ({
SafeAreaView: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/ui/sidebar", () => ({
SidebarProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Sidebar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarHeader: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SidebarInset: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

const renderLayout = (
initialEntry = "/game/g1/phase/1/overview",
child = <div>Overview</div>
) =>
render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route
path="/game/:gameId/phase/:phaseId/*"
element={<GameDetailLayout>{child}</GameDetailLayout>}
/>
</Routes>
</MemoryRouter>
);

describe("GameDetailLayout draw-proposal notification", () => {
it("adds a notification dot to each Overview navigation item", () => {
mockGameData.mockReturnValue({
status: "active",
sandbox: false,
members: [],
totalUnreadMessageCount: 0,
});
mockDrawProposalsData.mockReturnValue([
{
id: 1,
status: "pending",
myVote: { included: true, accepted: null },
},
]);

renderLayout();

expect(screen.getAllByTestId("nav-Overview")).toHaveLength(2);
for (const overviewItem of screen.getAllByTestId("nav-Overview")) {
expect(overviewItem).toHaveAttribute("data-badge", "•");
}
});

it("keeps Overview active throughout the Game Info flow", () => {
mockGameData.mockReturnValue({
status: "active",
sandbox: false,
members: [],
totalUnreadMessageCount: 0,
});
mockDrawProposalsData.mockReturnValue([]);

renderLayout("/game/g1/phase/1/game-info", <div>Game Info</div>);

for (const overviewItem of screen.getAllByTestId("nav-Overview")) {
expect(overviewItem).toHaveAttribute("data-active", "true");
}
});
});
59 changes: 53 additions & 6 deletions packages/web/src/components/GameDetailLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import React, { useMemo } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import { useRequiredParams } from "@/hooks";
import { ArrowLeft, Map, Gavel, MessageCircle } from "lucide-react";
import {
ArrowLeft,
Trophy,
Map,
Gavel,
MessageCircle,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
Sidebar,
Expand All @@ -15,9 +21,18 @@ import { Navigation } from "@/components/Navigation";
import { GameMap } from "@/components/GameMap";
import { SafeAreaView } from "@/components/SafeAreaView";
import { OfflineBanner } from "@/components/OfflineBanner";
import { useGameRetrieve } from "@/api/generated/endpoints";
import {
useGameRetrieve,
useGamesDrawProposalsList,
} from "@/api/generated/endpoints";
import { getDrawProposalNotificationCount } from "@/components/DrawProposalNotificationBadge";

const navigationItems = [
{
label: "Overview",
icon: Trophy,
path: "/game/:gameId/phase/:phaseId/overview",
},
{ label: "Map", icon: Map, path: "/game/:gameId/phase/:phaseId" },
{ label: "Orders", icon: Gavel, path: "/game/:gameId/phase/:phaseId/orders" },
{ label: "Chat", icon: MessageCircle, path: "/game/:gameId/phase/:phaseId/chat" },
Expand Down Expand Up @@ -45,6 +60,17 @@ const GameDetailLayout: React.FC<GameDetailLayoutProps> = ({
query.state.data?.status === "active" ? 5000 : false,
},
});
const isStartedGame =
game?.status === "active" ||
game?.status === "completed" ||
game?.status === "abandoned";
const canShowDrawProposals =
!!game && !game.sandbox && isStartedGame;
const { data: drawProposals } = useGamesDrawProposalsList(gameId, {
query: { enabled: canShowDrawProposals },
});
const drawProposalNotificationCount =
getDrawProposalNotificationCount(drawProposals);

const [searchParams] = useSearchParams();

Expand All @@ -55,11 +81,18 @@ const GameDetailLayout: React.FC<GameDetailLayoutProps> = ({
const searchParamsStr = searchParams.toString();
const chatBasePath = `/game/${gameId}/phase/${phaseId}/chat`;
const isInChatChannel = location.pathname.startsWith(chatBasePath + "/");
const overviewFlowPaths = [
`/game/${gameId}/phase/${phaseId}/game-info`,
`/game/${gameId}/phase/${phaseId}/draw-proposals`,
`/game/${gameId}/phase/${phaseId}/propose-draw`,
`/game/${gameId}/phase/${phaseId}/player/`,
];
return items.map(item => {
const basePath = item.path
.replace(":gameId", gameId)
.replace(":phaseId", phaseId);
const badge =
(item.label === "Overview" && drawProposalNotificationCount > 0) ||
(item.label === "Chat" &&
game?.totalUnreadMessageCount &&
game.totalUnreadMessageCount > 0) ||
Expand All @@ -77,17 +110,31 @@ const GameDetailLayout: React.FC<GameDetailLayoutProps> = ({
} else {
path = searchParamsStr ? `${basePath}?${searchParamsStr}` : basePath;
}
const isActive = item.label === "Chat"
? location.pathname === chatBasePath || location.pathname.startsWith(chatBasePath + "/")
: location.pathname === basePath;
const isActive =
item.label === "Chat"
? location.pathname === chatBasePath ||
location.pathname.startsWith(chatBasePath + "/")
: item.label === "Overview"
? location.pathname === basePath ||
overviewFlowPaths.some(path => location.pathname.startsWith(path))
: location.pathname === basePath;
return {
...item,
path,
isActive,
badge,
};
});
}, [gameId, phaseId, searchParams, location.pathname, game?.totalUnreadMessageCount, game?.sandbox, game?.members]);
}, [
gameId,
phaseId,
searchParams,
location.pathname,
game?.totalUnreadMessageCount,
game?.sandbox,
game?.members,
drawProposalNotificationCount,
]);

// Filter out Map for desktop sidebar since map is already visible in right
// panel. Unlike the bottom nav, the sidebar Chat icon should return to the
Expand Down
15 changes: 13 additions & 2 deletions packages/web/src/components/GameDropdownMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}

const renderMenu = (game: React.ComponentProps<typeof GameDropdownMenu>["game"]) => {
const renderMenu = (
game: React.ComponentProps<typeof GameDropdownMenu>["game"],
includePlayerInfo = true
) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
Expand All @@ -31,7 +34,7 @@ const renderMenu = (game: React.ComponentProps<typeof GameDropdownMenu>["game"])
<GameDropdownMenu
game={game}
onNavigateToGameInfo={() => {}}
onNavigateToPlayerInfo={() => {}}
onNavigateToPlayerInfo={includePlayerInfo ? () => {} : undefined}
/>
</MemoryRouter>
</QueryClientProvider>
Expand All @@ -43,6 +46,14 @@ const openMenu = async () => {
};

describe("GameDropdownMenu", () => {
it("hides Player info when the current screen has an Overview tab", async () => {
renderMenu(mockActiveGames[0], false);
await openMenu();
expect(
screen.queryByRole("menuitem", { name: /player info/i })
).not.toBeInTheDocument();
});

it("shows 'Clone to sandbox' for an active, non-sandbox game", async () => {
renderMenu(mockActiveGames[0]);
await openMenu();
Expand Down
12 changes: 7 additions & 5 deletions packages/web/src/components/GameDropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ interface GameDropdownMenuProps {
| "status"
>;
onNavigateToGameInfo: () => void;
onNavigateToPlayerInfo: () => void;
onNavigateToPlayerInfo?: () => void;
}

export function GameDropdownMenu({
Expand Down Expand Up @@ -209,10 +209,12 @@ export function GameDropdownMenu({
<Info />
Game info
</DropdownMenuItem>
<DropdownMenuItem onClick={onNavigateToPlayerInfo}>
<Users />
Player info
</DropdownMenuItem>
{onNavigateToPlayerInfo && (
<DropdownMenuItem onClick={onNavigateToPlayerInfo}>
<Users />
Player info
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => copyLink(`/game/${game.id}`)}>
<Share />
Expand Down
Loading