Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dev-dist/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ define(['./workbox-5357ef54'], function (workbox) {
[
{
url: 'index.html',
revision: '0.3rdphr8mv9',
revision: '0.vjm3pcb1ifo',
},
],
{},
Expand Down
93 changes: 93 additions & 0 deletions src/components/CompetitorListItem/CompetitorListItem.test.tsx
Original file line number Diff line number Diff line change
@@ -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;
}) => <div data-testid="assignment-code-cell">{children || assignmentCode}</div>,
}));

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(<CompetitorListItem person={mockPerson} />);

expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByRole('link')).toHaveAttribute('href', expect.stringContaining('persons/1'));
});

it('renders highlighted competitor with avatar', () => {
renderWithRouter(<CompetitorListItem person={mockPerson} highlight />);

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(<CompetitorListItem person={mockPerson} bookmarked />);

expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('', { selector: '.fa-bookmark' })).toBeInTheDocument();
});

it('renders competitor with assignment', () => {
renderWithRouter(<CompetitorListItem person={mockPerson} currentAssignmentCode="competitor" />);

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(
<CompetitorListItem person={mockPerson} bookmarked currentAssignmentCode="competitor" />,
);

expect(screen.queryByText('', { selector: '.fa-bookmark' })).not.toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions src/components/CompetitorListItem/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './CompetitorListItem';
73 changes: 73 additions & 0 deletions src/components/DesktopGroupView/DesktopGroupView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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: ExtendedPerson[];
children?: React.ReactNode;
}

export const DesktopGroupView = ({ rooms, personsInActivity, children }: DesktopGroupViewProps) => {
return (
<Container className="space-y-2 md:w-2/3 hidden md:flex flex-col" fullWidth>
{children}
<div
className="grid"
style={{
gridTemplateColumns: `repeat(${rooms.length}, 1fr)`,
}}>
{rooms.map((stage) => (
<div
key={stage.id}
className="py-3 px-2 text-center flex-1 col-span-1"
style={{
backgroundColor: `${stage.color}4f`,
}}>
{stage.name}
</div>
))}
{GroupAssignmentCodeRank.filter((assignmentCode) =>
personsInActivity?.some((person) => person.assignment?.assignmentCode === assignmentCode),
).map((assignmentCode) => {
const personsInActivityWithAssignment =
personsInActivity?.filter(
(person) => person.assignment?.assignmentCode === assignmentCode,
) || [];
return (
<Fragment key={assignmentCode}>
<AssignmentCodeCell
as="div"
border
assignmentCode={assignmentCode}
count={personsInActivityWithAssignment.length}
className="p-1 col-span-full drop-shadow-lg font-bold mt-4"
/>

{rooms.map((room) => (
<div key={room.id} className="col-span-1 grid grid-cols-2 gap-x-4 gap-y-1">
{personsInActivityWithAssignment
?.filter((person) => person.room?.id === room.id)
?.sort(byName)
.map((person) => (
<Link
key={person.registrantId}
to={`/competitions/${person.wcif?.id}/persons/${person.registrantId}`}
className="hover:opacity-80 hover:bg-slate-100 col-span-1 p-1 transition-colors duration-150">
{person.name}
</Link>
))}
</div>
))}
</Fragment>
);
})}
</div>
</Container>
);
};
1 change: 1 addition & 0 deletions src/components/DesktopGroupView/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './DesktopGroupView';
108 changes: 108 additions & 0 deletions src/components/GroupButtonMenu/GroupButtonMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
document.addEventListener = jest.fn((_event, _cb) => {
// No-op for tests
});
document.removeEventListener = jest.fn((_event) => {
// No-op for tests
});
});

it('renders navigation buttons', () => {
renderWithRouter(<GroupButtonMenu wcif={mockWcif} activityCode="333-r1-g2" />);

expect(screen.getByText('competition.groups.previousGroup')).toBeInTheDocument();
expect(screen.getByText('competition.groups.nextGroup')).toBeInTheDocument();
});

it('enables both buttons when prev and next exist', () => {
renderWithRouter(<GroupButtonMenu wcif={mockWcif} activityCode="333-r1-g2" />);

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(<GroupButtonMenu wcif={mockWcif} activityCode="333-r1-g1" />);

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(<GroupButtonMenu wcif={undefined} activityCode="333-r1-g2" />);

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');
});
});
86 changes: 86 additions & 0 deletions src/components/GroupButtonMenu/GroupButtonMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex space-x-2">
<Link
to={prevUrl || ''}
className={classNames(
'w-full border rounded-md p-2 px-2 flex cursor-pointer transition-colors my-1 justify-end',
{
'pointer-events-none opacity-25': !prev,
'hover:bg-slate-100 group cursor-pointer': prev,
},
)}>
<span className="fa fa-arrow-left self-center mr-2 group-hover:-translate-x-2 transition-all" />
{t('competition.groups.previousGroup')}
</Link>
<Link
to={nextUrl || ''}
className={classNames(
'w-full border rounded-md p-2 px-2 flex cursor-pointer group hover:bg-slate-100 transition-colors my-1',
{
'pointer-events-none opacity-25': !next,
'hover:bg-slate-100 group': next,
},
)}>
{t('competition.groups.nextGroup')}
<span className="fa fa-arrow-right self-center ml-2 group-hover:translate-x-2 transition-all" />
</Link>
</div>
);
};
1 change: 1 addition & 0 deletions src/components/GroupButtonMenu/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './GroupButtonMenu';
Loading