mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 14:15:23 +00:00
feat(client): shareable player profile modal (#modal=profile&publicID=x) (#4639)
## Description: Add player profile (stats only for now): <img width="952" height="241" alt="image" src="https://github.com/user-attachments/assets/2cc0c1ed-baf6-4cb5-837c-1f82d2850005" /> and clans modal: <img width="927" height="778" alt="image" src="https://github.com/user-attachments/assets/87e7ae53-6531-41cd-8795-4ea1d8fb67af" /> with dedicated url: <img width="1273" height="916" alt="image" src="https://github.com/user-attachments/assets/dd1ccff7-5d3c-4c70-a7d2-0c7399a88a08" /> ## 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 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
const copyToClipboardMock = vi.hoisted(() =>
|
||||
vi.fn(async (_text: string, onSuccess?: () => void) => onSuccess?.()),
|
||||
);
|
||||
|
||||
const fetchPublicPlayerProfileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../src/client/Utils", () => ({
|
||||
translateText: vi.fn((key: string) => key),
|
||||
copyToClipboard: copyToClipboardMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/client/Api", () => ({
|
||||
fetchPublicPlayerProfile: fetchPublicPlayerProfileMock,
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"../../src/client/components/baseComponents/stats/PlayerStatsTree",
|
||||
() => {
|
||||
class FakePlayerStatsTreeView extends HTMLElement {}
|
||||
if (!customElements.get("player-stats-tree-view")) {
|
||||
customElements.define("player-stats-tree-view", FakePlayerStatsTreeView);
|
||||
}
|
||||
return { PlayerStatsTreeView: FakePlayerStatsTreeView };
|
||||
},
|
||||
);
|
||||
|
||||
import type { CopyButton } from "../../src/client/components/CopyButton";
|
||||
import { modalRouter } from "../../src/client/ModalRouter";
|
||||
import { initNavigation } from "../../src/client/Navigation";
|
||||
import {
|
||||
PlayerProfileModal,
|
||||
playerProfileUrl,
|
||||
} from "../../src/client/PlayerProfileModal";
|
||||
|
||||
type ModalShell = HTMLElement & { updateComplete: Promise<boolean> };
|
||||
|
||||
const statsTree = { Public: { "Free For All": {} } };
|
||||
|
||||
describe("public player profile route", () => {
|
||||
let modal: PlayerProfileModal;
|
||||
let playPage: HTMLElement;
|
||||
|
||||
beforeAll(() => {
|
||||
playPage = document.createElement("div");
|
||||
playPage.id = "page-play";
|
||||
document.body.appendChild(playPage);
|
||||
initNavigation();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
playPage.remove();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
copyToClipboardMock.mockClear();
|
||||
fetchPublicPlayerProfileMock.mockReset();
|
||||
vi.stubGlobal("localStorage", { getItem: vi.fn(() => null) });
|
||||
history.replaceState(null, "", "/");
|
||||
modalRouter.register("profile", {
|
||||
tag: "player-profile-modal",
|
||||
pageId: "page-profile",
|
||||
});
|
||||
if (!customElements.get("player-profile-modal")) {
|
||||
customElements.define("player-profile-modal", PlayerProfileModal);
|
||||
}
|
||||
|
||||
modal = document.createElement(
|
||||
"player-profile-modal",
|
||||
) as PlayerProfileModal;
|
||||
modal.id = "page-profile";
|
||||
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, "", "/");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("opens a shared publicID and renders the fetched stats tree", async () => {
|
||||
fetchPublicPlayerProfileMock.mockResolvedValue({
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
stats: statsTree,
|
||||
});
|
||||
history.replaceState(null, "", "/#modal=profile&publicID=shared-player");
|
||||
|
||||
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 treeView = modal.querySelector("player-stats-tree-view") as
|
||||
| (HTMLElement & { statsTree?: unknown })
|
||||
| null;
|
||||
expect(modal.isOpen()).toBe(true);
|
||||
expect(treeView?.statsTree).toEqual(statsTree);
|
||||
});
|
||||
|
||||
expect(fetchPublicPlayerProfileMock).toHaveBeenCalledWith("shared-player");
|
||||
|
||||
const copyButton = modal.querySelector<CopyButton>("copy-button")!;
|
||||
await copyButton.updateComplete;
|
||||
expect(copyButton.copyText).toBe(playerProfileUrl("shared-player"));
|
||||
expect(copyButton.displayText).toBe("shared-player");
|
||||
expect(copyButton.compact).toBe(true);
|
||||
|
||||
expect(window.location.hash).toBe("#modal=profile&publicID=shared-player");
|
||||
|
||||
const backButton = modal.querySelector(
|
||||
'[slot="header"] button',
|
||||
) as HTMLButtonElement;
|
||||
backButton.click();
|
||||
|
||||
expect(modal.isOpen()).toBe(false);
|
||||
expect(window.location.hash).toBe("");
|
||||
});
|
||||
|
||||
it("shows not-found when the profile fetch fails", async () => {
|
||||
fetchPublicPlayerProfileMock.mockResolvedValue(false);
|
||||
history.replaceState(null, "", "/#modal=profile&publicID=missing");
|
||||
|
||||
expect(modalRouter.routeFromHash()).toBe(true);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await modal.updateComplete;
|
||||
expect(modal.isOpen()).toBe(true);
|
||||
expect(modal.querySelector("player-stats-tree-view")).toBeNull();
|
||||
expect(modal.textContent).toContain("player_profile.not_found");
|
||||
});
|
||||
expect(fetchPublicPlayerProfileMock).toHaveBeenCalledWith("missing");
|
||||
});
|
||||
|
||||
it("shows not-found without fetching when publicID is missing", async () => {
|
||||
history.replaceState(null, "", "/#modal=profile");
|
||||
|
||||
expect(modalRouter.routeFromHash()).toBe(true);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await modal.updateComplete;
|
||||
expect(modal.isOpen()).toBe(true);
|
||||
expect(modal.textContent).toContain("player_profile.not_found");
|
||||
});
|
||||
expect(fetchPublicPlayerProfileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns to the clan members tab when opened from a clan", async () => {
|
||||
fetchPublicPlayerProfileMock.mockResolvedValue({
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
stats: statsTree,
|
||||
});
|
||||
const returnToMembers = vi.fn();
|
||||
const fakeClanModal = document.createElement(
|
||||
"clan-modal",
|
||||
) as HTMLElement & {
|
||||
returnToMembers: () => void;
|
||||
};
|
||||
fakeClanModal.returnToMembers = returnToMembers;
|
||||
document.body.appendChild(fakeClanModal);
|
||||
|
||||
modal.openFromClan("clan-member");
|
||||
await modal.updateComplete;
|
||||
expect(modal.isOpen()).toBe(true);
|
||||
|
||||
const backButton = modal.querySelector(
|
||||
'[slot="header"] button',
|
||||
) as HTMLButtonElement;
|
||||
backButton.click();
|
||||
|
||||
expect(modal.isOpen()).toBe(false);
|
||||
expect(returnToMembers).toHaveBeenCalled();
|
||||
fakeClanModal.remove();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user