Files
Ryan df05d21fc2 Clan System Part 2 - UI (#3625)
## Description:

Continuation from #3276 

Adds the complete client-side clan UI as a Lit web component
(`<clan-modal>`), a typed API client with Zod-validated responses,
shared response schemas, and a reusable `<confirm-dialog>` component.


### New: `ClanModal.ts`

| View | What it does |
|------|-------------|
| **My Clans** | Lists joined clans + pending join requests (built from
`/users/@me`, no extra fetches) |
| **Browse** | Search by tag (min 3 chars), paginated results,
configurable per-page (10/25/50) |
| **Clan Detail** | Stats, paginated + searchable member list, role
badges, join/leave/request actions |
| **Manage** | Edit name (max 35 chars) + description, toggle
open/invite-only, disband |
| **Transfer** | Leadership transfer with member selector + confirmation
|
| **Requests** | Approve/deny join requests (leader/officer) |
| **Bans** | View and unban (leader/officer) |
| **My Requests** | View and withdraw outgoing requests |

### New: `ConfirmDialog.ts`

Reusable `<confirm-dialog>` Lit component — replaces native
`confirm()`/`prompt()` which are blocked or broken on mobile and
CrazyGames iframes. Supports danger/warning variants and an optional
textarea (used for ban reasons). Fires `confirm`/`cancel` events.

### New: `ClanApi.ts`

Typed API client covering all clan endpoints. Every response is
Zod-validated. Auth header is always last in the spread (can't be
overridden by callers). Unknown server error messages always fall back
to a generic client-side string — never displayed verbatim.

### New: `ClanApiSchemas.ts` (in `src/core/`)

Shared Zod schemas for clan API responses with max-length constraints on
`name` (35) and `description` (200). Lives in `core/` so it can be
consumed by both client code and the leaderboard table.

### Modified: `ApiSchemas.ts`

- Added `clans` and `clanRequests` arrays to `UserMeResponseSchema`
- Moved clan leaderboard schemas out to `ClanApiSchemas.ts`
- Renamed `LeaderboardClanTagSchema` → `RequiredClanTagSchema`

### Modified: `Api.ts`

- Added `invalidateUserMe()` to bust the cached `/users/me` response
after mutations
- Removed `fetchClanLeaderboard` (moved to `ClanApi.ts`)

### Tests

- `ClanModal.test.ts` — rendering, view navigation, user actions
- `ClanApiQueries.test.ts` — fetch functions, error handling, pagination
- `ClanApiMutations.test.ts` — join, leave, kick, ban, promote,
transfer, etc.
- `ClanApiBans.test.ts` — ban/unban calls and error paths
- `ClanApiSchemas.test.ts` — Zod schema validation edge cases
- `LeaderboardModal.test.ts` — updated imports

## Notable design decisions

- **Not-logged-in state** — shows "Sign in to join clans" instead of
false "no clans" empty state
- **Rate limit feedback** — reads `Retry-After` header and surfaces wait
time to the user

## 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
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

w.o.n

---------

Co-authored-by: evanpelle <evanpelle@gmail.com>
2026-04-30 21:27:35 -06:00

144 lines
4.9 KiB
TypeScript

import { render } from "lit";
import { describe, expect, it } from "vitest";
import type {
ClanJoinRequest,
ClanMember,
ClanMemberStats,
} from "../../../src/client/ClanApi";
import {
filterMembersBySearch,
filterRequestsBySearch,
renderMemberStats,
} from "../../../src/client/components/clan/ClanShared";
const members: ClanMember[] = [
{ publicId: "Alice123", role: "leader", joinedAt: "2024-01-01T00:00:00Z" },
{ publicId: "Bob456", role: "officer", joinedAt: "2024-02-01T00:00:00Z" },
{ publicId: "Charlie789", role: "member", joinedAt: "2024-03-01T00:00:00Z" },
];
const requests: ClanJoinRequest[] = [
{ publicId: "Dave111", createdAt: "2024-04-01T00:00:00Z" },
{ publicId: "Eve222", createdAt: "2024-05-01T00:00:00Z" },
];
describe("filterMembersBySearch", () => {
it("returns all members when search is empty", () => {
expect(filterMembersBySearch(members, "")).toEqual(members);
});
it("matches by publicId (case-insensitive)", () => {
const result = filterMembersBySearch(members, "alice");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Alice123");
});
it("matches by role", () => {
const result = filterMembersBySearch(members, "officer");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Bob456");
});
it("matches partial publicId", () => {
const result = filterMembersBySearch(members, "456");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Bob456");
});
it("returns empty array when nothing matches", () => {
expect(filterMembersBySearch(members, "zzz")).toEqual([]);
});
it("matches 'member' role without matching 'leader' or 'officer'", () => {
const result = filterMembersBySearch(members, "member");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Charlie789");
});
});
describe("renderMemberStats", () => {
const stats: ClanMemberStats = {
total: { wins: 7, losses: 5 },
ffa: { wins: 2, losses: 4 },
team: { wins: 5, losses: 1 },
hvn: { wins: 0, losses: 0 },
ranked: { wins: 0, losses: 0 },
"1v1": { wins: 0, losses: 0 },
};
function renderTo(result: ReturnType<typeof renderMemberStats>): HTMLElement {
const host = document.createElement("div");
render(result, host);
return host;
}
it("renders nothing when stats is undefined", () => {
const host = renderTo(renderMemberStats(undefined));
expect(host.textContent?.trim()).toBe("");
});
it("renders W/L labels inside bar segments and the win-rate per bucket", () => {
const host = renderTo(renderMemberStats(stats));
const text = host.textContent?.replace(/\s+/g, " ") ?? "";
// Each bucket with games shows `{wins}W` and `{losses}L` inside segments
expect(text).toContain("2W");
expect(text).toContain("4L");
expect(text).toContain("5W");
expect(text).toContain("1L");
// Win-rate, and em-dash placeholder for empty bucket
expect(text).toContain("33%");
expect(text).toContain("83%");
expect(text).toContain("—");
});
it("renders a proportional win-loss bar when there are games", () => {
const host = renderTo(renderMemberStats(stats));
const bars = host.querySelectorAll<HTMLDivElement>("[style*='width']");
// Two segments per bucket with games (total: 2, ffa: 2, team: 2). Ranked
// and 1v1 have 0 games → no segments.
expect(bars.length).toBe(6);
const widths = Array.from(bars).map((b) =>
(b.getAttribute("style") ?? "").replace(/\s+/g, ""),
);
// total: 7/12 ≈ 58.3% wins, 41.7% losses
expect(widths[0]).toContain("width:58.33");
expect(widths[1]).toContain("width:41.66");
// ffa: 2/6 ≈ 33.3% wins, 66.7% losses
expect(widths[2]).toContain("width:33.33");
expect(widths[3]).toContain("width:66.66");
});
it("includes all six translated bucket labels", () => {
const host = renderTo(renderMemberStats(stats));
const text = host.textContent ?? "";
expect(text).toContain("clan_modal.stats_total");
expect(text).toContain("clan_modal.stats_ffa");
expect(text).toContain("clan_modal.stats_team");
expect(text).toContain("clan_modal.stats_hvn");
expect(text).toContain("clan_modal.stats_ranked");
expect(text).toContain("clan_modal.stats_1v1");
});
});
describe("filterRequestsBySearch", () => {
it("returns all requests when search is empty", () => {
expect(filterRequestsBySearch(requests, "")).toEqual(requests);
});
it("matches by publicId (case-insensitive)", () => {
const result = filterRequestsBySearch(requests, "dave");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Dave111");
});
it("matches partial publicId", () => {
const result = filterRequestsBySearch(requests, "222");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Eve222");
});
it("returns empty array when nothing matches", () => {
expect(filterRequestsBySearch(requests, "zzz")).toEqual([]);
});
});