Skip to content

Commit b44ffb9

Browse files
committed
Merge remote-tracking branch 'origin/main' into codex/pnw-recipe-constraints
# Conflicts: # src/components/RoundActionButtons.tsx # src/pages/Competition/Round/RoundContainer.tsx # src/store/reducers/runRecipe.ts
2 parents 7101033 + dd5d8f5 commit b44ffb9

31 files changed

Lines changed: 1982 additions & 230 deletions

.github/workflows/lint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
- name: Setup Node.js
2020
uses: actions/setup-node@v4
2121
with:
22-
node-version: '18'
22+
node-version-file: '.nvmrc'
2323
cache: 'yarn'
2424

2525
- name: Install dependencies

.github/workflows/type-check.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
- name: Setup Node.js
2020
uses: actions/setup-node@v4
2121
with:
22-
node-version: '18'
22+
node-version-file: '.nvmrc'
2323
cache: 'yarn'
2424

2525
- name: Install dependencies

src/components/RoundActionButtons.tsx

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { type ActivityWithParent } from '../lib/domain/types';
21
import { Recipes } from '../lib/recipes';
2+
import { hasDistributedAttempts, parseActivityCode } from '../lib/domain/activities';
3+
import { type ActivityWithParent } from '../lib/domain/types';
4+
import Box from '@mui/material/Box';
35
import Button from '@mui/material/Button';
4-
import MenuItem from '@mui/material/MenuItem';
5-
import Select from '@mui/material/Select';
66
import FormControl from '@mui/material/FormControl';
77
import InputLabel from '@mui/material/InputLabel';
8+
import MenuItem from '@mui/material/MenuItem';
9+
import Select from '@mui/material/Select';
810
import { type Person } from '@wca/helpers';
911

