From fa9b8fdfb9d1653d9ed6039da92275c0771a2254 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Sep 2025 00:51:06 +0000 Subject: [PATCH 1/5] Initial plan From b28db2a7c7306a84b1a84b46f437b57937b927d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Sep 2025 00:59:17 +0000 Subject: [PATCH 2/5] Move presentation components UserRow and CompetitorListItem to components folder Co-authored-by: coder13 <881394+coder13@users.noreply.github.com> --- .../CompetitorListItem}/CompetitorListItem.tsx | 0 src/components/CompetitorListItem/index.ts | 1 + .../Information => components/UserRow}/UserRow.tsx | 6 +++++- src/components/UserRow/index.ts | 1 + src/components/index.ts | 2 ++ src/containers/Competitors/Competitors.tsx | 2 +- src/pages/Competition/Information/index.tsx | 3 +-- 7 files changed, 11 insertions(+), 4 deletions(-) rename src/{containers/Competitors => components/CompetitorListItem}/CompetitorListItem.tsx (100%) create mode 100644 src/components/CompetitorListItem/index.ts rename src/{pages/Competition/Information => components/UserRow}/UserRow.tsx (90%) create mode 100644 src/components/UserRow/index.ts diff --git a/src/containers/Competitors/CompetitorListItem.tsx b/src/components/CompetitorListItem/CompetitorListItem.tsx similarity index 100% rename from src/containers/Competitors/CompetitorListItem.tsx rename to src/components/CompetitorListItem/CompetitorListItem.tsx diff --git a/src/components/CompetitorListItem/index.ts b/src/components/CompetitorListItem/index.ts new file mode 100644 index 0000000..1462c53 --- /dev/null +++ b/src/components/CompetitorListItem/index.ts @@ -0,0 +1 @@ +export * from './CompetitorListItem'; diff --git a/src/pages/Competition/Information/UserRow.tsx b/src/components/UserRow/UserRow.tsx similarity index 90% rename from src/pages/Competition/Information/UserRow.tsx rename to src/components/UserRow/UserRow.tsx index 9942b9b..96ae2d7 100644 --- a/src/pages/Competition/Information/UserRow.tsx +++ b/src/components/UserRow/UserRow.tsx @@ -1,4 +1,8 @@ -export const UserRow = ({ user }: { user: User }) => { +interface UserRowProps { + user: User; +} + +export const UserRow = ({ user }: UserRowProps) => { const avatarUrl = user.avatar?.thumb_url; return ( diff --git a/src/components/UserRow/index.ts b/src/components/UserRow/index.ts new file mode 100644 index 0000000..dcd43a0 --- /dev/null +++ b/src/components/UserRow/index.ts @@ -0,0 +1 @@ +export * from './UserRow'; diff --git a/src/components/index.ts b/src/components/index.ts index 87e3260..8bb8b71 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -6,6 +6,7 @@ export * from './Button'; export * from './CompetitionList'; export * from './CompetitionListItem'; export * from './CompetitionSelect'; +export * from './CompetitorListItem'; export * from './Container'; export * from './CutoffTimeLimitPanel'; export * from './DisclaimerText'; @@ -15,3 +16,4 @@ export * from './LastFetchedAt'; export * from './LinkButton'; export * from './Notebox'; export * from './PinCompetitionButton'; +export * from './UserRow'; diff --git a/src/containers/Competitors/Competitors.tsx b/src/containers/Competitors/Competitors.tsx index 627f100..8fecc6f 100644 --- a/src/containers/Competitors/Competitors.tsx +++ b/src/containers/Competitors/Competitors.tsx @@ -2,12 +2,12 @@ import { Competition, Person } from '@wca/helpers'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; +import { CompetitorListItem } from '@/components'; import { usePinnedPersons } from '@/hooks/UsePinnedPersons'; import { useOngoingActivities } from '@/hooks/useOngoingActivities'; import { acceptedRegistration } from '@/lib/person'; import { byName } from '@/lib/utils'; import { useAuth } from '@/providers/AuthProvider'; -import { CompetitorListItem } from './CompetitorListItem'; export const Competitors = ({ wcif }: { wcif: Competition }) => { const { t } = useTranslation(); diff --git a/src/pages/Competition/Information/index.tsx b/src/pages/Competition/Information/index.tsx index ee9b44b..a7e561d 100644 --- a/src/pages/Competition/Information/index.tsx +++ b/src/pages/Competition/Information/index.tsx @@ -1,9 +1,8 @@ import { useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import { Container, ExternalLink } from '@/components'; +import { Container, ExternalLink, UserRow } from '@/components'; import { useCompetition } from '@/hooks/queries/useCompetition'; import { useWCIF } from '@/providers/WCIFProvider'; -import { UserRow } from './UserRow'; export default function Information() { const { setTitle, wcif } = useWCIF(); From df5b1229fa74e94324831b6eb65dc32e5b6e8bfc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Sep 2025 01:06:35 +0000 Subject: [PATCH 3/5] Break down Group.tsx into separate components - extract GroupHeader, GroupButtonMenu, MobileGroupView, DesktopGroupView Co-authored-by: coder13 <881394+coder13@users.noreply.github.com> --- .../DesktopGroupView/DesktopGroupView.tsx | 72 +++++ src/components/DesktopGroupView/index.ts | 1 + .../GroupButtonMenu/GroupButtonMenu.tsx | 86 +++++ src/components/GroupButtonMenu/index.ts | 1 + src/components/GroupHeader/GroupHeader.tsx | 75 +++++ src/components/GroupHeader/index.ts | 1 + .../MobileGroupView/MobileGroupView.tsx | 79 +++++ src/components/MobileGroupView/index.ts | 1 + src/components/index.ts | 4 + src/pages/Competition/ByGroup/Group.tsx | 304 ++---------------- 10 files changed, 342 insertions(+), 282 deletions(-) create mode 100644 src/components/DesktopGroupView/DesktopGroupView.tsx create mode 100644 src/components/DesktopGroupView/index.ts create mode 100644 src/components/GroupButtonMenu/GroupButtonMenu.tsx create mode 100644 src/components/GroupButtonMenu/index.ts create mode 100644 src/components/GroupHeader/GroupHeader.tsx create mode 100644 src/components/GroupHeader/index.ts create mode 100644 src/components/MobileGroupView/MobileGroupView.tsx create mode 100644 src/components/MobileGroupView/index.ts diff --git a/src/components/DesktopGroupView/DesktopGroupView.tsx b/src/components/DesktopGroupView/DesktopGroupView.tsx new file mode 100644 index 0000000..ea37f4b --- /dev/null +++ b/src/components/DesktopGroupView/DesktopGroupView.tsx @@ -0,0 +1,72 @@ +import { Person, Room } from '@wca/helpers'; +import { Fragment } from 'react'; +import { Link } from 'react-router-dom'; +import { AssignmentCodeCell } from '@/components/AssignmentCodeCell'; +import { Container } from '@/components/Container'; +import { GroupAssignmentCodeRank } from '@/lib/constants'; +import { byName } from '@/lib/utils'; + +interface DesktopGroupViewProps { + rooms: Room[]; + personsInActivity: Person[]; + children?: React.ReactNode; +} + +export const DesktopGroupView = ({ rooms, personsInActivity, children }: DesktopGroupViewProps) => { + return ( + + {children} +
+ {rooms.map((stage) => ( +
+ {stage.name} +
+ ))} + {GroupAssignmentCodeRank.filter((assignmentCode) => + personsInActivity?.some((person) => person.assignment?.assignmentCode === assignmentCode), + ).map((assignmentCode) => { + const personsInActivityWithAssignment = + personsInActivity?.filter( + (person) => person.assignment?.assignmentCode === assignmentCode, + ) || []; + return ( + + + + {rooms.map((room) => ( +
+ {personsInActivityWithAssignment + ?.filter((person) => person.room?.id === room.id) + ?.sort(byName) + .map((person) => ( + + {person.name} + + ))} +
+ ))} +
+ ); + })} +
+
+ ); +}; diff --git a/src/components/DesktopGroupView/index.ts b/src/components/DesktopGroupView/index.ts new file mode 100644 index 0000000..8caf85f --- /dev/null +++ b/src/components/DesktopGroupView/index.ts @@ -0,0 +1 @@ +export * from './DesktopGroupView'; diff --git a/src/components/GroupButtonMenu/GroupButtonMenu.tsx b/src/components/GroupButtonMenu/GroupButtonMenu.tsx new file mode 100644 index 0000000..fc39243 --- /dev/null +++ b/src/components/GroupButtonMenu/GroupButtonMenu.tsx @@ -0,0 +1,86 @@ +import { Competition } from '@wca/helpers'; +import classNames from 'classnames'; +import { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { nextActivityCode, prevActivityCode } from '@/lib/activityCodes'; + +interface GroupButtonMenuProps { + wcif?: Competition; + activityCode: string; +} + +export const GroupButtonMenu = ({ wcif, activityCode }: GroupButtonMenuProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { competitionId } = useParams(); + + const prev = wcif && prevActivityCode(wcif, activityCode); + const next = wcif && nextActivityCode(wcif, activityCode); + + const prevUrl = `/competitions/${competitionId}/events/${prev?.split?.('-g')?.[0]}/${ + prev?.split?.('-g')?.[1] + }`; + const nextUrl = `/competitions/${competitionId}/events/${next?.split?.('-g')?.[0]}/${ + next?.split?.('-g')?.[1] + }`; + + const goToPrev = useCallback(() => { + if (prev) { + navigate(prevUrl); + } + }, [prev, navigate, prevUrl]); + + const goToNext = useCallback(() => { + if (next) { + navigate(nextUrl); + } + }, [next, navigate, nextUrl]); + + useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => { + if (event.key === 'ArrowLeft') { + goToPrev(); + } + + if (event.key === 'ArrowRight') { + goToNext(); + } + }; + + document.addEventListener('keydown', handleKeydown); + + return () => { + document.removeEventListener('keydown', handleKeydown); + }; + }, [wcif, activityCode, goToPrev, goToNext]); + + return ( +
+ + + {t('competition.groups.previousGroup')} + + + {t('competition.groups.nextGroup')} + + +
+ ); +}; diff --git a/src/components/GroupButtonMenu/index.ts b/src/components/GroupButtonMenu/index.ts new file mode 100644 index 0000000..b3f819e --- /dev/null +++ b/src/components/GroupButtonMenu/index.ts @@ -0,0 +1 @@ +export * from './GroupButtonMenu'; diff --git a/src/components/GroupHeader/GroupHeader.tsx b/src/components/GroupHeader/GroupHeader.tsx new file mode 100644 index 0000000..6b34f85 --- /dev/null +++ b/src/components/GroupHeader/GroupHeader.tsx @@ -0,0 +1,75 @@ +import { Room, Round } from '@wca/helpers'; +import { Fragment } from 'react'; +import { ActivityRow } from '@/components'; +import { Breadcrumbs } from '@/components/Breadcrumbs/Breadcrumbs'; +import { CutoffTimeLimitPanel } from '@/components/CutoffTimeLimitPanel'; +import { hasActivities } from '@/lib/activities'; +import { activityCodeToName } from '@/lib/activityCodes'; +import { useWCIF } from '@/providers/WCIFProvider'; + +interface GroupHeaderProps { + round?: Round; + activityCode: string; + rooms: Room[]; + children?: React.ReactNode; +} + +export const GroupHeader = ({ round, activityCode, rooms, children }: GroupHeaderProps) => { + const { competitionId } = useWCIF(); + + const activityName = activityCodeToName(activityCode); + const activityNameSplit = activityName.split(', '); + + const roundName = activityNameSplit.slice(0, 2).join(', '); + const groupName = activityNameSplit ? activityNameSplit.slice(-1).join('') : undefined; + + return ( +
+
+ +
+ + {children} +
+ {round && } +
+
+ {rooms?.filter(hasActivities(activityCode)).map((room) => { + const activity = room.activities + .flatMap((ra) => ra.childActivities) + .find((a) => a.activityCode === activityCode); + + if (!activity) { + return null; + } + const venue = room.venue; + const timeZone = venue.timezone; + + return ( + + {/* {multistage &&
{stage.name}:
} +
+ {activity && formatDateTimeRange(minStartTime, maxEndTime)} +
*/} + +
+ ); + })} +
+
+ ); +}; diff --git a/src/components/GroupHeader/index.ts b/src/components/GroupHeader/index.ts new file mode 100644 index 0000000..8484d3b --- /dev/null +++ b/src/components/GroupHeader/index.ts @@ -0,0 +1 @@ +export * from './GroupHeader'; diff --git a/src/components/MobileGroupView/MobileGroupView.tsx b/src/components/MobileGroupView/MobileGroupView.tsx new file mode 100644 index 0000000..b91432b --- /dev/null +++ b/src/components/MobileGroupView/MobileGroupView.tsx @@ -0,0 +1,79 @@ +import { Competition, Person } from '@wca/helpers'; +import { Fragment } from 'react'; +import { Link } from 'react-router-dom'; +import { AssignmentCodeCell } from '@/components/AssignmentCodeCell'; +import { Container } from '@/components/Container'; +import { GroupAssignmentCodeRank } from '@/lib/constants'; +import { byName } from '@/lib/utils'; + +interface MobileGroupViewProps { + wcif?: Competition; + personsInActivity: Person[]; + multistage: boolean; + children?: React.ReactNode; +} + +export const MobileGroupView = ({ + wcif, + personsInActivity, + multistage, + children, +}: MobileGroupViewProps) => { + return ( + + {children} +
+ {GroupAssignmentCodeRank.filter((assignmentCode) => + personsInActivity?.some((person) => person.assignment?.assignmentCode === assignmentCode), + ).map((assignmentCode) => { + const personsInActivityWithAssignment = + personsInActivity?.filter( + (person) => person.assignment?.assignmentCode === assignmentCode, + ) || []; + + return ( + +
+
+ +
+ {personsInActivityWithAssignment + .sort((a, b) => { + const stageSort = (a.stage?.name || '').localeCompare(b.stage?.name || ''); + return stageSort !== 0 ? stageSort : byName(a, b); + }) + ?.map((person) => ( + +
{person.name}
+ {multistage && ( +
+ {person.stage && person.stage.name} +
+ )} + + ))} +
+
+
+
+ ); + })} +
+
+ ); +}; diff --git a/src/components/MobileGroupView/index.ts b/src/components/MobileGroupView/index.ts new file mode 100644 index 0000000..7ba28e1 --- /dev/null +++ b/src/components/MobileGroupView/index.ts @@ -0,0 +1 @@ +export * from './MobileGroupView'; diff --git a/src/components/index.ts b/src/components/index.ts index 8bb8b71..642cf8c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -9,11 +9,15 @@ export * from './CompetitionSelect'; export * from './CompetitorListItem'; export * from './Container'; export * from './CutoffTimeLimitPanel'; +export * from './DesktopGroupView'; export * from './DisclaimerText'; export * from './ErrorFallback'; export * from './ExternalLink'; +export * from './GroupButtonMenu'; +export * from './GroupHeader'; export * from './LastFetchedAt'; export * from './LinkButton'; +export * from './MobileGroupView'; export * from './Notebox'; export * from './PinCompetitionButton'; export * from './UserRow'; diff --git a/src/pages/Competition/ByGroup/Group.tsx b/src/pages/Competition/ByGroup/Group.tsx index e1e2556..22b9893 100644 --- a/src/pages/Competition/ByGroup/Group.tsx +++ b/src/pages/Competition/ByGroup/Group.tsx @@ -1,27 +1,12 @@ import { ActivityCode } from '@wca/helpers'; -import classNames from 'classnames'; -import { Fragment, useCallback, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Link, useNavigate, useParams } from 'react-router-dom'; -import { ActivityRow } from '@/components'; -import { AssignmentCodeCell } from '@/components/AssignmentCodeCell'; -import { Breadcrumbs } from '@/components/Breadcrumbs/Breadcrumbs'; -import { Container } from '@/components/Container'; -import { CutoffTimeLimitPanel } from '@/components/CutoffTimeLimitPanel'; -import { getAllRoundActivities, getRoomData, getRooms, hasActivities } from '@/lib/activities'; -import { - activityCodeToName, - matchesActivityCode, - nextActivityCode, - prevActivityCode, - toRoundAttemptId, -} from '@/lib/activityCodes'; -import { GroupAssignmentCodeRank } from '@/lib/constants'; +import { useParams } from 'react-router-dom'; +import { DesktopGroupView, GroupButtonMenu, GroupHeader, MobileGroupView } from '@/components'; +import { getAllRoundActivities, getRoomData, getRooms } from '@/lib/activities'; +import { matchesActivityCode, toRoundAttemptId } from '@/lib/activityCodes'; import { getAllEvents } from '@/lib/events'; -import { byName } from '@/lib/utils'; import { useWCIF } from '@/providers/WCIFProvider'; -const useCommon = () => { +export const useCommon = () => { const { wcif } = useWCIF(); const { roundId, groupNumber } = useParams(); const activityCode = `${roundId}-g${groupNumber}` as ActivityCode; @@ -88,270 +73,25 @@ const useCommon = () => { }; export default function Group() { - return ( - <> - - - - ); -} - -export const GroupHeader = () => { - const { competitionId } = useWCIF(); - const { round, activityCode, rooms } = useCommon(); - - const activityName = activityCodeToName(activityCode); - const activityNameSplit = activityName.split(', '); - - const roundName = activityNameSplit.slice(0, 2).join(', '); - const groupName = activityNameSplit ? activityNameSplit.slice(-1).join('') : undefined; - - return ( -
-
- -
- - -
- {round && } -
-
- {rooms?.filter(hasActivities(activityCode)).map((room) => { - const activity = room.activities - .flatMap((ra) => ra.childActivities) - .find((a) => a.activityCode === activityCode); - - if (!activity) { - return null; - } - const venue = room.venue; - const timeZone = venue.timezone; - - return ( - - {/* {multistage &&
{stage.name}:
} -
- {activity && formatDateTimeRange(minStartTime, maxEndTime)} -
*/} - -
- ); - })} -
-
- ); -}; - -export const MobileGroupView = () => { - const { wcif, personsInActivity, multistage } = useCommon(); - - return ( - - -
- {GroupAssignmentCodeRank.filter((assignmentCode) => - personsInActivity?.some((person) => person.assignment?.assignmentCode === assignmentCode), - ).map((assignmentCode) => { - const personsInActivityWithAssignment = - personsInActivity?.filter( - (person) => person.assignment?.assignmentCode === assignmentCode, - ) || []; - - return ( - -
-
- -
- {personsInActivityWithAssignment - .sort((a, b) => { - const stageSort = (a.stage?.name || '').localeCompare(b.stage?.name || ''); - return stageSort !== 0 ? stageSort : byName(a, b); - }) - ?.map((person) => ( - -
{person.name}
- {multistage && ( -
- {person.stage && person.stage.name} -
- )} - - ))} -
-
-
-
- ); - })} -
-
- ); -}; - -const DesktopGroupView = () => { - const { rooms, personsInActivity } = useCommon(); - - return ( - - -
- {rooms.map((stage) => ( -
- {stage.name} -
- ))} - {GroupAssignmentCodeRank.filter((assignmentCode) => - personsInActivity?.some((person) => person.assignment?.assignmentCode === assignmentCode), - ).map((assignmentCode) => { - const personsInActivityWithAssignment = - personsInActivity?.filter( - (person) => person.assignment?.assignmentCode === assignmentCode, - ) || []; - return ( - - + const { wcif, personsInActivity, multistage, round, activityCode, rooms } = useCommon(); - {rooms.map((room) => ( -
- {personsInActivityWithAssignment - ?.filter((person) => person.room?.id === room.id) - ?.sort(byName) - .map((person) => ( - - {person.name} - - ))} -
- ))} -
- ); - })} -
-
+ const groupHeader = ( + + + ); -}; - -export const GroupButtonMenu = () => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const { competitionId } = useParams(); - const { wcif, activityCode } = useCommon(); - - const prev = wcif && prevActivityCode(wcif, activityCode); - const next = wcif && nextActivityCode(wcif, activityCode); - - const prevUrl = `/competitions/${competitionId}/events/${prev?.split?.('-g')?.[0]}/${ - prev?.split?.('-g')?.[1] - }`; - const nextUrl = `/competitions/${competitionId}/events/${next?.split?.('-g')?.[0]}/${ - next?.split?.('-g')?.[1] - }`; - - const goToPrev = useCallback(() => { - if (prev) { - navigate(prevUrl); - } - }, [prev, navigate, prevUrl]); - - const goToNext = useCallback(() => { - if (next) { - navigate(nextUrl); - } - }, [next, navigate, nextUrl]); - - useEffect(() => { - const handleKeydown = (event: KeyboardEvent) => { - if (event.key === 'ArrowLeft') { - goToPrev(); - } - - if (event.key === 'ArrowRight') { - goToNext(); - } - }; - - document.addEventListener('keydown', handleKeydown); - - return () => { - document.removeEventListener('keydown', handleKeydown); - }; - }, [wcif, activityCode, goToPrev, goToNext]); return ( -
- - - {t('competition.groups.previousGroup')} - - - {t('competition.groups.nextGroup')} - - -
+ <> + + {groupHeader} + + + {groupHeader} + + ); -}; +} From b61107cf33ada9ccd38cad1e06a81a4cf26d8cd5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Sep 2025 01:11:46 +0000 Subject: [PATCH 4/5] Add comprehensive tests for moved and extracted components Co-authored-by: coder13 <881394+coder13@users.noreply.github.com> --- .../CompetitorListItem.test.tsx | 93 +++++++++++++++ .../GroupButtonMenu/GroupButtonMenu.test.tsx | 108 ++++++++++++++++++ src/components/UserRow/UserRow.test.tsx | 57 +++++++++ 3 files changed, 258 insertions(+) create mode 100644 src/components/CompetitorListItem/CompetitorListItem.test.tsx create mode 100644 src/components/GroupButtonMenu/GroupButtonMenu.test.tsx create mode 100644 src/components/UserRow/UserRow.test.tsx diff --git a/src/components/CompetitorListItem/CompetitorListItem.test.tsx b/src/components/CompetitorListItem/CompetitorListItem.test.tsx new file mode 100644 index 0000000..80c1410 --- /dev/null +++ b/src/components/CompetitorListItem/CompetitorListItem.test.tsx @@ -0,0 +1,93 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { Person } from '@wca/helpers'; +import { MemoryRouter } from 'react-router-dom'; +import { CompetitorListItem } from './CompetitorListItem'; + +// Mock the translation hook +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +// Mock the AssignmentCodeCell component to avoid tailwind colors import issues +jest.mock('@/components/AssignmentCodeCell', () => ({ + AssignmentCodeCell: ({ + children, + assignmentCode, + }: { + children?: React.ReactNode; + assignmentCode?: string; + }) =>
{children || assignmentCode}
, +})); + +const mockPerson: Person = { + registrantId: 1, + name: 'John Doe', + wcaUserId: 123, + wcaId: '2023DOEJ01', + countryIso2: 'US', + gender: 'm', + birthdate: '1990-01-01', + email: 'john@example.com', + avatar: { + url: 'https://example.com/full.jpg', + thumbUrl: 'https://example.com/thumb.jpg', + }, + roles: [], + assignments: [], + personalBests: [], + extensions: [], + registration: { + wcaRegistrationId: 1, + eventIds: ['333'], + status: 'accepted', + isCompeting: true, + }, +}; + +function renderWithRouter(ui: React.ReactElement) { + return render(ui, { + wrapper: MemoryRouter, + }); +} + +describe('CompetitorListItem', () => { + it('renders basic competitor item', () => { + renderWithRouter(); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByRole('link')).toHaveAttribute('href', expect.stringContaining('persons/1')); + }); + + it('renders highlighted competitor with avatar', () => { + renderWithRouter(); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('competition.competitors.viewMyAssignments')).toBeInTheDocument(); + expect(screen.getByRole('img')).toHaveAttribute('src', 'https://example.com/thumb.jpg'); + }); + + it('renders bookmarked competitor', () => { + renderWithRouter(); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('', { selector: '.fa-bookmark' })).toBeInTheDocument(); + }); + + it('renders competitor with assignment', () => { + renderWithRouter(); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + // The AssignmentCodeCell should be rendered but we won't test its internals + }); + + it('does not show bookmark when assignment is present', () => { + renderWithRouter( + , + ); + + expect(screen.queryByText('', { selector: '.fa-bookmark' })).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/GroupButtonMenu/GroupButtonMenu.test.tsx b/src/components/GroupButtonMenu/GroupButtonMenu.test.tsx new file mode 100644 index 0000000..497afe4 --- /dev/null +++ b/src/components/GroupButtonMenu/GroupButtonMenu.test.tsx @@ -0,0 +1,108 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { Competition } from '@wca/helpers'; +import { MemoryRouter } from 'react-router-dom'; +import { GroupButtonMenu } from './GroupButtonMenu'; + +// Mock the translation hook +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +// Mock react-router-dom +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, + useParams: () => ({ competitionId: 'TestComp2023' }), +})); + +// Mock activity codes helper +jest.mock('@/lib/activityCodes', () => ({ + prevActivityCode: (_wcif: Competition, activityCode: string) => { + if (activityCode === '333-r1-g2') return '333-r1-g1'; + return null; + }, + nextActivityCode: (_wcif: Competition, activityCode: string) => { + if (activityCode === '333-r1-g2') return '333-r1-g3'; + return null; + }, +})); + +const mockWcif: Competition = { + formatVersion: '1.0', + id: 'TestComp2023', + name: 'Test Competition 2023', + shortName: 'TestComp2023', + persons: [], + events: [], + schedule: { + startDate: '2023-01-01', + numberOfDays: 1, + venues: [], + }, + competitorLimit: 100, + extensions: [], +}; + +function renderWithRouter(ui: React.ReactElement) { + return render(ui, { + wrapper: MemoryRouter, + }); +} + +describe('GroupButtonMenu', () => { + beforeEach(() => { + mockNavigate.mockClear(); + + // Clear event listeners + const _events: Record = {}; + document.addEventListener = jest.fn((_event, _cb) => { + // No-op for tests + }); + document.removeEventListener = jest.fn((_event) => { + // No-op for tests + }); + }); + + it('renders navigation buttons', () => { + renderWithRouter(); + + expect(screen.getByText('competition.groups.previousGroup')).toBeInTheDocument(); + expect(screen.getByText('competition.groups.nextGroup')).toBeInTheDocument(); + }); + + it('enables both buttons when prev and next exist', () => { + renderWithRouter(); + + const prevButton = screen.getByRole('link', { name: /previousGroup/ }); + const nextButton = screen.getByRole('link', { name: /nextGroup/ }); + + expect(prevButton).not.toHaveClass('pointer-events-none'); + expect(nextButton).not.toHaveClass('pointer-events-none'); + expect(prevButton).toHaveAttribute('href', '/competitions/TestComp2023/events/333-r1/1'); + expect(nextButton).toHaveAttribute('href', '/competitions/TestComp2023/events/333-r1/3'); + }); + + it('disables buttons when no prev/next available', () => { + renderWithRouter(); + + const prevButton = screen.getByRole('link', { name: /previousGroup/ }); + const nextButton = screen.getByRole('link', { name: /nextGroup/ }); + + expect(prevButton).toHaveClass('pointer-events-none opacity-25'); + expect(nextButton).toHaveClass('pointer-events-none opacity-25'); + }); + + it('handles missing wcif gracefully', () => { + renderWithRouter(); + + const prevButton = screen.getByRole('link', { name: /previousGroup/ }); + const nextButton = screen.getByRole('link', { name: /nextGroup/ }); + + expect(prevButton).toHaveClass('pointer-events-none opacity-25'); + expect(nextButton).toHaveClass('pointer-events-none opacity-25'); + }); +}); diff --git a/src/components/UserRow/UserRow.test.tsx b/src/components/UserRow/UserRow.test.tsx new file mode 100644 index 0000000..2b5b559 --- /dev/null +++ b/src/components/UserRow/UserRow.test.tsx @@ -0,0 +1,57 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { UserRow } from './UserRow'; + +const mockUser: User = { + id: 1, + name: 'John Doe', + email: 'john@example.com', + wca_id: '2023DOEJ01', + avatar: { + thumb_url: 'https://example.com/avatar.jpg', + }, + delegate_status: 'none', +}; + +const mockUserNoWcaId: User = { + id: 2, + name: 'Jane Smith', + email: 'jane@example.com', + wca_id: '', + delegate_status: 'none', +}; + +describe('UserRow', () => { + it('renders user with WCA ID correctly', () => { + render(); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('2023DOEJ01')).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'John Doe' })).toHaveAttribute( + 'src', + 'https://example.com/avatar.jpg', + ); + expect(screen.getByRole('link')).toHaveAttribute( + 'href', + 'https://www.worldcubeassociation.org/persons/2023DOEJ01', + ); + expect(screen.getByRole('link')).toHaveAttribute('target', '_blank'); + }); + + it('renders user without WCA ID correctly', () => { + render(); + + expect(screen.getByText('Jane Smith')).toBeInTheDocument(); + expect(screen.getByRole('link')).toHaveAttribute('href', ''); + expect( + screen.queryByRole('button', { name: /fa-arrow-up-right-from-square/ }), + ).not.toBeInTheDocument(); + }); + + it('renders with missing avatar', () => { + const userWithoutAvatar = { ...mockUser, avatar: undefined }; + render(); + + expect(screen.getByRole('img')).not.toHaveAttribute('src'); + }); +}); From 08199d7b548563a05ff2adfbc9cdb5724a10bcbf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Sep 2025 01:16:35 +0000 Subject: [PATCH 5/5] Complete code organization - fix TypeScript types and add shared types Co-authored-by: coder13 <881394+coder13@users.noreply.github.com> --- dev-dist/sw.js | 2 +- src/components/DesktopGroupView/DesktopGroupView.tsx | 5 +++-- src/components/GroupHeader/GroupHeader.tsx | 4 ++-- src/components/MobileGroupView/MobileGroupView.tsx | 5 +++-- src/types/group.types.ts | 9 +++++++++ 5 files changed, 18 insertions(+), 7 deletions(-) create mode 100644 src/types/group.types.ts diff --git a/dev-dist/sw.js b/dev-dist/sw.js index 7a0a0a8..957bc46 100644 --- a/dev-dist/sw.js +++ b/dev-dist/sw.js @@ -81,7 +81,7 @@ define(['./workbox-5357ef54'], function (workbox) { [ { url: 'index.html', - revision: '0.3rdphr8mv9', + revision: '0.vjm3pcb1ifo', }, ], {}, diff --git a/src/components/DesktopGroupView/DesktopGroupView.tsx b/src/components/DesktopGroupView/DesktopGroupView.tsx index ea37f4b..f328c0d 100644 --- a/src/components/DesktopGroupView/DesktopGroupView.tsx +++ b/src/components/DesktopGroupView/DesktopGroupView.tsx @@ -1,14 +1,15 @@ -import { Person, Room } from '@wca/helpers'; +import { Room } from '@wca/helpers'; import { Fragment } from 'react'; import { Link } from 'react-router-dom'; import { AssignmentCodeCell } from '@/components/AssignmentCodeCell'; import { Container } from '@/components/Container'; import { GroupAssignmentCodeRank } from '@/lib/constants'; import { byName } from '@/lib/utils'; +import { ExtendedPerson } from '@/types/group.types'; interface DesktopGroupViewProps { rooms: Room[]; - personsInActivity: Person[]; + personsInActivity: ExtendedPerson[]; children?: React.ReactNode; } diff --git a/src/components/GroupHeader/GroupHeader.tsx b/src/components/GroupHeader/GroupHeader.tsx index 6b34f85..c524be8 100644 --- a/src/components/GroupHeader/GroupHeader.tsx +++ b/src/components/GroupHeader/GroupHeader.tsx @@ -1,4 +1,4 @@ -import { Room, Round } from '@wca/helpers'; +import { Room, Round, Venue } from '@wca/helpers'; import { Fragment } from 'react'; import { ActivityRow } from '@/components'; import { Breadcrumbs } from '@/components/Breadcrumbs/Breadcrumbs'; @@ -10,7 +10,7 @@ import { useWCIF } from '@/providers/WCIFProvider'; interface GroupHeaderProps { round?: Round; activityCode: string; - rooms: Room[]; + rooms: (Room & { venue: Venue })[]; children?: React.ReactNode; } diff --git a/src/components/MobileGroupView/MobileGroupView.tsx b/src/components/MobileGroupView/MobileGroupView.tsx index b91432b..a88565f 100644 --- a/src/components/MobileGroupView/MobileGroupView.tsx +++ b/src/components/MobileGroupView/MobileGroupView.tsx @@ -1,14 +1,15 @@ -import { Competition, Person } from '@wca/helpers'; +import { Competition } from '@wca/helpers'; import { Fragment } from 'react'; import { Link } from 'react-router-dom'; import { AssignmentCodeCell } from '@/components/AssignmentCodeCell'; import { Container } from '@/components/Container'; import { GroupAssignmentCodeRank } from '@/lib/constants'; import { byName } from '@/lib/utils'; +import { ExtendedPerson } from '@/types/group.types'; interface MobileGroupViewProps { wcif?: Competition; - personsInActivity: Person[]; + personsInActivity: ExtendedPerson[]; multistage: boolean; children?: React.ReactNode; } diff --git a/src/types/group.types.ts b/src/types/group.types.ts new file mode 100644 index 0000000..216e533 --- /dev/null +++ b/src/types/group.types.ts @@ -0,0 +1,9 @@ +import { Assignment, Competition, Person, Room, Venue } from '@wca/helpers'; + +export interface ExtendedPerson extends Person { + assignment?: Assignment; + activity?: any; + room?: Room & { venue: Venue }; + stage?: { name: string; color: string }; + wcif?: Competition; +}