diff --git a/resources/lang/en.json b/resources/lang/en.json index 8fe5e002b3..19caf537ee 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -332,7 +332,7 @@ "role_officer": "Officer", "save_changes": "Save Changes", "saving": "Saving...", - "search_members_placeholder": "Filter current page by ID or role...", + "search_members_placeholder": "Search by exact ID, username, or role...", "search_placeholder": "Search by clan tag...", "search_requests_placeholder": "Search by player public ID...", "select_new_leader": "Select a new leader", diff --git a/src/client/ClanApi.ts b/src/client/ClanApi.ts index 6f7d0b76dc..240b7b88c9 100644 --- a/src/client/ClanApi.ts +++ b/src/client/ClanApi.ts @@ -194,6 +194,7 @@ export async function fetchClanMembers( limit = 20, sort: ClanMemberSort = "default", order?: ClanMemberOrder, + search?: string, ): Promise { try { const params = new URLSearchParams(); @@ -201,6 +202,8 @@ export async function fetchClanMembers( params.set("limit", String(limit)); if (sort !== "default") params.set("sort", sort); if (order) params.set("order", order); + const normalizedSearch = search?.trim(); + if (normalizedSearch) params.set("search", normalizedSearch); const res = await clanFetch( `/clans/${encodeURIComponent(tag)}/members?${params}`, ); diff --git a/src/client/components/clan/ClanBansView.ts b/src/client/components/clan/ClanBansView.ts index bdf542a24f..521fa0970c 100644 --- a/src/client/components/clan/ClanBansView.ts +++ b/src/client/components/clan/ClanBansView.ts @@ -114,6 +114,7 @@ export class ClanBansView extends LitElement { ${renderMemberSearchInput( (e) => this.onSearchInput(e), + this.memberSearch, "clan_modal.search_members_placeholder", )} ${filtered.length === 0 diff --git a/src/client/components/clan/ClanDetailView.ts b/src/client/components/clan/ClanDetailView.ts index 321f0847d5..f217df6545 100644 --- a/src/client/components/clan/ClanDetailView.ts +++ b/src/client/components/clan/ClanDetailView.ts @@ -67,8 +67,9 @@ export class ClanDetailView extends LitElement { @state() private actionPending = false; @state() private allStatsExpanded = false; @state() private membersLoadInFlight = false; - private memberSearch = ""; + @state() private memberSearch = ""; private memberSearchDebounce: ReturnType | null = null; + private memberLoadSeq = 0; private asyncGeneration = 0; connectedCallback() { @@ -109,6 +110,7 @@ export class ClanDetailView extends LitElement { disconnectedCallback() { if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce); + this.memberLoadSeq++; super.disconnectedCallback(); } @@ -122,6 +124,7 @@ export class ClanDetailView extends LitElement { private async loadDetail() { const gen = ++this.asyncGeneration; + this.memberLoadSeq++; this.loading = true; this.myRole = null; this.pendingRequestCount = 0; @@ -237,25 +240,36 @@ export class ClanDetailView extends LitElement { } } - private async loadMemberPage(page: number) { + private async loadMemberPage(page: number, search = this.memberSearch) { if (!this.selectedClan) return; - const res = await fetchClanMembers( - this.selectedClan.tag, - page, - this.membersPerPage, - this.memberSort, - this.memberOrder, - ); + const seq = ++this.memberLoadSeq; + const res = search + ? await fetchClanMembers( + this.selectedClan.tag, + page, + this.membersPerPage, + this.memberSort, + this.memberOrder, + search, + ) + : await fetchClanMembers( + this.selectedClan.tag, + page, + this.membersPerPage, + this.memberSort, + this.memberOrder, + ); + if (seq !== this.memberLoadSeq || search !== this.memberSearch) return; if (!res) return; if (res.results.length === 0 && page > 1) { - await this.loadMemberPage(1); + await this.loadMemberPage(1, search); return; } this.members = res.results; this.membersTotal = res.total; this.memberPage = page; this.pendingRequestCount = res.pendingRequests ?? 0; - if (this.selectedClan.memberCount !== res.total) { + if (!search && this.selectedClan.memberCount !== res.total) { this.selectedClan = { ...this.selectedClan, memberCount: res.total }; } } @@ -344,11 +358,13 @@ export class ClanDetailView extends LitElement { } private onSearchInput(e: Event) { - const val = (e.target as HTMLInputElement).value; + const search = (e.target as HTMLInputElement).value.trim(); + if (search === this.memberSearch) return; + this.memberSearch = search; if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce); this.memberSearchDebounce = setTimeout(() => { - this.memberSearch = val; - this.requestUpdate(); + this.memberSearchDebounce = null; + void this.loadMemberPage(1, search); }, 200); } @@ -539,6 +555,7 @@ export class ClanDetailView extends LitElement { ${renderMemberSearchInput( (e: Event) => this.onSearchInput(e), + this.memberSearch, undefined, renderMemberSortControl( this.memberSort, diff --git a/src/client/components/clan/ClanManageView.ts b/src/client/components/clan/ClanManageView.ts index 005b129258..dddc3a796b 100644 --- a/src/client/components/clan/ClanManageView.ts +++ b/src/client/components/clan/ClanManageView.ts @@ -58,8 +58,9 @@ export class ClanManageView extends LitElement { @state() private confirmTargetId: string | null = null; @state() private pendingRequestCount = 0; @state() private actionPending = false; - private memberSearch = ""; + @state() private memberSearch = ""; private memberSearchDebounce: ReturnType | null = null; + private memberLoadSeq = 0; connectedCallback() { super.connectedCallback(); @@ -74,31 +75,47 @@ export class ClanManageView extends LitElement { disconnectedCallback() { if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce); + this.memberLoadSeq++; super.disconnectedCallback(); } - private async loadMembers(page: number) { + private async loadMembers(page: number, search = this.memberSearch) { + const seq = ++this.memberLoadSeq; if (this.members.length === 0) this.loading = true; - const res = await fetchClanMembers( - this.clanTag, - page, - this.membersPerPage, - this.memberSort, - this.memberOrder, - ); + const res = search + ? await fetchClanMembers( + this.clanTag, + page, + this.membersPerPage, + this.memberSort, + this.memberOrder, + search, + ) + : await fetchClanMembers( + this.clanTag, + page, + this.membersPerPage, + this.memberSort, + this.memberOrder, + ); + if (seq !== this.memberLoadSeq || search !== this.memberSearch) return; if (!res) { this.loading = false; return; } if (res.results.length === 0 && page > 1) { - await this.loadMembers(1); + await this.loadMembers(1, search); return; } this.members = res.results; this.membersTotal = res.total; this.memberPage = page; this.pendingRequestCount = res.pendingRequests ?? 0; - if (this.selectedClan && this.selectedClan.memberCount !== res.total) { + if ( + !search && + this.selectedClan && + this.selectedClan.memberCount !== res.total + ) { this.dispatchEvent( new CustomEvent("clan-updated", { detail: { memberCount: res.total }, @@ -254,10 +271,13 @@ export class ClanManageView extends LitElement { } private onSearchInput(e: Event) { + const search = (e.target as HTMLInputElement).value.trim(); + if (search === this.memberSearch) return; + this.memberSearch = search; if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce); this.memberSearchDebounce = setTimeout(() => { - this.memberSearch = (e.target as HTMLInputElement).value; - this.requestUpdate(); + this.memberSearchDebounce = null; + void this.loadMembers(1, search); }, 200); } @@ -442,6 +462,7 @@ export class ClanManageView extends LitElement { ${renderMemberSearchInput( (e) => this.onSearchInput(e), + this.memberSearch, undefined, renderMemberSortControl( this.memberSort, diff --git a/src/client/components/clan/ClanRequestsView.ts b/src/client/components/clan/ClanRequestsView.ts index 8ca97c5d98..bdcc393608 100644 --- a/src/client/components/clan/ClanRequestsView.ts +++ b/src/client/components/clan/ClanRequestsView.ts @@ -135,6 +135,7 @@ export class ClanRequestsView extends LitElement { ${renderMemberSearchInput( (e) => this.onSearchInput(e), + this.memberSearch, "clan_modal.search_requests_placeholder", )} ${filtered.length === 0 diff --git a/src/client/components/clan/ClanShared.ts b/src/client/components/clan/ClanShared.ts index ffb7556ee3..c7bf978617 100644 --- a/src/client/components/clan/ClanShared.ts +++ b/src/client/components/clan/ClanShared.ts @@ -152,6 +152,7 @@ export function renderServerPagination( export function renderMemberSearchInput( onInput: (e: Event) => void, + value: string, placeholderKey = "clan_modal.search_members_placeholder", trailing?: TemplateResult, ): TemplateResult { @@ -159,6 +160,7 @@ export function renderMemberSearchInput(
| null = null; + private memberLoadSeq = 0; connectedCallback() { super.connectedCallback(); @@ -48,18 +49,30 @@ export class ClanTransferView extends LitElement { disconnectedCallback() { if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce); + this.memberLoadSeq++; super.disconnectedCallback(); } - private async loadMembers(page: number) { - if (page === 1) this.loading = true; - const res = await fetchClanMembers(this.clanTag, page, this.membersPerPage); + private async loadMembers(page: number, search = this.memberSearch) { + const seq = ++this.memberLoadSeq; + if (this.members.length === 0) this.loading = true; + const res = search + ? await fetchClanMembers( + this.clanTag, + page, + this.membersPerPage, + "default", + undefined, + search, + ) + : await fetchClanMembers(this.clanTag, page, this.membersPerPage); + if (seq !== this.memberLoadSeq || search !== this.memberSearch) return; if (!res) { this.loading = false; return; } if (res.results.length === 0 && page > 1) { - await this.loadMembers(1); + await this.loadMembers(1, search); return; } this.members = res.results; @@ -98,10 +111,13 @@ export class ClanTransferView extends LitElement { } private onSearchInput(e: Event) { + const search = (e.target as HTMLInputElement).value.trim(); + if (search === this.memberSearch) return; + this.memberSearch = search; if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce); this.memberSearchDebounce = setTimeout(() => { - this.memberSearch = (e.target as HTMLInputElement).value; - this.requestUpdate(); + this.memberSearchDebounce = null; + void this.loadMembers(1, search); }, 200); } @@ -150,7 +166,10 @@ export class ClanTransferView extends LitElement {

- ${renderMemberSearchInput((e) => this.onSearchInput(e))} + ${renderMemberSearchInput( + (e) => this.onSearchInput(e), + this.memberSearch, + )}
${filterMembersBySearch(nonLeaders, this.memberSearch).map( diff --git a/tests/client/clan/ClanApiQueries.test.ts b/tests/client/clan/ClanApiQueries.test.ts index 70d52a06bc..4cba402090 100644 --- a/tests/client/clan/ClanApiQueries.test.ts +++ b/tests/client/clan/ClanApiQueries.test.ts @@ -348,6 +348,24 @@ describe("fetchClanMembers", () => { expect(url.searchParams.get("limit")).toBe("50"); }); + it("passes a trimmed member search with pagination and sorting", async () => { + const fetchSpy = vi.fn( + (_input: string | URL | Request, _init?: RequestInit) => + Promise.resolve(okJson(membersResponse)), + ); + vi.stubGlobal("fetch", fetchSpy); + + await fetchClanMembers("TEST", 3, 50, "winsTotal", "desc", " d3G1QO8Z "); + + const calledUrl = fetchSpy.mock.calls[0]![0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("page")).toBe("3"); + expect(url.searchParams.get("limit")).toBe("50"); + expect(url.searchParams.get("sort")).toBe("winsTotal"); + expect(url.searchParams.get("order")).toBe("desc"); + expect(url.searchParams.get("search")).toBe("d3G1QO8Z"); + }); + it("includes the optional pendingRequests field", async () => { mockFetch(() => okJson({ ...membersResponse, pendingRequests: 5 })); const result = await fetchClanMembers("TEST"); diff --git a/tests/client/clan/ClanMemberSearchPagination.test.ts b/tests/client/clan/ClanMemberSearchPagination.test.ts new file mode 100644 index 0000000000..11fc0ca1c7 --- /dev/null +++ b/tests/client/clan/ClanMemberSearchPagination.test.ts @@ -0,0 +1,345 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { + ClanInfo, + ClanMember, + ClanMembersResponse, + ClanMemberStats, +} from "../../../src/client/ClanApi"; +import { + apiMockFactory, + clanApiMockFactory, + setElState, + utilsMockFactory, +} from "./ClanModalTestUtils"; + +vi.mock("../../../src/client/Api", () => apiMockFactory()); +vi.mock("../../../src/client/ClanApi", () => clanApiMockFactory()); +vi.mock("../../../src/client/Utils", () => utilsMockFactory()); + +import { ClanDetailView } from "../../../src/client/components/clan/ClanDetailView"; +import { ClanManageView } from "../../../src/client/components/clan/ClanManageView"; +import { ClanTransferView } from "../../../src/client/components/clan/ClanTransferView"; + +const TARGET_ID = "d3G1QO8Z"; +const PAGE_MEMBER_ID = "page-five-member"; +const OLDER_RESULT_ID = `${TARGET_ID}-older`; +const NEWER_RESULT_ID = `${TARGET_ID}-newer`; +const ZERO_WL = { wins: 0, losses: 0 } as const; +const ZERO_STATS: ClanMemberStats = { + total: ZERO_WL, + ffa: ZERO_WL, + team: ZERO_WL, + hvn: ZERO_WL, + duos: ZERO_WL, + trios: ZERO_WL, + quads: ZERO_WL, + "2": ZERO_WL, + "3": ZERO_WL, + "4": ZERO_WL, + "5": ZERO_WL, + "6": ZERO_WL, + "7": ZERO_WL, + ranked: ZERO_WL, + "1v1": ZERO_WL, +}; + +const clan: ClanInfo = { + name: "Test Clan", + tag: "TST", + description: "A test clan", + isOpen: true, + createdAt: "2024-01-01T00:00:00.000Z", + memberCount: 100, +}; + +function member(publicId: string, role: ClanMember["role"] = "member") { + return { + publicId, + username: null, + role, + joinedAt: "2024-01-01T00:00:00.000Z", + stats: ZERO_STATS, + } satisfies ClanMember; +} + +type MemberView = HTMLElement & { + updateComplete: Promise; +}; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +function memberResponse( + members: ClanMember[], + page = 1, + total = members.length, +): ClanMembersResponse { + return { + results: members, + total, + page, + limit: 10, + pendingRequests: 0, + }; +} + +function createDetailView(): MemberView { + const view = new ClanDetailView(); + view.clanTag = clan.tag; + view.cachedClan = clan; + view.cachedDetail = { + tag: clan.tag, + members: [member(PAGE_MEMBER_ID)], + membersTotal: 100, + pendingRequestCount: 0, + }; + view.myClanRoles = new Map([[clan.tag, "leader"]]); + view.detailTab = "members"; + return view; +} + +function createManageView(): MemberView { + const view = new ClanManageView(); + view.clanTag = clan.tag; + view.selectedClan = clan; + view.myPublicId = "leader-id"; + view.myRole = "leader"; + return view; +} + +function createTransferView(): MemberView { + const view = new ClanTransferView(); + view.clanTag = clan.tag; + view.selectedClan = clan; + return view; +} + +const MEMBER_VIEW_CASES = [ + { label: "members", create: createDetailView }, + { label: "manage", create: createManageView }, + { label: "transfer", create: createTransferView }, +]; + +async function settle(view: MemberView) { + for (let i = 0; i < 3; i++) { + await Promise.resolve(); + await view.updateComplete; + } +} + +async function putOnPageFive(view: MemberView) { + document.body.appendChild(view); + await settle(view); + setElState(view, "members", [member(PAGE_MEMBER_ID)]); + setElState(view, "membersTotal", 100); + setElState(view, "memberPage", 5); + await view.updateComplete; + expect(view.textContent).toContain(PAGE_MEMBER_ID); + expect(view.textContent).toContain("5 / 10"); +} + +async function searchForTarget(view: MemberView) { + const input = memberSearchInput(view); + input.value = TARGET_ID; + input.dispatchEvent(new Event("input", { bubbles: true })); + + await vi.advanceTimersByTimeAsync(200); + await settle(view); + + expect(view.textContent).toContain(TARGET_ID); + expect(view.textContent).not.toContain(PAGE_MEMBER_ID); + expect(view.textContent).not.toContain("5 / 10"); +} + +function memberSearchInput(view: MemberView): HTMLInputElement { + const input = view.querySelector( + 'input[placeholder="clan_modal.search_members_placeholder"]', + ); + expect(input).not.toBeNull(); + return input!; +} + +function enterSearch(view: MemberView, search: string) { + const input = memberSearchInput(view); + input.value = search; + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +function nextPageButton(view: MemberView): HTMLButtonElement { + const button = Array.from( + view.querySelectorAll("button"), + ).find((candidate) => candidate.textContent?.trim() === ">"); + expect(button).not.toBeUndefined(); + return button!; +} + +describe("clan member search pagination", () => { + beforeEach(async () => { + vi.useFakeTimers(); + const { fetchClanMembers } = await import("../../../src/client/ClanApi"); + vi.mocked(fetchClanMembers).mockImplementation( + async (_tag, page = 1, limit = 20, _sort, _order, search) => { + if (page === 1 && search === TARGET_ID) { + return { + results: [member(TARGET_ID)], + total: 1, + page: 1, + limit, + pendingRequests: 0, + }; + } + return { + results: [member(PAGE_MEMBER_ID)], + total: 100, + page, + limit, + pendingRequests: 0, + }; + }, + ); + }); + + afterEach(() => { + document.body.replaceChildren(); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("searches the full roster from page one in the members view", async () => { + const view = new ClanDetailView(); + view.clanTag = clan.tag; + view.cachedClan = clan; + view.cachedDetail = { + tag: clan.tag, + members: [member(PAGE_MEMBER_ID)], + membersTotal: 100, + pendingRequestCount: 0, + }; + view.myClanRoles = new Map([[clan.tag, "leader"]]); + view.detailTab = "members"; + + await putOnPageFive(view); + await searchForTarget(view); + view.detailTab = "overview"; + await view.updateComplete; + expect(view.textContent).toContain("100"); + }); + + it("searches the full roster from page one in the manage view", async () => { + const view = new ClanManageView(); + view.clanTag = clan.tag; + view.selectedClan = clan; + view.myPublicId = "leader-id"; + view.myRole = "leader"; + + await putOnPageFive(view); + const memberCountUpdates: number[] = []; + view.addEventListener("clan-updated", (event) => { + memberCountUpdates.push( + (event as CustomEvent<{ memberCount: number }>).detail.memberCount, + ); + }); + await searchForTarget(view); + expect(memberCountUpdates).toEqual([]); + }); + + it("searches the full roster from page one in the transfer view", async () => { + const view = new ClanTransferView(); + view.clanTag = clan.tag; + view.selectedClan = clan; + + await putOnPageFive(view); + await searchForTarget(view); + }); + + it.each(MEMBER_VIEW_CASES)( + "ignores stale same-search responses in the $label view", + async ({ create }) => { + const view = create(); + await putOnPageFive(view); + + const older = deferred(); + const newer = deferred(); + const { fetchClanMembers } = await import("../../../src/client/ClanApi"); + vi.mocked(fetchClanMembers).mockReset(); + vi.mocked(fetchClanMembers) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise); + + enterSearch(view, TARGET_ID); + await vi.advanceTimersByTimeAsync(200); + nextPageButton(view).click(); + + newer.resolve(memberResponse([member(NEWER_RESULT_ID)], 6, 100)); + await settle(view); + expect(view.textContent).toContain(NEWER_RESULT_ID); + expect(view.textContent).toContain("6 / 10"); + + older.resolve(memberResponse([member(OLDER_RESULT_ID)])); + await settle(view); + expect(view.textContent).toContain(NEWER_RESULT_ID); + expect(view.textContent).not.toContain(OLDER_RESULT_ID); + expect(view.textContent).toContain("6 / 10"); + }, + ); + + it.each([ + { label: "manage", create: createManageView }, + { label: "transfer", create: createTransferView }, + ])( + "preserves a refined empty-result search in the $label view", + async ({ create }) => { + const view = create(); + await putOnPageFive(view); + + const firstSearch = deferred(); + const secondSearch = deferred(); + const { fetchClanMembers } = await import("../../../src/client/ClanApi"); + vi.mocked(fetchClanMembers).mockReset(); + vi.mocked(fetchClanMembers) + .mockReturnValueOnce(firstSearch.promise) + .mockReturnValueOnce(secondSearch.promise); + + enterSearch(view, "missing-one"); + await vi.advanceTimersByTimeAsync(200); + await settle(view); + expect(memberSearchInput(view).value).toBe("missing-one"); + + enterSearch(view, "missing-two"); + await vi.advanceTimersByTimeAsync(200); + secondSearch.resolve(memberResponse([])); + await settle(view); + + firstSearch.resolve(memberResponse([])); + await settle(view); + + expect(memberSearchInput(view).value).toBe("missing-two"); + }, + ); + + it.each(MEMBER_VIEW_CASES)( + "rerenders the filtered fallback when search fails in the $label view", + async ({ create }) => { + const view = create(); + await putOnPageFive(view); + + const { fetchClanMembers } = await import("../../../src/client/ClanApi"); + vi.mocked(fetchClanMembers).mockReset(); + vi.mocked(fetchClanMembers).mockResolvedValue(false); + + enterSearch(view, "definitely-not-present"); + await vi.advanceTimersByTimeAsync(200); + await settle(view); + + expect(view.textContent).not.toContain(PAGE_MEMBER_ID); + expect(memberSearchInput(view).value).toBe("definitely-not-present"); + }, + ); +});