diff --git a/__tests__/components/waves/leaderboard/header/WaveLeaderboardCurationGroupSelect.test.tsx b/__tests__/components/waves/leaderboard/header/WaveLeaderboardCurationGroupSelect.test.tsx index c18b0e67e8..119b29ac4a 100644 --- a/__tests__/components/waves/leaderboard/header/WaveLeaderboardCurationGroupSelect.test.tsx +++ b/__tests__/components/waves/leaderboard/header/WaveLeaderboardCurationGroupSelect.test.tsx @@ -2,25 +2,32 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { WaveLeaderboardCurationGroupSelect } from "@/components/waves/leaderboard/header/WaveLeaderboardCurationGroupSelect"; -const commonSelectMock = jest.fn(); +let mockBreakpoint = "MD"; -jest.mock("@/components/utils/select/CommonSelect", () => ({ +jest.mock("react-use", () => ({ + createBreakpoint: jest.fn(() => () => mockBreakpoint), +})); + +jest.mock("@tanstack/react-query", () => ({ + useQueries: jest.fn(({ queries, combine }: any) => { + const results = (queries ?? []).map(() => ({ data: undefined })); + return combine ? combine(results) : results; + }), +})); + +const commonDropdownMock = jest.fn((props: any) => ( +
+ {props.items.map((item: any) => ( + + ))} +
+)); + +jest.mock("@/components/utils/select/dropdown/CommonDropdown", () => ({ __esModule: true, - default: (props: any) => { - commonSelectMock(props); - return ( -
- {props.items.map((item: any) => ( - - ))} - - {props.activeItem === null ? "null" : props.activeItem} - -
- ); - }, + default: (props: any) => commonDropdownMock(props), })); const groups = [ @@ -44,7 +51,8 @@ const groups = [ describe("WaveLeaderboardCurationGroupSelect", () => { beforeEach(() => { - commonSelectMock.mockClear(); + mockBreakpoint = "MD"; + commonDropdownMock.mockClear(); }); it("returns null when there are no curation groups", () => { @@ -57,10 +65,9 @@ describe("WaveLeaderboardCurationGroupSelect", () => { ); expect(container).toBeEmptyDOMElement(); - expect(commonSelectMock).not.toHaveBeenCalled(); }); - it("maps curation options and passes select configuration", () => { + it("renders horizontal tabs on desktop", () => { render( { ); expect(screen.getByTestId("curation-group-select")).toBeInTheDocument(); - expect(screen.getByTestId("active-item")).toHaveTextContent("cg-2"); - - const commonSelectProps = commonSelectMock.mock.calls[0][0]; - expect(commonSelectProps.filterLabel).toBe("Curation"); - expect(commonSelectProps.fill).toBe(false); - expect(commonSelectProps.items).toEqual([ - { key: "all-submissions", label: "All submissions", value: null }, - { key: "cg-1", label: "Curators One", value: "cg-1" }, - { key: "cg-2", label: "Curators Two", value: "cg-2" }, - ]); + expect(screen.getByText("All submissions")).toBeInTheDocument(); + expect(screen.getByText("Curators One")).toBeInTheDocument(); + expect(screen.getByText("Curators Two")).toBeInTheDocument(); + expect(commonDropdownMock).not.toHaveBeenCalled(); + }); + + it("highlights the active tab on desktop", () => { + render( + + ); + + const activeButton = screen.getByText("Curators Two").closest("button")!; + expect(activeButton.className).toContain("tw-ring-iron-600"); + + const inactiveButton = screen + .getByText("All submissions") + .closest("button")!; + expect(inactiveButton.className).toContain("tw-ring-transparent"); }); - it("handles selecting curation options", async () => { + it("handles tab clicks on desktop", async () => { const user = userEvent.setup(); const onChange = jest.fn(); @@ -94,10 +113,49 @@ describe("WaveLeaderboardCurationGroupSelect", () => { /> ); - await user.click(screen.getByRole("button", { name: "Curators One" })); - await user.click(screen.getByRole("button", { name: "All submissions" })); + await user.click(screen.getByText("Curators One")); + await user.click(screen.getByText("All submissions")); expect(onChange).toHaveBeenNthCalledWith(1, "cg-1"); expect(onChange).toHaveBeenNthCalledWith(2, null); }); + + it("renders dropdown on mobile", () => { + mockBreakpoint = "S"; + + render( + + ); + + expect(screen.getByTestId("mobile-dropdown")).toBeInTheDocument(); + expect(commonDropdownMock).toHaveBeenCalledWith( + expect.objectContaining({ + filterLabel: "Group", + showFilterLabel: true, + size: "sm", + activeItem: null, + }) + ); + }); + + it("handles dropdown selection on mobile", async () => { + mockBreakpoint = "S"; + const user = userEvent.setup(); + const onChange = jest.fn(); + + render( + + ); + + await user.click(screen.getByText("Curators One")); + expect(onChange).toHaveBeenCalledWith("cg-1"); + }); }); diff --git a/__tests__/components/waves/leaderboard/header/WaveleaderboardSort.test.tsx b/__tests__/components/waves/leaderboard/header/WaveleaderboardSort.test.tsx index 9804730c5b..d93d6fe6db 100644 --- a/__tests__/components/waves/leaderboard/header/WaveleaderboardSort.test.tsx +++ b/__tests__/components/waves/leaderboard/header/WaveleaderboardSort.test.tsx @@ -1,23 +1,17 @@ import { WaveleaderboardSort } from "@/components/waves/leaderboard/header/WaveleaderboardSort"; import { WaveDropsLeaderboardSort } from "@/hooks/useWaveDropsLeaderboard"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -let mockBreakpoint = "MD"; const commonDropdownMock = jest.fn((props: any) => ( )); -jest.mock("react-use", () => ({ - createBreakpoint: jest.fn(() => () => mockBreakpoint), -})); - jest.mock("@/components/utils/select/dropdown/CommonDropdown", () => ({ __esModule: true, default: (props: any) => commonDropdownMock(props), @@ -25,72 +19,55 @@ jest.mock("@/components/utils/select/dropdown/CommonDropdown", () => ({ describe("WaveleaderboardSort", () => { beforeEach(() => { - mockBreakpoint = "MD"; commonDropdownMock.mockClear(); }); - it("shows desktop sort tabs and triggers changes on desktop", async () => { + it("always renders dropdown sort and forwards selection", async () => { const onSortChange = jest.fn(); const user = userEvent.setup(); - const queryClient = new QueryClient(); render( - - - + ); - const current = screen.getByText("Current Vote"); - expect(current.className).toContain("tw-bg-white/10"); - - await user.click(screen.getByText("Projected Vote")); - expect(onSortChange).toHaveBeenCalledWith( - WaveDropsLeaderboardSort.RATING_PREDICTION + expect(screen.getByTestId("sort-dropdown")).toHaveTextContent("Sort: RANK"); + expect(screen.getByTestId("sort-dropdown").parentElement).toHaveClass( + "tw-min-w-0" ); - await user.click(screen.getByText("Hot")); - expect(onSortChange).toHaveBeenCalledWith(WaveDropsLeaderboardSort.TREND); - - await user.click(screen.getByText("Newest")); + await user.click(screen.getByTestId("sort-dropdown")); expect(onSortChange).toHaveBeenCalledWith( WaveDropsLeaderboardSort.CREATED_AT ); - - expect(commonDropdownMock).not.toHaveBeenCalled(); }); - it("shows dropdown sort and forwards selection on small screens", async () => { - mockBreakpoint = "S"; - - const onSortChange = jest.fn(); - const user = userEvent.setup(); - const queryClient = new QueryClient(); - + it("passes correct props to CommonDropdown", () => { render( - - - + ); - expect(screen.queryByText("Current Vote")).not.toBeInTheDocument(); - expect(screen.getByTestId("mobile-sort")).toHaveTextContent("Sort: RANK"); - expect(screen.getByTestId("mobile-sort").parentElement).toHaveClass( - "tw-w-full", - "tw-min-w-0" + expect(commonDropdownMock).toHaveBeenCalledWith( + expect.objectContaining({ + activeItem: WaveDropsLeaderboardSort.TREND, + filterLabel: "Sort", + size: "sm", + showFilterLabel: true, + }) ); - expect( - screen.getByTestId("mobile-sort").parentElement?.className - ).not.toContain("tw-w-[11rem]"); - await user.click(screen.getByTestId("mobile-sort")); - expect(onSortChange).toHaveBeenCalledWith( - WaveDropsLeaderboardSort.CREATED_AT - ); + const items = commonDropdownMock.mock.calls[0]![0].items; + expect(items).toHaveLength(4); + expect(items.map((i: any) => i.label)).toEqual([ + "Current Vote", + "Projected Vote", + "Hot", + "Newest", + ]); }); }); diff --git a/components/utils/select/dropdown/CommonDropdown.tsx b/components/utils/select/dropdown/CommonDropdown.tsx index 0bfdf53cf1..b1d1ef5a34 100644 --- a/components/utils/select/dropdown/CommonDropdown.tsx +++ b/components/utils/select/dropdown/CommonDropdown.tsx @@ -88,15 +88,15 @@ export default function CommonDropdown( : "tw-bg-iron-800" } ${ size === "md" - ? "tw-py-3" + ? "tw-py-3 tw-text-sm" : size === "tabs" - ? "tw-py-[11px]" - : "tw-py-2.5" - } tw-w-full tw-truncate tw-text-left tw-relative tw-block tw-whitespace-nowrap tw-rounded-lg tw-border-0 tw-pl-3.5 tw-pr-10 tw-font-semibold tw-caret-primary-400 tw-shadow-sm tw-ring-1 tw-ring-inset tw-ring-iron-700 - focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset focus:tw-ring-primary-400 tw-text-sm hover:tw-bg-iron-800 tw-transition tw-duration-300 tw-ease-out tw-justify-between`} + ? "tw-py-[11px] tw-text-sm" + : "tw-py-2.5 tw-text-xs" + } tw-w-full tw-truncate tw-text-left tw-relative tw-block tw-whitespace-nowrap tw-rounded-lg tw-border-0 tw-pl-3.5 tw-pr-8 tw-font-semibold tw-caret-primary-400 tw-shadow-sm tw-ring-1 tw-ring-inset tw-ring-iron-700 + focus:tw-outline-none focus:tw-ring-1 focus:tw-ring-inset focus:tw-ring-primary-400 hover:tw-bg-iron-800 tw-transition tw-duration-300 tw-ease-out tw-justify-between`} > {showFilterLabel && ( - {filterLabel}: + {filterLabel}: )} {computedLabel} {sortDirection && ( @@ -107,7 +107,7 @@ export default function CommonDropdown(
+ + items={items} + activeItem={selectedGroupId} + filterLabel="Group" + setSelected={onChange} + size="sm" + showFilterLabel={true} + /> +
+ ); + } + + const getTabClassName = (value: string | null) => { + const baseClass = + "tw-flex tw-items-center tw-gap-x-1.5 tw-px-3 tw-py-2.5 tw-text-xs tw-font-semibold tw-rounded-lg tw-transition-colors tw-whitespace-nowrap tw-border-0 tw-ring-1 tw-ring-inset"; + + if (selectedGroupId === value) { + return `${baseClass} tw-ring-iron-600 tw-bg-iron-800 tw-text-iron-200`; + } + + return `${baseClass} tw-ring-transparent tw-bg-transparent tw-text-iron-500 desktop-hover:hover:tw-ring-iron-700 desktop-hover:hover:tw-text-iron-300`; + }; + return (
- - - {isOpen && ( -
- {items.map((item) => { - const isActive = item.value === selectedGroupId; - return ( - - ); - })} -
- )} + {items.map((item) => { + const pfp = item.value ? pfpMap.get(item.value) : null; + return ( + + ); + })}
); } diff --git a/components/waves/leaderboard/header/WaveleaderboardHeader.tsx b/components/waves/leaderboard/header/WaveleaderboardHeader.tsx index 28640d6179..cb8e342293 100644 --- a/components/waves/leaderboard/header/WaveleaderboardHeader.tsx +++ b/components/waves/leaderboard/header/WaveleaderboardHeader.tsx @@ -133,60 +133,54 @@ export const WaveLeaderboardHeader: React.FC = ({ return (
-
-
-
-
- {viewModes.map((mode) => ( - - - - {getViewModeLabel(mode)} - - - ))} -
- +
+
+
+ {viewModes.map((mode) => ( + + + + {getViewModeLabel(mode)} + + + ))}
-
-
+ {showCurationGroupSelect && onCurationGroupChange && ( = ({ onChange={onCurationGroupChange} /> )} - {connectedProfile && participation.isEligible && ( -
- - - Drop - -
- )}
+ {connectedProfile && participation.isEligible && ( +
+ + + Drop + +
+ )}
); diff --git a/components/waves/leaderboard/header/WaveleaderboardSort.tsx b/components/waves/leaderboard/header/WaveleaderboardSort.tsx index 8eba4a5777..c287b83485 100644 --- a/components/waves/leaderboard/header/WaveleaderboardSort.tsx +++ b/components/waves/leaderboard/header/WaveleaderboardSort.tsx @@ -1,50 +1,19 @@ "use client"; -import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; -import { - WAVE_DROPS_PARAMS, - getDefaultQueryRetry, -} from "@/components/react-query-wrapper/utils/query-utils"; import type { CommonSelectItem } from "@/components/utils/select/CommonSelect"; import CommonDropdown from "@/components/utils/select/dropdown/CommonDropdown"; -import type { ApiDropsLeaderboardPage } from "@/generated/models/ApiDropsLeaderboardPage"; import { WaveDropsLeaderboardSort } from "@/hooks/useWaveDropsLeaderboard"; -import { commonApiFetch } from "@/services/api/common-api"; -import { useQueryClient } from "@tanstack/react-query"; -import { debounce } from "lodash"; -import React, { useCallback, useEffect, useMemo } from "react"; -import { createBreakpoint } from "react-use"; +import React, { useMemo } from "react"; interface WaveleaderboardSortProps { readonly sort: WaveDropsLeaderboardSort; readonly onSortChange: (sort: WaveDropsLeaderboardSort) => void; - readonly waveId?: string | undefined; - readonly curatedByGroupId?: string | undefined; } -const SORT_DIRECTION_MAP: Record< - WaveDropsLeaderboardSort, - "ASC" | "DESC" | undefined -> = { - [WaveDropsLeaderboardSort.RANK]: undefined, - [WaveDropsLeaderboardSort.RATING_PREDICTION]: "DESC", - [WaveDropsLeaderboardSort.TREND]: "DESC", - [WaveDropsLeaderboardSort.MY_REALTIME_VOTE]: undefined, - [WaveDropsLeaderboardSort.CREATED_AT]: "DESC", -}; - -const useBreakpoint = createBreakpoint({ MD: 768, S: 0 }); - export const WaveleaderboardSort: React.FC = ({ sort, onSortChange, - waveId, - curatedByGroupId, }) => { - const breakpoint = useBreakpoint(); - const isSmallViewport = breakpoint === "S"; - const queryClient = useQueryClient(); - const normalizedCuratedByGroupId = curatedByGroupId?.trim() ?? undefined; const sortItems = useMemo< readonly CommonSelectItem[] >( @@ -73,150 +42,16 @@ export const WaveleaderboardSort: React.FC = ({ [] ); - const prefetchSortImmediate = useCallback( - (targetSort: WaveDropsLeaderboardSort) => { - if (!waveId || targetSort === sort) return; - - const sortDirection = SORT_DIRECTION_MAP[targetSort]; - const queryKey = [ - QueryKey.DROPS_LEADERBOARD, - { - waveId, - page_size: WAVE_DROPS_PARAMS.limit, - sort: targetSort, - sort_direction: sortDirection, - curated_by_group: normalizedCuratedByGroupId ?? null, - }, - ]; - - queryClient - .prefetchInfiniteQuery({ - queryKey, - queryFn: async ({ pageParam }: { pageParam: number | null }) => { - const params: Record = { - page_size: WAVE_DROPS_PARAMS.limit.toString(), - sort: targetSort, - }; - - if (sortDirection) { - params["sort_direction"] = sortDirection; - } - - if (typeof pageParam === "number") { - params["page"] = `${pageParam}`; - } - - if (normalizedCuratedByGroupId) { - params["curated_by_group"] = normalizedCuratedByGroupId; - } - - return await commonApiFetch({ - endpoint: `waves/${waveId}/leaderboard`, - params, - }); - }, - pages: 1, - initialPageParam: null, - getNextPageParam: (lastPage: ApiDropsLeaderboardPage) => { - if (targetSort === WaveDropsLeaderboardSort.MY_REALTIME_VOTE) { - const haveZeroVotes = lastPage.drops.some( - (drop) => drop.context_profile_context?.rating === 0 - ); - if (haveZeroVotes) { - return null; - } - } - return lastPage.next ? lastPage.page + 1 : null; - }, - staleTime: 60000, - ...getDefaultQueryRetry(), - }) - .catch((error: unknown) => { - // Log prefetch errors for debugging while not blocking the UI - console.warn("Failed to prefetch leaderboard data:", { - waveId, - targetSort, - error: error instanceof Error ? error.message : error, - }); - }); - }, - [queryClient, waveId, sort, normalizedCuratedByGroupId] - ); - - // Debounce prefetch to prevent excessive network requests on rapid hover events - const prefetchSort = useMemo( - () => debounce(prefetchSortImmediate, 300), - [prefetchSortImmediate] - ); - - // Cancel pending debounced calls on unmount to avoid late network requests - useEffect(() => { - return () => prefetchSort.cancel(); - }, [prefetchSort]); - - const getButtonClassName = (buttonSort: WaveDropsLeaderboardSort) => { - const baseClass = - "tw-px-4 tw-py-1.5 tw-text-xs tw-font-medium tw-border-0 tw-rounded-md tw-transition-colors"; - - if (sort === buttonSort) { - return `${baseClass} tw-bg-white/10 tw-text-white tw-shadow-sm`; - } - - return `${baseClass} tw-bg-transparent tw-text-iron-500 desktop-hover:hover:tw-text-iron-300`; - }; - - if (isSmallViewport) { - return ( -
- - items={sortItems} - activeItem={sort} - filterLabel="Sort" - setSelected={onSortChange} - size="sm" - showFilterLabel={true} - /> -
- ); - } - return ( -
- - - - +
+ + items={sortItems} + activeItem={sort} + filterLabel="Sort" + setSelected={onSortChange} + size="sm" + showFilterLabel={true} + />
); };