1012
interface RoundActionButtonsProps {
@@ -16,11 +18,14 @@ interface RoundActionButtonsProps {
1618
recipeId: string;
1719
onChangeRecipeId: (recipeId: string) => void;
1820
onRunRecipe: () => void;
21+
onAssignToRoundAttempt: () => void;
22+
onResetAttemptAssignments: () => void;
1923
onConfigureStationNumbers: (activityCode: string) => void;
2024
onConfigureGroups: () => void;
2125
onResetAll: () => void;
2226
onResetNonScrambling: () => void;
2327
onConfigureGroupCounts: () => void;
28+
isDistributedAttemptRoundLevel: boolean;
2429
}
2530

2631
export const RoundActionButtons = ({
@@ -32,12 +37,52 @@ export const RoundActionButtons = ({
3237
recipeId,
3338
onChangeRecipeId,
3439
onRunRecipe,
40+
onAssignToRoundAttempt,
41+
onResetAttemptAssignments,
3542
onConfigureStationNumbers,
3643
onConfigureGroups,
3744
onResetAll,
3845
onResetNonScrambling,
3946
onConfigureGroupCounts,
47+
isDistributedAttemptRoundLevel,
4048
}: RoundActionButtonsProps) => {
49+
const { attemptNumber } = parseActivityCode(activityCode);
50+
const isAttemptActivity = hasDistributedAttempts(activityCode) && attemptNumber !== undefined;
51+
52+
if (isDistributedAttemptRoundLevel) {
53+
return (
54+
<>
55+
<Button onClick={onConfigureAssignments}>Configure Round Attempt Assignments</Button>
56+
<Button onClick={onAssignToRoundAttempt}>Assign All</Button>
57+
<Box sx={{ display: 'flex', flex: 1 }} />
58+
<Button color="error" onClick={onResetAttemptAssignments}>
59+
Clear Round Attempt Assignments
60+
</Button>
61+
</>
62+
);
63+
}
64+
65+
if (groups.length === 0 && isAttemptActivity) {
66+
if (personsAssignedToCompete.length > 0) {
67+
return (
68+
<>
69+
<Button onClick={onAssignToRoundAttempt}>Assign to Round Attempt</Button>
70+
<Box sx={{ display: 'flex', flex: 1 }} />
71+
<Button color="error" onClick={onResetAttemptAssignments}>
72+
Reset Attempt Assignments
73+
</Button>
74+
</>
75+
);
76+
}
77+
78+
return (
79+
<>
80+
<Button onClick={onAssignToRoundAttempt}>Assign to Round Attempt</Button>
81+
<Button onClick={onConfigureGroupCounts}>Configure Group Counts</Button>
82+
</>
83+
);
84+
}
85+
4186
if (groups.length === 0) {
4287
return (
4388
<>
@@ -56,8 +101,7 @@ export const RoundActionButtons = ({
56101
labelId="recipe-select-label"
57102
label="Recipe"
58103
value={recipeId}
59-
onChange={(e) => onChangeRecipeId(String(e.target.value))}
60-
>
104+
onChange={(e) => onChangeRecipeId(String(e.target.value))}>
61105
{Recipes.map((r) => (
62106
<MenuItem key={r.id} value={r.id}>
63107
{r.name}

src/components/RoundSelector/RoundListItem.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,17 @@ interface RoundListItemProps {
2121
activityCode: string;
2222
round: Round;
2323
selected: boolean;
24+
nestingLevel?: number;
2425
in?: boolean;
2526
}
2627

27-
function RoundListItem({ activityCode, round, selected, ...props }: RoundListItemProps) {
28+
function RoundListItem({
29+
activityCode,
30+
round,
31+
selected,
32+
nestingLevel = 0,
33+
...props
34+
}: RoundListItemProps) {
2835
const ref = useRef<HTMLAnchorElement>(null);
2936
const wcif = useAppSelector((state) => state.wcif);
3037
const realGroups = wcif ? findGroupActivitiesByRound(wcif, activityCode) : [];
@@ -75,6 +82,7 @@ function RoundListItem({ activityCode, round, selected, ...props }: RoundListIte
7582
component={RouterLink}
7683
to={`/competitions/${wcif?.id}/events/${activityCode}`}
7784
selected={selected}
85+
sx={{ pl: 2 + nestingLevel * 4 }}
7886
ref={ref}>
7987
<ListItemAvatar>
8088
<span className={`cubing-icon event-${eventId}`} />

src/components/RoundSelector/_tests_/RoundSelector.test.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,21 @@ vi.mock('../../../lib/domain/activities', async () => {
2626
});
2727

2828
vi.mock('../RoundListItem', () => ({
29-
default: ({ activityCode, selected }: { activityCode: string; selected: boolean }) => (
30-
<div data-testid="round-item" data-selected={selected} data-code={activityCode} />
29+
default: ({
30+
activityCode,
31+
selected,
32+
nestingLevel,
33+
}: {
34+
activityCode: string;
35+
selected: boolean;
36+
nestingLevel?: number;
37+
}) => (
38+
<div
39+
data-testid="round-item"
40+
data-selected={selected}
41+
data-code={activityCode}
42+
data-nesting={nestingLevel ?? 0}
43+
/>
3144
),
3245
}));
3346

@@ -85,4 +98,31 @@ describe('RoundSelector', () => {
8598

8699
expect(toggle).toBeChecked();
87100
});
101+
102+
it('renders round-level entry plus attempts for distributed-attempt events', () => {
103+
const wcif = {
104+
id: 'TestComp',
105+
events: [
106+
{
107+
id: '333fm',
108+
rounds: [{ id: '333fm-r1', format: '3', results: [], timeLimit: null, cutoff: null }],
109+
},
110+
],
111+
};
112+
113+
const state = { wcif } as unknown as AppState;
114+
useAppSelector.mockImplementation((selector: (state: AppState) => unknown) => selector(state));
115+
116+
const { getAllByTestId } = renderWithProviders(
117+
<RoundSelector competitionId="TestComp" onSelected={() => undefined} />
118+
);
119+
120+
const activityCodes = getAllByTestId('round-item').map((item) => item.getAttribute('data-code'));
121+
const nestingLevels = getAllByTestId('round-item').map((item) =>
122+
item.getAttribute('data-nesting')
123+
);
124+
125+
expect(activityCodes).toEqual(['333fm-r1', '333fm-r1-a1', '333fm-r1-a2', '333fm-r1-a3']);
126+
expect(nestingLevels).toEqual(['0', '1', '1', '1']);
127+
});
88128
});

src/components/RoundSelector/index.tsx

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => {
2727
const [showAllRounds, setShowAllRounds] = useState(false);
2828
const [selectedId, setSelectedId] = useState(wcif?.events[0]?.rounds[0]?.id || null);
2929

30+
const attemptCountForRound = (round: Round) => (round.format === 'm' ? 3 : +round.format);
31+
3032
const shouldShowRound = (round: Round) => {
3133
if (!wcif) return false;
3234

@@ -54,13 +56,18 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => {
5456
.filter(shouldShowRound)
5557
: [];
5658

57-
const roundIds = rounds.flatMap((r) =>
58-
hasDistributedAttempts(r.id)
59-
? new Array(r.format === 'm' ? 3 : +r.format)
60-
.fill(0)
61-
.map((_, index) => `${r.id}-a${index + 1}`)
62-
: r.id
63-
);
59+
const roundIds = rounds.flatMap((r) => {
60+
if (!hasDistributedAttempts(r.id)) {
61+
return r.id;
62+
}
63+
64+
return [
65+
r.id,
66+
...new Array(attemptCountForRound(r))
67+
.fill(0)
68+
.map((_, index) => `${r.id}-a${index + 1}`),
69+
];
70+
});
6471

6572
const handleKeyDown = (e: KeyboardEvent) => {
6673
if (commandPromptOpen) {
@@ -124,31 +131,45 @@ const RoundSelector = ({ onSelected }: RoundSelectorProps) => {
124131
<TransitionGroup>
125132
{!hasDistributedAttempts(event.id)
126133
? roundsForEvent.map((round) => (
127-
<RoundListItem
128-
key={round.id}
129-
activityCode={round.id}
130-
round={round}
131-
selected={round.id === selectedId}
132-
in
133-
/>
134+
<RoundListItem
135+
key={round.id}
136+
activityCode={round.id}
137+
round={round}
138+
nestingLevel={0}
139+
selected={round.id === selectedId}
140+
in
141+
/>
134142
))
135143
: roundsForEvent.flatMap((round) => {
136-
const attempts = new Array(round.format === 'm' ? 3 : +round.format) // TODO: create helper function to calculate attempts
137-
.fill(0);
138-
139-
return attempts.map((_, index) => {
144+
const attempts = new Array(attemptCountForRound(round)).fill(0);
145+
146+
const roundListItem = (
147+
<RoundListItem
148+
key={round.id}
149+
activityCode={round.id}
150+
round={round}
151+
nestingLevel={0}
152+
selected={round.id === selectedId}
153+
in
154+
/>
155+
);
156+
157+
const attemptListItems = attempts.map((_, index) => {
140158
const attemptActivityCode = `${round.id}-a${index + 1}`;
141159

142160
return (
143161
<RoundListItem
144162
key={attemptActivityCode}
145163
activityCode={attemptActivityCode}
146164
round={round}
165+
nestingLevel={1}
147166
selected={attemptActivityCode === selectedId}
148167
in
149168
/>
150169
);
151170
});
171+
172+
return [roundListItem, ...attemptListItems];
152173
})}
153174
</TransitionGroup>
154175
</React.Fragment>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { RoundActionButtons } from '../RoundActionButtons';
2+
import { renderWithProviders } from '../../test-utils';
3+
import { describe, expect, it, vi } from 'vitest';
4+
5+
const baseProps = {
6+
groups: [],
7+
personsAssignedToCompete: [],
8+
personsShouldBeInRound: [],
9+
activityCode: '333fm-r1',
10+
onConfigureAssignments: vi.fn(),
11+
recipeId: 'pnw',
12+
onChangeRecipeId: vi.fn(),
13+
onRunRecipe: vi.fn(),
14+
onAssignToRoundAttempt: vi.fn(),
15+
onResetAttemptAssignments: vi.fn(),
16+
onConfigureStationNumbers: vi.fn(),
17+
onConfigureGroups: vi.fn(),
18+
onResetAll: vi.fn(),
19+
onResetNonScrambling: vi.fn(),
20+
onConfigureGroupCounts: vi.fn(),
21+
};
22+
23+
describe('RoundActionButtons', () => {
24+
it('shows distributed round-level attempt actions', () => {
25+
const { getByText, queryByText } = renderWithProviders(
26+
<RoundActionButtons {...baseProps} isDistributedAttemptRoundLevel />
27+
);
28+
29+
expect(getByText('Configure Round Attempt Assignments')).toBeInTheDocument();
30+
expect(getByText('Assign All')).toBeInTheDocument();
31+
expect(getByText('Clear Round Attempt Assignments')).toBeInTheDocument();
32+
expect(queryByText('Configure Group Counts')).not.toBeInTheDocument();
33+
});
34+
});

0 commit comments

Comments
 (0)