diff --git a/__tests__/components/waves/small-leaderboard/WaveSmallLeaderboardItemOutcomes.test.tsx b/__tests__/components/waves/small-leaderboard/WaveSmallLeaderboardItemOutcomes.test.tsx index a7e08a5d5b..60f7d7eebc 100644 --- a/__tests__/components/waves/small-leaderboard/WaveSmallLeaderboardItemOutcomes.test.tsx +++ b/__tests__/components/waves/small-leaderboard/WaveSmallLeaderboardItemOutcomes.test.tsx @@ -1,29 +1,53 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { WaveSmallLeaderboardItemOutcomes } from '@/components/waves/small-leaderboard/WaveSmallLeaderboardItemOutcomes'; -import { ApiWaveOutcomeCredit } from '@/generated/models/ApiWaveOutcomeCredit'; -import { ApiWaveOutcomeType } from '@/generated/models/ApiWaveOutcomeType'; +const mockUseWaveRankReward = jest.fn(); + +jest.mock('@/hooks/waves/useWaveRankReward', () => ({ + useWaveRankReward: (args: any) => mockUseWaveRankReward(args), +})); describe('WaveSmallLeaderboardItemOutcomes', () => { - const drop: any = { rank: 1 }; - const wave: any = { - outcomes: [ - { credit: ApiWaveOutcomeCredit.Cic, distribution: [{ amount: 1 }] }, - { credit: ApiWaveOutcomeCredit.Rep, distribution: [{ amount: 2 }] }, - { type: ApiWaveOutcomeType.Manual, distribution: [{ amount: 1, description: 'Award' }] }, - ], - }; + const drop: any = { rank: 1, wave: { id: 'w1' } }; + + beforeEach(() => { + mockUseWaveRankReward.mockReset(); + }); it('renders button when outcomes exist', () => { - render(); + mockUseWaveRankReward.mockReturnValue({ + nicTotal: 10, + repTotal: 20, + manualOutcomes: ['Award'], + isLoading: false + }); + + render(); expect(screen.getByRole('button')).toBeInTheDocument(); expect(screen.getByText('Outcome:')).toBeInTheDocument(); }); - it('hides when no outcomes', () => { - const waveEmpty = { outcomes: [] } as any; - const { container } = render(); + it('hides when no outcomes and not loading', () => { + mockUseWaveRankReward.mockReturnValue({ + nicTotal: 0, + repTotal: 0, + manualOutcomes: [], + isLoading: false + }); + + const { container } = render(); expect(container.firstChild).toBeNull(); }); + + it('shows loading state', () => { + mockUseWaveRankReward.mockReturnValue({ + nicTotal: 0, + repTotal: 0, + manualOutcomes: [], + isLoading: true + }); + const { container } = render(); + expect(container.querySelector('.tw-animate-pulse')).toBeInTheDocument(); + }); }); diff --git a/components/react-query-wrapper/ReactQueryWrapper.tsx b/components/react-query-wrapper/ReactQueryWrapper.tsx index c7c774b11f..9028dde3e5 100644 --- a/components/react-query-wrapper/ReactQueryWrapper.tsx +++ b/components/react-query-wrapper/ReactQueryWrapper.tsx @@ -95,6 +95,7 @@ export enum QueryKey { WAVE_DECISIONS = "WAVE_DECISIONS", WAVE_OUTCOMES = "WAVE_OUTCOMES", WAVE_OUTCOME_DISTRIBUTION = "WAVE_OUTCOME_DISTRIBUTION", + WAVE_OUTCOME_DISTRIBUTION_PAGE = "WAVE_OUTCOME_DISTRIBUTION_PAGE", } interface InitProfileRatersParamsAndData { diff --git a/components/waves/drop/SingleWaveDropInfoAuthorSection.tsx b/components/waves/drop/SingleWaveDropInfoAuthorSection.tsx index 5d9f11674d..4321bfd862 100644 --- a/components/waves/drop/SingleWaveDropInfoAuthorSection.tsx +++ b/components/waves/drop/SingleWaveDropInfoAuthorSection.tsx @@ -23,7 +23,7 @@ export const SingleWaveDropInfoAuthorSection: React.FC< {wave && drop && ( - + )} ); diff --git a/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.tsx b/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.tsx index 942da3a16d..fde6908bee 100644 --- a/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.tsx +++ b/components/waves/leaderboard/drops/DefaultWaveLeaderboardDrop.tsx @@ -7,7 +7,6 @@ import WaveDropActionsOpen from "@/components/waves/drops/WaveDropActionsOpen"; import WaveDropActionsOptions from "@/components/waves/drops/WaveDropActionsOptions"; import WaveDropMobileMenuDelete from "@/components/waves/drops/WaveDropMobileMenuDelete"; import WaveDropMobileMenuOpen from "@/components/waves/drops/WaveDropMobileMenuOpen"; -import { ApiWave } from "@/generated/models/ObjectSerializer"; import { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { useDropInteractionRules } from "@/hooks/drops/useDropInteractionRules"; import useIsMobileScreen from "@/hooks/isMobileScreen"; @@ -22,13 +21,12 @@ import { WaveLeaderboardDropRaters } from "./header/WaveleaderboardDropRaters"; interface DefaultWaveLeaderboardDropProps { readonly drop: ExtendedDrop; - readonly wave: ApiWave; readonly onDropClick: (drop: ExtendedDrop) => void; } export const DefaultWaveLeaderboardDrop: React.FC< DefaultWaveLeaderboardDropProps -> = ({ drop, wave, onDropClick }) => { +> = ({ drop, onDropClick }) => { const { canShowVote, canDelete } = useDropInteractionRules(drop); const [isVotingModalOpen, setIsVotingModalOpen] = useState(false); const { hasTouchScreen } = useDeviceInfo(); @@ -87,7 +85,7 @@ export const DefaultWaveLeaderboardDrop: React.FC<
- +
{canShowVote && (
= ({ return ( ); diff --git a/components/waves/leaderboard/drops/footer/WaveLeaderboardDropFooter.tsx b/components/waves/leaderboard/drops/footer/WaveLeaderboardDropFooter.tsx index fde40edf7c..46e4f75ac8 100644 --- a/components/waves/leaderboard/drops/footer/WaveLeaderboardDropFooter.tsx +++ b/components/waves/leaderboard/drops/footer/WaveLeaderboardDropFooter.tsx @@ -1,15 +1,13 @@ import React from "react"; import { ExtendedDrop } from "@/helpers/waves/drop.helpers"; -import { ApiWave } from "@/generated/models/ApiWave"; import { WaveSmallLeaderboardItemOutcomes } from "@/components/waves/small-leaderboard/WaveSmallLeaderboardItemOutcomes"; interface WaveLeaderboardDropFooterProps { readonly drop: ExtendedDrop; - readonly wave: ApiWave; } export const WaveLeaderboardDropFooter: React.FC< WaveLeaderboardDropFooterProps -> = ({ drop, wave }) => { - return ; +> = ({ drop }) => { + return ; }; diff --git a/components/waves/small-leaderboard/DefaultWaveSmallLeaderboardDrop.tsx b/components/waves/small-leaderboard/DefaultWaveSmallLeaderboardDrop.tsx index d80e9e8c47..d12e9c68ef 100644 --- a/components/waves/small-leaderboard/DefaultWaveSmallLeaderboardDrop.tsx +++ b/components/waves/small-leaderboard/DefaultWaveSmallLeaderboardDrop.tsx @@ -2,29 +2,25 @@ import React from "react"; import { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { WaveSmallLeaderboardTopThreeDrop } from "./WaveSmallLeaderboardTopThreeDrop"; import { WaveSmallLeaderboardDefaultDrop } from "./WaveSmallLeaderboardDefaultDrop"; -import { ApiWave } from "@/generated/models/ApiWave"; interface DefaultWaveSmallLeaderboardDropProps { readonly drop: ExtendedDrop; - readonly wave: ApiWave; readonly onDropClick: (drop: ExtendedDrop) => void; } export const DefaultWaveSmallLeaderboardDrop: React.FC< DefaultWaveSmallLeaderboardDropProps -> = ({ drop, wave, onDropClick }) => { +> = ({ drop, onDropClick }) => { return (
onDropClick(drop)}> {drop.rank && drop.rank <= 3 ? ( ) : ( )} diff --git a/components/waves/small-leaderboard/MemesWaveSmallLeaderboardDrop.tsx b/components/waves/small-leaderboard/MemesWaveSmallLeaderboardDrop.tsx index b9338f4d94..5a33fa1ea5 100644 --- a/components/waves/small-leaderboard/MemesWaveSmallLeaderboardDrop.tsx +++ b/components/waves/small-leaderboard/MemesWaveSmallLeaderboardDrop.tsx @@ -2,27 +2,23 @@ import React from "react"; import { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { WaveSmallLeaderboardTopThreeDrop } from "./WaveSmallLeaderboardTopThreeDrop"; import { WaveSmallLeaderboardDefaultDrop } from "./WaveSmallLeaderboardDefaultDrop"; -import { ApiWave } from "@/generated/models/ApiWave"; interface MemesWaveSmallLeaderboardDropProps { readonly drop: ExtendedDrop; - readonly wave: ApiWave; readonly onDropClick: (drop: ExtendedDrop) => void; } -export const MemesWaveSmallLeaderboardDrop: React.FC = ({ drop, wave, onDropClick }) => { +export const MemesWaveSmallLeaderboardDrop: React.FC = ({ drop, onDropClick }) => { return (
onDropClick(drop)}> {drop.rank && drop.rank <= 3 ? ( ) : ( )} diff --git a/components/waves/small-leaderboard/WaveSmallLeaderboardDefaultDrop.tsx b/components/waves/small-leaderboard/WaveSmallLeaderboardDefaultDrop.tsx index a582b421aa..745c4ff0c7 100644 --- a/components/waves/small-leaderboard/WaveSmallLeaderboardDefaultDrop.tsx +++ b/components/waves/small-leaderboard/WaveSmallLeaderboardDefaultDrop.tsx @@ -1,6 +1,5 @@ import React from "react"; import { ExtendedDrop } from "@/helpers/waves/drop.helpers"; -import { ApiWave } from "@/generated/models/ApiWave"; import Link from "next/link"; import { CICType } from "@/entities/IProfile"; import { cicToType, formatNumberWithCommas } from "@/helpers/Helpers"; @@ -14,13 +13,12 @@ import UserProfileTooltipWrapper from "@/components/utils/tooltip/UserProfileToo interface WaveSmallLeaderboardDefaultDropProps { readonly drop: ExtendedDrop; - readonly wave: ApiWave; readonly onDropClick: (drop: ExtendedDrop) => void; } export const WaveSmallLeaderboardDefaultDrop: React.FC< WaveSmallLeaderboardDefaultDropProps -> = ({ drop, wave, onDropClick }) => { +> = ({ drop, onDropClick }) => { const getCICColor = (cic: number): string => { const cicType = cicToType(cic); switch (cicType) { @@ -129,7 +127,7 @@ export const WaveSmallLeaderboardDefaultDrop: React.FC< />
- +
diff --git a/components/waves/small-leaderboard/WaveSmallLeaderboardDrop.tsx b/components/waves/small-leaderboard/WaveSmallLeaderboardDrop.tsx index a970e9b5d5..c7a3efdcbc 100644 --- a/components/waves/small-leaderboard/WaveSmallLeaderboardDrop.tsx +++ b/components/waves/small-leaderboard/WaveSmallLeaderboardDrop.tsx @@ -19,7 +19,6 @@ export const WaveSmallLeaderboardDrop: React.FC< return ( ); @@ -27,7 +26,6 @@ export const WaveSmallLeaderboardDrop: React.FC< return ( ); diff --git a/components/waves/small-leaderboard/WaveSmallLeaderboardItemOutcomes.tsx b/components/waves/small-leaderboard/WaveSmallLeaderboardItemOutcomes.tsx index 54faf804fc..2c712abdef 100644 --- a/components/waves/small-leaderboard/WaveSmallLeaderboardItemOutcomes.tsx +++ b/components/waves/small-leaderboard/WaveSmallLeaderboardItemOutcomes.tsx @@ -5,97 +5,17 @@ import { Tooltip } from "react-tooltip"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faAddressCard, faStar } from "@fortawesome/free-regular-svg-icons"; import { faAward } from "@fortawesome/free-solid-svg-icons"; - -import { ApiWave } from "@/generated/models/ApiWave"; -import { ApiWaveOutcomeCredit } from "@/generated/models/ApiWaveOutcomeCredit"; -import { ApiWaveOutcomeType } from "@/generated/models/ApiWaveOutcomeType"; import { ApiDrop } from "@/generated/models/ApiDrop"; +import { useWaveRankReward } from "@/hooks/waves/useWaveRankReward"; interface WaveSmallLeaderboardItemOutcomesProps { readonly drop: ApiDrop; - readonly wave: ApiWave; readonly isMobile?: boolean; } -interface OutcomeSummary { - nicTotal: number; - repTotal: number; - manualOutcomes: string[]; -} - -const calculateNIC = ({ - drop, - wave, -}: { - drop: ApiDrop; - wave: ApiWave; -}): number => { - const rank = drop.rank; - if (!rank) return 0; - const outcomes = wave.outcomes; - const nicOutcomes = outcomes.filter( - (outcome) => outcome.credit === ApiWaveOutcomeCredit.Cic - ); - const nic = nicOutcomes.reduce((acc, outcome) => { - return acc + (outcome.distribution?.[rank - 1]?.amount ?? 0); - }, 0); - return nic; -}; - -const calculateRep = ({ - drop, - wave, -}: { - drop: ApiDrop; - wave: ApiWave; -}): number => { - const rank = drop.rank; - if (!rank) return 0; - const outcomes = wave.outcomes; - const repOutcomes = outcomes.filter( - (outcome) => outcome.credit === ApiWaveOutcomeCredit.Rep - ); - const rep = repOutcomes.reduce((acc, outcome) => { - return acc + (outcome.distribution?.[rank - 1]?.amount ?? 0); - }, 0); - return rep; -}; - -const calculateManualOutcomes = ({ - drop, - wave, -}: { - drop: ApiDrop; - wave: ApiWave; -}): string[] => { - const rank = drop.rank; - if (!rank) return []; - const outcomes = wave.outcomes; - const manualOutcomes = outcomes.filter( - (outcome) => outcome.type === ApiWaveOutcomeType.Manual - ); - return manualOutcomes - .filter((outcome) => !!outcome.distribution?.[rank - 1]?.amount) - .map((outcome) => outcome.distribution?.[rank - 1]?.description ?? ""); -}; - -const calculateOutcomeSummary = ({ - drop, - wave, -}: { - drop: ApiDrop; - wave: ApiWave; -}): OutcomeSummary => { - return { - nicTotal: calculateNIC({ drop, wave }), - repTotal: calculateRep({ drop, wave }), - manualOutcomes: calculateManualOutcomes({ drop, wave }), - }; -}; - export const WaveSmallLeaderboardItemOutcomes: React.FC< WaveSmallLeaderboardItemOutcomesProps -> = ({ drop, wave, isMobile = false }) => { +> = ({ drop, isMobile = false }) => { const [isTouch, setIsTouch] = useState(false); const [isOpen, setIsOpen] = useState(false); @@ -110,17 +30,24 @@ export const WaveSmallLeaderboardItemOutcomes: React.FC< } }; - const { nicTotal, repTotal, manualOutcomes } = calculateOutcomeSummary({ - drop, - wave, + const { nicTotal, repTotal, manualOutcomes, isLoading } = useWaveRankReward({ + waveId: drop.wave.id, + rank: drop.rank, }); + const totalOutcomes = (nicTotal ? 1 : 0) + (repTotal ? 1 : 0) + manualOutcomes.length; - if (totalOutcomes === 0) { + if (totalOutcomes === 0 && !isLoading) { return null; } + if (isLoading) { + return ( +
+ ) + } + const tooltipContent = (
@@ -184,11 +111,9 @@ export const WaveSmallLeaderboardItemOutcomes: React.FC< <>
- +
diff --git a/hooks/waves/useWaveRankReward.ts b/hooks/waves/useWaveRankReward.ts new file mode 100644 index 0000000000..d14cf63c57 --- /dev/null +++ b/hooks/waves/useWaveRankReward.ts @@ -0,0 +1,96 @@ +import { useQueries } from "@tanstack/react-query"; +import { useWaveOutcomesQuery } from "./useWaveOutcomesQuery"; +import { ApiWaveOutcomeCredit } from "@/generated/models/ApiWaveOutcomeCredit"; +import { ApiWaveOutcomeType } from "@/generated/models/ApiWaveOutcomeType"; +import { commonApiFetch } from "@/services/api/common-api"; +import { ApiWaveOutcomeDistributionItemsPage } from "@/generated/models/ApiWaveOutcomeDistributionItemsPage"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; + +const DISTRIBUTION_PAGE_SIZE = 100; + +export interface WaveRankRewards { + readonly nicTotal: number; + readonly repTotal: number; + readonly manualOutcomes: string[]; + readonly isLoading: boolean; +} + +export function useWaveRankReward({ + waveId, + rank, + enabled = true, +}: { + waveId: string; + rank: number | null; + enabled?: boolean; +}): WaveRankRewards { + const { outcomes, isEnabled: isOutcomesEnabled } = useWaveOutcomesQuery({ + waveId, + enabled: enabled && !!rank, + }); + + const targetIndex = rank ? rank - 1 : 0; + const page = Math.floor(targetIndex / DISTRIBUTION_PAGE_SIZE) + 1; + + const distributionQueries = useQueries({ + queries: outcomes.map((outcome) => ({ + queryKey: [ + QueryKey.WAVE_OUTCOME_DISTRIBUTION_PAGE, + waveId, + outcome.index, + page, + DISTRIBUTION_PAGE_SIZE, + ], + queryFn: () => + commonApiFetch({ + endpoint: `waves/${waveId}/outcomes/${outcome.index}/distribution`, + params: { + page: page.toString(), + page_size: DISTRIBUTION_PAGE_SIZE.toString(), + }, + }), + enabled: isOutcomesEnabled && !!rank && enabled, + staleTime: 60000, // Cache for a minute + })), + }); + + const isLoading = distributionQueries.some((q) => q.isLoading); + + if (!rank || !enabled) { + return { + nicTotal: 0, + repTotal: 0, + manualOutcomes: [], + isLoading: false + } + } + + let nicTotal = 0; + let repTotal = 0; + const manualOutcomes: string[] = []; + + outcomes.forEach((outcome, i) => { + const query = distributionQueries[i]; + if (query.data?.data) { + const item = query.data.data.find((d) => d.index === targetIndex); + if (item?.amount) { + if (outcome.credit === ApiWaveOutcomeCredit.Cic) { + nicTotal += item.amount; + } else if (outcome.credit === ApiWaveOutcomeCredit.Rep) { + repTotal += item.amount; + } else if (outcome.type === ApiWaveOutcomeType.Manual) { + if (item.description) { + manualOutcomes.push(item.description); + } + } + } + } + }); + + return { + nicTotal, + repTotal, + manualOutcomes, + isLoading, + }; +}