mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-24 03:52:52 +00:00
feat(client): configurable leaderboard and team stats columns (#4646)
## Description: - rips out some duped code, making tables use the same layout, where the only thing different is the row content. - updated text to be emojis to save space - updated columns to be as wide as they need to be - updated cells to have left/right thin borders to read easier - added more supported column types - removed the + button to always show everyone, where the current player is either at the bottom or in the top 5 - added a cogwheel to change options - can select different options for each menu type (player/team stats) <img width="582" height="630" alt="image" src="https://github.com/user-attachments/assets/e54dc3d5-eb2c-4bab-8732-06bea68aec45" /> works fine on mobile, will probably need some future love though (has h-scroll, so all options can be seen by scrolling): ## 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
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { PlayerStats } from "../src/client/hud/layers/PlayerStats";
|
||||
import type { GameView, PlayerView } from "../src/client/view";
|
||||
import { UserSettings } from "../src/core/game/UserSettings";
|
||||
|
||||
function player(id: string, tiles: number): PlayerView {
|
||||
return {
|
||||
id: () => id,
|
||||
displayName: () => id,
|
||||
numTilesOwned: () => tiles,
|
||||
gold: () => BigInt(tiles),
|
||||
troops: () => tiles,
|
||||
totalUnitLevels: () => tiles,
|
||||
isAlive: () => true,
|
||||
isOnSameTeam: () => false,
|
||||
} as unknown as PlayerView;
|
||||
}
|
||||
|
||||
function gameWith(players: PlayerView[], me: PlayerView | null): GameView {
|
||||
return {
|
||||
myPlayer: () => me,
|
||||
playerViews: () => players,
|
||||
config: () => ({ maxTroops: () => 100 }),
|
||||
numLandTiles: () => 100,
|
||||
numTilesWithFallout: () => 0,
|
||||
} as unknown as GameView;
|
||||
}
|
||||
|
||||
async function mount(game: GameView): Promise<PlayerStats> {
|
||||
const playerStats = new PlayerStats();
|
||||
playerStats.game = game;
|
||||
playerStats.visible = true;
|
||||
document.body.append(playerStats);
|
||||
playerStats.refresh();
|
||||
await playerStats.updateComplete;
|
||||
return playerStats;
|
||||
}
|
||||
|
||||
function scrollRows(el: PlayerStats): string[] {
|
||||
return [...el.querySelectorAll(".stats-table-scroll .stats-table-row")].map(
|
||||
(row) => row.textContent?.trim().split(/\s+/)[0] ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
// jsdom reports zero element sizes, so the component falls back to its
|
||||
// defaults: 24px rows, 180px viewport, 4 overscan rows. The expected
|
||||
// window is ceil(180 / 24) + 4 = 12 rows at scrollTop 0.
|
||||
describe("StatsTable virtualization", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
(
|
||||
UserSettings as unknown as { cache: Map<string, string | null> }
|
||||
).cache.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("renders only a window of rows for large player lists", async () => {
|
||||
const players = Array.from({ length: 300 }, (_, i) =>
|
||||
player(`p${i}`, 1000 - i),
|
||||
);
|
||||
const el = await mount(gameWith(players, null));
|
||||
|
||||
expect(scrollRows(el)).toEqual(
|
||||
Array.from({ length: 12 }, (_, i) => `${i + 1}`),
|
||||
);
|
||||
const bottomSpacer = el.querySelector(
|
||||
".stats-table-scroll .stats-table-spacer:last-child",
|
||||
);
|
||||
expect(bottomSpacer?.getAttribute("style")).toContain(
|
||||
`height: ${(300 - 12) * 24}px`,
|
||||
);
|
||||
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("reveals later rows with a top spacer when scrolled", async () => {
|
||||
const players = Array.from({ length: 300 }, (_, i) =>
|
||||
player(`p${i}`, 1000 - i),
|
||||
);
|
||||
const el = await mount(gameWith(players, null));
|
||||
|
||||
const scroller = el.querySelector<HTMLElement>(".stats-table-scroll")!;
|
||||
scroller.scrollTop = 240;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
await el.updateComplete;
|
||||
|
||||
// first = floor(240/24) - 4 = 6; last = ceil((240+180)/24) + 4 = 22
|
||||
expect(scrollRows(el)).toEqual(
|
||||
Array.from({ length: 16 }, (_, i) => `${i + 7}`),
|
||||
);
|
||||
const topSpacer = scroller.querySelector(".stats-table-spacer");
|
||||
expect(topSpacer?.getAttribute("style")).toContain(`height: ${6 * 24}px`);
|
||||
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("renders the pinned row even when it is far outside the window", async () => {
|
||||
const players = Array.from({ length: 299 }, (_, i) =>
|
||||
player(`p${i}`, 1000 - i),
|
||||
);
|
||||
const me = player("me", 1);
|
||||
players.push(me);
|
||||
const el = await mount(gameWith(players, me));
|
||||
|
||||
const pinned = el.querySelector(".stats-table-pinned-row");
|
||||
expect(pinned?.textContent).toContain("me");
|
||||
expect(pinned?.textContent?.trim().split(/\s+/)[0]).toBe("300");
|
||||
expect(scrollRows(el)).toEqual(
|
||||
Array.from({ length: 12 }, (_, i) => `${i + 1}`),
|
||||
);
|
||||
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("renders small lists fully without spacers", async () => {
|
||||
const players = Array.from({ length: 7 }, (_, i) =>
|
||||
player(`p${i}`, 100 - i),
|
||||
);
|
||||
const el = await mount(gameWith(players, null));
|
||||
|
||||
expect(scrollRows(el)).toEqual(["1", "2", "3", "4", "5", "6", "7"]);
|
||||
expect(el.querySelector(".stats-table-spacer")).toBeNull();
|
||||
|
||||
el.remove();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user