From f070d4843de9c7448ddcaed6301cfcb1abb94d7d Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:18:32 +0100 Subject: [PATCH] make it a dedicated stats modal, not under account (#4627) ## Description: seperates the stats modal into a dedicated path, with gameID support e.g. : #modal=stats&gameID=VvyXpL9x ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- index.html | 5 + src/client/AccountModal.ts | 71 +++-------- src/client/GameStatsModal.ts | 59 +++++++++ src/client/LangSelector.ts | 1 + src/client/Main.ts | 5 + .../client/AccountGameStatsNavigation.test.ts | 112 ++++++++++++------ tests/client/GameInfoView.test.ts | 35 ++++++ tests/client/GameStatsModal.test.ts | 96 +++++++++++++++ 8 files changed, 294 insertions(+), 90 deletions(-) create mode 100644 src/client/GameStatsModal.ts create mode 100644 tests/client/GameStatsModal.test.ts diff --git a/index.html b/index.html index f2e53c14f..1edcbad0d 100644 --- a/index.html +++ b/index.html @@ -267,6 +267,11 @@ inline class="hidden w-full h-full page-content relative z-50" > + this.backToGames(), - ariaLabel: translateText("common.back"), - }); - } - const isLoggedIn = !!this.userMeResponse?.user; const publicId = this.userMeResponse?.player?.publicId ?? ""; const displayId = publicId || translateText("account_modal.not_found"); @@ -154,9 +144,6 @@ export class AccountModal extends BaseModal { if (this.isLoadingUser || !this.isLinkedAccount()) { return {}; } - if (this.selectedGameId) { - return {}; - } return { tabs: [ { key: "account", label: translateText("account_modal.tab_account") }, @@ -181,15 +168,6 @@ export class AccountModal extends BaseModal { : this.renderLoginOptions()} `; } - if (this.selectedGameId) { - return html` -
-
- -
-
- `; - } return html`
${this.renderTab(tab)}
@@ -602,15 +580,15 @@ export class AccountModal extends BaseModal { private openGameStats(gameId: string): void { this.gamesScrollTop = this.modalEl?.getScrollTop() ?? 0; - this.modalEl?.setScrollTop(0); - this.selectedGameId = gameId; - modalRouter.syncArgs(this.routerName, { tab: null, gameID: gameId }); + const statsModal = document.querySelector< + HTMLElement & { openFromAccount(gameId: string): void } + >("game-stats-modal"); + statsModal?.openFromAccount(gameId); } - private backToGames(): void { - this.selectedGameId = null; - modalRouter.syncArgs(this.routerName, { gameID: null, tab: "games" }); - void this.restoreGamesScroll(); + public returnToGames(): void { + this.restoreGamesScrollAfterOpen = true; + this.open({ tab: "games" }); } private async restoreGamesScroll(): Promise { @@ -623,15 +601,20 @@ export class AccountModal extends BaseModal { this.modalEl?.setScrollTop(this.gamesScrollTop); } - private resetGameStatsNavigation(): void { - this.selectedGameId = null; - this.gamesScrollTop = 0; + private finishLoadingUser(): void { + this.isLoadingUser = false; + this.requestUpdate(); + if (this.restoreGamesScrollAfterOpen) { + this.restoreGamesScrollAfterOpen = false; + void this.restoreGamesScroll(); + } } private resetPlayerData(): void { this.statsTree = null; this.gameHistoryCache = null; - this.resetGameStatsNavigation(); + this.gamesScrollTop = 0; + this.restoreGamesScrollAfterOpen = false; } private renderLogoutButton(): TemplateResult { @@ -830,19 +813,6 @@ export class AccountModal extends BaseModal { } protected onOpen(args?: Record): void { - if (args !== undefined) { - const gameId = - typeof args.gameID === "string" && args.gameID.length > 0 - ? args.gameID - : null; - this.resetGameStatsNavigation(); - if (gameId) { - this.activeTab = "games"; - this.selectedGameId = gameId; - this.modalEl?.setScrollTop(0); - } - } - this.isLoadingUser = true; this.handleLinkResult(args); @@ -861,19 +831,16 @@ export class AccountModal extends BaseModal { this.loadPlayerProfile(this.userMeResponse.player.publicId); } } - this.isLoadingUser = false; - this.requestUpdate(); + this.finishLoadingUser(); }) .catch((err) => { console.warn("Failed to fetch user info in AccountModal.open():", err); - this.isLoadingUser = false; - this.requestUpdate(); + this.finishLoadingUser(); }); this.requestUpdate(); } protected onClose(): void { - this.resetGameStatsNavigation(); this.dispatchEvent( new CustomEvent("close", { bubbles: true, composed: true }), ); diff --git a/src/client/GameStatsModal.ts b/src/client/GameStatsModal.ts new file mode 100644 index 000000000..1e88a83c6 --- /dev/null +++ b/src/client/GameStatsModal.ts @@ -0,0 +1,59 @@ +import { html } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import "./components/baseComponents/stats/GameInfoView"; +import { BaseModal } from "./components/BaseModal"; +import { modalHeader } from "./components/ui/ModalHeader"; +import { translateText } from "./Utils"; + +@customElement("game-stats-modal") +export class GameStatsModal extends BaseModal { + protected routerName = "stats"; + + @state() private gameId: string | null = null; + private openedFromAccount = false; + + protected renderHeaderSlot() { + return modalHeader({ + title: translateText("game_list.stats"), + onBack: () => this.back(), + ariaLabel: translateText("common.back"), + }); + } + + protected renderBody() { + return html` +
+
+ +
+
+ `; + } + + protected onOpen(args?: Record): void { + this.gameId = + typeof args?.gameID === "string" && args.gameID.length > 0 + ? args.gameID + : null; + } + + protected onClose(): void { + this.gameId = null; + this.openedFromAccount = false; + } + + public openFromAccount(gameId: string): void { + this.openedFromAccount = true; + this.open({ gameID: gameId }); + } + + private back(): void { + const returnToAccount = this.openedFromAccount; + this.close(); + if (!returnToAccount) return; + + document + .querySelector("account-modal") + ?.returnToGames(); + } +} diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 62d22672e..f5426c796 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -233,6 +233,7 @@ export class LangSelector extends LitElement { "news-modal", "news-button", "account-modal", + "game-stats-modal", "game-info-view", "ranking-controls", "ranking-header", diff --git a/src/client/Main.ts b/src/client/Main.ts index ef2f45ad2..22fd8f2d4 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -33,6 +33,7 @@ import { FlagInputModal } from "./FlagInputModal"; import "./GameModeSelector"; import { GameModeSelector } from "./GameModeSelector"; import { GameStartingModal } from "./GameStartingModal"; +import "./GameStatsModal"; import "./GoogleAdElement"; import { HelpModal } from "./HelpModal"; import "./HomepagePromos"; @@ -301,6 +302,10 @@ class Client { tag: "account-modal", pageId: "page-account", }); + modalRouter.register("stats", { + tag: "game-stats-modal", + pageId: "page-stats", + }); modalRouter.register("help", { tag: "help-modal", pageId: "page-help" }); modalRouter.register("news", { tag: "news-modal", pageId: "page-news" }); modalRouter.register("language", { diff --git a/tests/client/AccountGameStatsNavigation.test.ts b/tests/client/AccountGameStatsNavigation.test.ts index b80371718..63f7af8cf 100644 --- a/tests/client/AccountGameStatsNavigation.test.ts +++ b/tests/client/AccountGameStatsNavigation.test.ts @@ -1,4 +1,13 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; vi.mock("../../src/client/Api", () => ({ fetchPlayerById: vi.fn(async () => ({ stats: {} })), @@ -114,7 +123,9 @@ vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver); import { AccountModal } from "../../src/client/AccountModal"; import { fetchPublicPlayerGames } from "../../src/client/Api"; +import { GameStatsModal } from "../../src/client/GameStatsModal"; import { modalRouter } from "../../src/client/ModalRouter"; +import { initNavigation } from "../../src/client/Navigation"; type ModalShell = HTMLElement & { getScrollTop(): number; @@ -138,20 +149,66 @@ async function waitForModal( }); } +async function waitForStatsModal( + modal: GameStatsModal, + assertion: () => void, +): Promise { + await vi.waitFor(async () => { + await modal.updateComplete; + const shell = modal.querySelector("o-modal") as ModalShell | null; + await shell?.updateComplete; + assertion(); + }); +} + describe("Account Games stats navigation", () => { let modal: AccountModal; + let statsModal: GameStatsModal; + let playPage: HTMLElement; + + beforeAll(() => { + playPage = document.createElement("div"); + playPage.id = "page-play"; + document.body.appendChild(playPage); + initNavigation(); + }); + + afterAll(() => { + playPage.remove(); + }); beforeEach(async () => { vi.clearAllMocks(); history.replaceState(null, "", "/"); - modalRouter.register("account", { tag: "account-modal" }); + modalRouter.register("account", { + tag: "account-modal", + pageId: "page-account", + }); + modalRouter.register("stats", { + tag: "game-stats-modal", + pageId: "page-stats", + }); if (!customElements.get("account-modal")) { customElements.define("account-modal", AccountModal); } + if (!customElements.get("game-stats-modal")) { + customElements.define("game-stats-modal", GameStatsModal); + } modal = document.createElement("account-modal") as AccountModal; + modal.id = "page-account"; + modal.setAttribute("inline", ""); + modal.className = "hidden page-content"; document.body.appendChild(modal); + + statsModal = document.createElement("game-stats-modal") as GameStatsModal; + statsModal.id = "page-stats"; + statsModal.setAttribute("inline", ""); + statsModal.className = "hidden page-content"; + document.body.appendChild(statsModal); + await modal.updateComplete; - modal.open(); + await statsModal.updateComplete; + window.showPage?.("page-account"); await waitForModal(modal, () => { const shell = modal.querySelector("o-modal") as ModalShell; expect(shell.shadowRoot?.textContent).toContain( @@ -165,8 +222,9 @@ describe("Account Games stats navigation", () => { }); afterEach(() => { - modal.close(); + window.showPage?.("page-play"); modal.remove(); + statsModal.remove(); history.replaceState(null, "", "/"); }); @@ -179,20 +237,22 @@ describe("Account Games stats navigation", () => { ); expect(statsButton).toBeTruthy(); statsButton!.click(); - await waitForModal(modal, () => { - expect(modal.querySelector("game-info-view")).not.toBeNull(); + await waitForStatsModal(statsModal, () => { + expect(statsModal.isOpen()).toBe(true); + expect(statsModal.querySelector("game-info-view")).not.toBeNull(); }); - expect(modal.textContent).toContain("game_list.stats"); - const statsView = modal.querySelector("game-info-view") as HTMLElement & { - gameId: string; - }; + expect(modal.isOpen()).toBe(false); + expect(statsModal.textContent).toContain("game_list.stats"); + const statsView = statsModal.querySelector( + "game-info-view", + ) as HTMLElement & { gameId: string }; expect(statsView.gameId).toBe("game-1"); - expect(shell.shadowRoot?.querySelector('[role="tablist"]')).toBeNull(); - expect(shell.getScrollTop()).toBe(0); - expect(window.location.hash).toBe("#modal=account&gameID=game-1"); + const statsShell = statsModal.querySelector("o-modal") as ModalShell; + expect(statsShell.shadowRoot?.querySelector('[role="tablist"]')).toBeNull(); + expect(window.location.hash).toBe("#modal=stats&gameID=game-1"); - const backButton = modal.querySelector( + const backButton = statsModal.querySelector( '[slot="header"] button', ) as HTMLButtonElement; backButton.click(); @@ -204,28 +264,4 @@ describe("Account Games stats navigation", () => { expect(fetchPublicPlayerGames).toHaveBeenCalledOnce(); }); - - it("opens a gameID route directly and returns to Games", async () => { - modal.setActiveTab("account"); - modal.close(); - modal.open({ gameID: "game-1" }); - - await waitForModal(modal, () => { - const statsView = modal.querySelector("game-info-view") as - | (HTMLElement & { gameId: string }) - | null; - expect(statsView?.gameId).toBe("game-1"); - }); - expect(window.location.hash).toBe("#modal=account&gameID=game-1"); - - const backButton = modal.querySelector( - '[slot="header"] button', - ) as HTMLButtonElement; - backButton.click(); - - await waitForModal(modal, () => { - expect(modal.querySelector("player-game-history-view")).not.toBeNull(); - }); - expect(window.location.hash).toBe("#modal=account&tab=games"); - }); }); diff --git a/tests/client/GameInfoView.test.ts b/tests/client/GameInfoView.test.ts index c792bd6b7..a9d39cfe1 100644 --- a/tests/client/GameInfoView.test.ts +++ b/tests/client/GameInfoView.test.ts @@ -154,6 +154,41 @@ describe("GameInfoView", () => { }); }); + it("renders a fetch error and recovers when Retry succeeds", async () => { + const retry = deferred(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + fetchMock + .mockRejectedValueOnce(new Error("network failure")) + .mockReturnValueOnce(retry.promise); + view = mountView("retry-game"); + + await waitForRender(view, () => { + expect(view!.textContent).toContain("game_info_modal.load_failed"); + expect(view!.querySelector("button")?.textContent).toContain( + "game_info_modal.retry", + ); + }); + + const retryButton = view.querySelector("button") as HTMLButtonElement; + retryButton.click(); + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(fetchMock).toHaveBeenNthCalledWith(2, "retry-game"); + + retry.resolve(makeSession("retry-game", GameMapType.Montreal)); + await retry.promise; + await waitForRender(view, () => { + expect(view!.textContent).toContain(GameMapType.Montreal); + expect(view!.textContent).not.toContain("game_info_modal.load_failed"); + expect(view!.querySelectorAll("player-row")).toHaveLength(1); + }); + } finally { + errorSpy.mockRestore(); + } + }); + it("clears rendered game state when gameId becomes null", async () => { fetchMock.mockResolvedValue(makeSession("game-1", GameMapType.Montreal)); view = mountView("game-1"); diff --git a/tests/client/GameStatsModal.test.ts b/tests/client/GameStatsModal.test.ts new file mode 100644 index 000000000..8ac725616 --- /dev/null +++ b/tests/client/GameStatsModal.test.ts @@ -0,0 +1,96 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +vi.mock("../../src/client/Utils", () => ({ + translateText: vi.fn((key: string) => key), +})); + +vi.mock("../../src/client/components/baseComponents/stats/GameInfoView", () => { + class FakeGameInfoView extends HTMLElement {} + if (!customElements.get("game-info-view")) { + customElements.define("game-info-view", FakeGameInfoView); + } + return { GameInfoView: FakeGameInfoView }; +}); + +import { GameStatsModal } from "../../src/client/GameStatsModal"; +import { modalRouter } from "../../src/client/ModalRouter"; +import { initNavigation } from "../../src/client/Navigation"; + +type ModalShell = HTMLElement & { updateComplete: Promise }; + +describe("public game stats route", () => { + let modal: GameStatsModal; + let playPage: HTMLElement; + + beforeAll(() => { + playPage = document.createElement("div"); + playPage.id = "page-play"; + document.body.appendChild(playPage); + initNavigation(); + }); + + afterAll(() => { + playPage.remove(); + }); + + beforeEach(async () => { + history.replaceState(null, "", "/"); + modalRouter.register("stats", { + tag: "game-stats-modal", + pageId: "page-stats", + }); + if (!customElements.get("game-stats-modal")) { + customElements.define("game-stats-modal", GameStatsModal); + } + + modal = document.createElement("game-stats-modal") as GameStatsModal; + modal.id = "page-stats"; + modal.setAttribute("inline", ""); + modal.className = "hidden page-content"; + document.body.appendChild(modal); + await modal.updateComplete; + }); + + afterEach(() => { + window.showPage?.("page-play"); + modal.remove(); + history.replaceState(null, "", "/"); + }); + + it("opens a shared gameID without mounting the authenticated account", async () => { + history.replaceState(null, "", "/#modal=stats&gameID=public-game"); + + expect(modalRouter.routeFromHash()).toBe(true); + + await vi.waitFor(async () => { + await modal.updateComplete; + const shell = modal.querySelector("o-modal") as ModalShell | null; + await shell?.updateComplete; + const statsView = modal.querySelector("game-info-view") as + | (HTMLElement & { gameId: string | null }) + | null; + expect(modal.isOpen()).toBe(true); + expect(statsView?.gameId).toBe("public-game"); + }); + + expect(document.querySelector("account-modal")).toBeNull(); + expect(window.location.hash).toBe("#modal=stats&gameID=public-game"); + + const backButton = modal.querySelector( + '[slot="header"] button', + ) as HTMLButtonElement; + backButton.click(); + + expect(modal.isOpen()).toBe(false); + expect(window.location.hash).toBe(""); + }); +});