Skip to content

Commit f505635

Browse files
committed
Add bulk round generation page
1 parent 8f8156e commit f505635

14 files changed

Lines changed: 1019 additions & 2 deletions

File tree

src/layout/CompetitionLayout/CompetitionHeader.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useAppSelector } from '../../store';
22
import { Tune } from '@mui/icons-material';
3+
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
34
import FileDownloadIcon from '@mui/icons-material/FileDownload';
45
import FileUploadIcon from '@mui/icons-material/FileUpload';
56
import HomeIcon from '@mui/icons-material/Home';
@@ -103,6 +104,11 @@ export const DrawerLinks = () => {
103104
icon: <ScheduleIcon />,
104105
text: 'Scrambler Schedule',
105106
},
107+
{
108+
url: `/competitions/${competitionId}/bulk-generation`,
109+
icon: <AutoFixHighIcon />,
110+
text: 'Bulk Generate',
111+
},
106112
{
107113
type: 'divider' as const,
108114
},

src/layout/CompetitionLayout/_tests_/CompetitionLayout.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ describe('CompetitionLayout', () => {
7373
expect(getByText('Child Content')).toBeInTheDocument();
7474
});
7575

76+
it('renders the bulk generation navigation link', () => {
77+
getLocalStorageMock.mockReturnValue('true');
78+
const { getByText } = renderLayout();
79+
80+
expect(getByText('Bulk Generate')).toBeInTheDocument();
81+
});
82+
7683
it('dispatches fetchWCIF on mount', () => {
7784
renderLayout();
7885

src/layout/RootLayout/Navigation.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Export as ExportPage,
1111
Import as ImportPage,
1212
ScramblerSchedule as ScramblerSchedulePage,
13+
BulkGeneration as BulkGenerationPage,
1314
} from '../../pages/Competition';
1415
import FirstTimers from '../../pages/Competition/Checks/FirstTimers';
1516
import DangerEditPage from '../../pages/Competition/DangerEdit/DangerEdit';
@@ -63,6 +64,7 @@ const Navigation = () => {
6364
<Route path="export" element={<ExportPage />} />
6465
<Route path="import" element={<ImportPage />} />
6566
<Route path="scrambler-schedule" element={<ScramblerSchedulePage />} />
67+
<Route path="bulk-generation" element={<BulkGenerationPage />} />
6668
<Route path="query" element={<QueryPage />} />
6769
<Route path="checks/first-timers" element={<FirstTimers />} />
6870
<Route path="external/groupifier-printing" element={<GroupifierPrintingConfig />} />
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { BulkRoundRow } from './bulkRoundRows';
2+
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
3+
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
4+
import {
5+
Checkbox,
6+
IconButton,
7+
Stack,
8+
Table,
9+
TableBody,
10+
TableCell,
11+
TableContainer,
12+
TableHead,
13+
TableRow,
14+
Tooltip,
15+
} from '@mui/material';
16+
17+
interface BulkRoundTableProps {
18+
rows: BulkRoundRow[];
19+
selectedRoundIds: Set<string>;
20+
onToggleRound: (roundId: string) => void;
21+
onMoveRound: (roundId: string, direction: -1 | 1) => void;
22+
}
23+
24+
export const BulkRoundTable = ({
25+
rows,
26+
selectedRoundIds,
27+
onToggleRound,
28+
onMoveRound,
29+
}: BulkRoundTableProps) => (
30+
<TableContainer>
31+
<Table size="small" aria-label="bulk generation rounds">
32+
<TableHead>
33+
<TableRow>
34+
<TableCell padding="checkbox">Generate</TableCell>
35+
<TableCell>Order</TableCell>
36+
<TableCell>Event/Round</TableCell>
37+
<TableCell>Scheduled</TableCell>
38+
<TableCell align="right">Size</TableCell>
39+
<TableCell align="right">Groups</TableCell>
40+
<TableCell align="right">Competitors</TableCell>
41+
<TableCell align="right">Staff</TableCell>
42+
</TableRow>
43+
</TableHead>
44+
<TableBody>
45+
{rows.map((row, index) => (
46+
<TableRow key={row.roundId} selected={selectedRoundIds.has(row.roundId)}>
47+
<TableCell padding="checkbox">
48+
<Checkbox
49+
checked={selectedRoundIds.has(row.roundId)}
50+
disabled={!row.selectable}
51+
onChange={() => onToggleRound(row.roundId)}
52+
slotProps={{ input: { 'aria-label': `Select ${row.roundId}` } }}
53+
/>
54+
</TableCell>
55+
<TableCell>
56+
<Stack direction="row" spacing={0.5}>
57+
<Tooltip title="Move up">
58+
<span>
59+
<IconButton
60+
size="small"
61+
aria-label={`Move ${row.roundId} up`}
62+
disabled={index === 0}
63+
onClick={() => onMoveRound(row.roundId, -1)}>
64+
<ArrowUpwardIcon fontSize="inherit" />
65+
</IconButton>
66+
</span>
67+
</Tooltip>
68+
<Tooltip title="Move down">
69+
<span>
70+
<IconButton
71+
size="small"
72+
aria-label={`Move ${row.roundId} down`}
73+
disabled={index === rows.length - 1}
74+
onClick={() => onMoveRound(row.roundId, 1)}>
75+
<ArrowDownwardIcon fontSize="inherit" />
76+
</IconButton>
77+
</span>
78+
</Tooltip>
79+
</Stack>
80+
</TableCell>
81+
<TableCell>{row.label}</TableCell>
82+
<TableCell>{row.scheduledTime}</TableCell>
83+
<TableCell align="right">{row.roundSize}</TableCell>
84+
<TableCell align="right">{row.existingGroupCount}</TableCell>
85+
<TableCell align="right">
86+
{row.competitorAssignmentCount} / {row.roundSize}
87+
</TableCell>
88+
<TableCell align="right">{row.staffAssignmentCount}</TableCell>
89+
</TableRow>
90+
))}
91+
</TableBody>
92+
</Table>
93+
</TableContainer>
94+
);
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import {
2+
buildBulkRoundRows,
3+
defaultSelectedRoundIds,
4+
mergeRoundOrder,
5+
scheduleOrderedRoundIds,
6+
} from './bulkRoundRows';
7+
import {
8+
buildActivity,
9+
buildEvent,
10+
buildPerson,
11+
buildRound,
12+
buildWcifWithEvents,
13+
} from '../../../store/reducers/_tests_/helpers';
14+
import type { AssignmentCode, Competition, EventId } from '@wca/helpers';
15+
import { describe, expect, it } from 'vitest';
16+
17+
const assignment = (activityId: number, assignmentCode: AssignmentCode) => ({
18+
activityId,
19+
assignmentCode,
20+
stationNumber: null,
21+
});
22+
23+
const registration = (registrantId: number, eventIds: EventId[]) => ({
24+
status: 'accepted' as const,
25+
eventIds,
26+
isCompeting: true,
27+
comments: undefined,
28+
wcaRegistrationId: registrantId,
29+
});
30+
31+
const buildCompetition = (): Competition => {
32+
const round333 = buildActivity({
33+
id: 1,
34+
activityCode: '333-r1',
35+
startTime: '2024-01-01T11:00:00Z',
36+
endTime: '2024-01-01T11:30:00Z',
37+
childActivities: [
38+
buildActivity({ id: 101, activityCode: '333-r1-g1' }),
39+
buildActivity({ id: 102, activityCode: '333-r1-g2' }),
40+
],
41+
});
42+
const round333Second = buildActivity({
43+
id: 2,
44+
activityCode: '333-r2',
45+
startTime: '2024-01-01T10:00:00Z',
46+
endTime: '2024-01-01T10:30:00Z',
47+
childActivities: [buildActivity({ id: 201, activityCode: '333-r2-g1' })],
48+
});
49+
const distributedRound = buildActivity({
50+
id: 3,
51+
activityCode: '333fm-r1',
52+
childActivities: [],
53+
});
54+
55+
return buildWcifWithEvents(
56+
[round333, round333Second, distributedRound],
57+
[
58+
buildEvent({
59+
id: '333',
60+
rounds: [
61+
buildRound({ id: '333-r1' }),
62+
buildRound({
63+
id: '333-r2',
64+
results: [{ personId: 1, ranking: 1, attempts: [], best: 0, average: 0 }],
65+
}),
66+
],
67+
}),
68+
buildEvent({
69+
id: '333fm' as EventId,
70+
rounds: [buildRound({ id: '333fm-r1' })],
71+
}),
72+
],
73+
[
74+
buildPerson({
75+
registrantId: 1,
76+
registration: registration(1, ['333' as EventId]),
77+
assignments: [assignment(101, 'competitor'), assignment(102, 'staff-judge')],
78+
}),
79+
buildPerson({
80+
registrantId: 2,
81+
registration: registration(2, ['333' as EventId]),
82+
assignments: [assignment(201, 'competitor')],
83+
}),
84+
]
85+
);
86+
};
87+
88+
describe('bulkRoundRows', () => {
89+
it('builds normal non-distributed round review rows', () => {
90+
const rows = buildBulkRoundRows(buildCompetition());
91+
92+
expect(rows.map((row) => row.roundId)).toEqual(['333-r1', '333-r2']);
93+
expect(rows[0]).toMatchObject({
94+
label: '333 Round 1',
95+
roundSize: 2,
96+
existingGroupCount: 2,
97+
competitorAssignmentCount: 1,
98+
staffAssignmentCount: 1,
99+
warnings: [],
100+
selectable: true,
101+
});
102+
expect(rows[1]).toMatchObject({
103+
roundSize: 1,
104+
selectable: true,
105+
});
106+
expect(defaultSelectedRoundIds(rows)).toEqual(new Set(['333-r1']));
107+
});
108+
109+
it('orders rows by schedule and merges persisted order with current rounds', () => {
110+
const rows = buildBulkRoundRows(buildCompetition());
111+
112+
expect(scheduleOrderedRoundIds(rows)).toEqual(['333-r2', '333-r1']);
113+
expect(mergeRoundOrder(['333-r2', '333-r1', '222-r1'], ['333-r1', 'stale-r1'])).toEqual([
114+
'333-r1',
115+
'333-r2',
116+
'222-r1',
117+
]);
118+
});
119+
});

0 commit comments

Comments
 (0)