sendou.ink/app/features/calendar/components/BracketProgressionSelector.tsx
Kalle c3444349b0
Free bracket progression (#1959)
* Initial

* Start implementing TournamentFormatSelector

* Progress

* Small progress

* Add source

* Progress

* Skeleton

* Progress

* Rename progression

* Can submit progression

* Fix potential issue with caching errors

* Settings

* isFinals/isUnderground

* Valid formats tests

* New bracket check in progress

* Perf optimization: simulate brackets only frontend

* Admin check in fix

* resolvesWinner logic

* SAME_PLACEMENT_TO_MULTIPLE_BRACKETS

* Structure work

* Edit bracket while tournament in progress initial

* Delayed check in to follow up bracket

* Progress validation

* NEGATIVE_PROGRESSION

* test first sources = null

* Different text when invitational

* More checks

* Validate changed are in preview

* Rename

* Translated errors

* Disbale submti if bracket progression is bad

* Adjust bracketIdx

* changedBracketProgressionFormat

* Progress

* Fix E2E tests

* Docs progress

* Fix state change

* Add docs
2024-11-10 12:07:43 +02:00

443 lines
11 KiB
TypeScript

import { nanoid } from "nanoid";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "~/components/Button";
import { DateInput } from "~/components/DateInput";
import { FormMessage } from "~/components/FormMessage";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { Toggle } from "~/components/Toggle";
import { PlusIcon } from "~/components/icons/Plus";
import { TOURNAMENT } from "~/features/tournament";
import * as Progression from "~/features/tournament-bracket/core/Progression";
const defaultBracket = (): Progression.InputBracket => ({
id: nanoid(),
name: "Main Bracket",
type: "double_elimination",
requiresCheckIn: false,
settings: {},
});
export function BracketProgressionSelector({
initialBrackets,
isInvitationalTournament,
setErrored,
}: {
initialBrackets?: Progression.InputBracket[];
isInvitationalTournament: boolean;
setErrored: (errored: boolean) => void;
}) {
const [brackets, setBrackets] = React.useState<Progression.InputBracket[]>(
initialBrackets ?? [defaultBracket()],
);
const handleAddBracket = () => {
setBrackets([
...brackets,
{
...defaultBracket(),
id: nanoid(),
name: "",
sources: [
{
bracketId: brackets[0].id,
placements: "",
},
],
},
]);
};
const handleDeleteBracket = (idx: number) => {
const newBrackets = brackets.filter((_, i) => i !== idx);
const newBracketIds = new Set(newBrackets.map((b) => b.id));
const updatedBrackets = newBrackets.map((b) => ({
...b,
sources:
newBrackets.length === 1
? undefined
: b.sources?.map((source) => ({
...source,
bracketId: newBracketIds.has(source.bracketId)
? source.bracketId
: newBrackets[0].id,
})),
}));
setBrackets(updatedBrackets);
};
const validated = Progression.validatedBrackets(brackets);
React.useEffect(() => {
if (Progression.isError(validated)) {
setErrored(true);
} else {
setErrored(false);
}
}, [validated, setErrored]);
return (
<div className="stack lg items-start">
{Progression.isBrackets(validated) ? (
<input
type="hidden"
name="bracketProgression"
value={JSON.stringify(validated)}
/>
) : null}
<div className="stack lg">
{brackets.map((bracket, i) => (
<TournamentFormatBracketSelector
key={bracket.id}
bracket={bracket}
brackets={brackets}
onChange={(newBracket) => {
const newBrackets = [...brackets];
newBrackets[i] = newBracket;
setBrackets(newBrackets);
}}
onDelete={
i !== 0 && !bracket.disabled
? () => handleDeleteBracket(i)
: undefined
}
count={i + 1}
isInvitationalTournament={isInvitationalTournament}
/>
))}
</div>
<Button
icon={<PlusIcon />}
size="tiny"
variant="outlined"
onClick={handleAddBracket}
disabled={brackets.length >= TOURNAMENT.MAX_BRACKETS_PER_TOURNAMENT}
>
Add bracket
</Button>
{Progression.isError(validated) ? (
<ErrorMessage error={validated} />
) : null}
</div>
);
}
function TournamentFormatBracketSelector({
bracket,
brackets,
onChange,
onDelete,
count,
isInvitationalTournament,
}: {
bracket: Progression.InputBracket;
brackets: Progression.InputBracket[];
onChange: (newBracket: Progression.InputBracket) => void;
onDelete?: () => void;
count: number;
isInvitationalTournament: boolean;
}) {
const id = React.useId();
const createId = (name: string) => {
return `${id}-${name}`;
};
const isFirstBracket = count === 1;
const updateBracket = (newProps: Partial<Progression.InputBracket>) => {
onChange({ ...bracket, ...newProps });
};
return (
<div className="stack horizontal md items-center">
<div>
<div className="format-selector__count">Bracket #{count}</div>
{onDelete ? (
<Button
size="tiny"
variant="minimal-destructive"
onClick={onDelete}
className="mx-auto"
testId="delete-bracket-button"
>
Delete
</Button>
) : null}
</div>
<div className="format-selector__divider" />
<div className="stack md items-start">
<div>
<Label htmlFor={createId("name")}>Bracket's name</Label>
<Input
id={createId("name")}
value={bracket.name}
onChange={(e) => updateBracket({ name: e.target.value })}
maxLength={TOURNAMENT.BRACKET_NAME_MAX_LENGTH}
readOnly={bracket.disabled}
/>
</div>
{!isFirstBracket ? (
<div>
<Label htmlFor={createId("startTime")}>Start time</Label>
<DateInput
id={createId("startTime")}
defaultValue={bracket.startTime ?? undefined}
onChange={(newDate) =>
updateBracket({ startTime: newDate ?? undefined })
}
readOnly={bracket.disabled}
/>
<FormMessage type="info">
If missing, bracket can be started when the previous brackets have
finished
</FormMessage>
</div>
) : null}
{!isFirstBracket ? (
<div>
<Label htmlFor={createId("checkIn")}>Check-in required</Label>
<Toggle
checked={bracket.requiresCheckIn}
setChecked={(checked) =>
updateBracket({ requiresCheckIn: checked })
}
disabled={bracket.disabled}
/>
<FormMessage type="info">
Check-in starts 1 hour before start time or right after the
previous bracket finishes if no start time is set
</FormMessage>
</div>
) : null}
<div>
<Label htmlFor={createId("format")}>Format</Label>
<select
value={bracket.type}
onChange={(e) =>
updateBracket({
type: e.target.value as Progression.InputBracket["type"],
})
}
className="w-max"
name="format"
id={createId("format")}
disabled={bracket.disabled}
>
<option value="single_elimination">Single-elimination</option>
<option value="double_elimination">Double-elimination</option>
<option value="round_robin">Round robin</option>
<option value="swiss">Swiss</option>
</select>
</div>
{bracket.type === "single_elimination" ? (
<div>
<Label htmlFor={createId("thirdPlaceMatch")}>
Third place match
</Label>
<Toggle
checked={Boolean(bracket.settings.thirdPlaceMatch)}
setChecked={(checked) =>
updateBracket({
settings: { ...bracket.settings, thirdPlaceMatch: checked },
})
}
disabled={bracket.disabled}
/>
</div>
) : null}
{bracket.type === "round_robin" ? (
<div>
<Label htmlFor="teamsPerGroup">Teams per group</Label>
<select
value={bracket.settings.teamsPerGroup ?? 4}
onChange={(e) =>
updateBracket({
settings: {
...bracket.settings,
teamsPerGroup: Number(e.target.value),
},
})
}
className="w-max"
name="teamsPerGroup"
id="teamsPerGroup"
disabled={bracket.disabled}
>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
</div>
) : null}
{bracket.type === "swiss" ? (
<div>
<Label htmlFor="swissGroupCount">Groups count</Label>
<select
value={bracket.settings.groupCount ?? 1}
onChange={(e) =>
updateBracket({
settings: {
...bracket.settings,
groupCount: Number(e.target.value),
},
})
}
className="w-max"
name="swissGroupCount"
id="swissGroupCount"
disabled={bracket.disabled}
>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
</div>
) : null}
{bracket.type === "swiss" ? (
<div>
<Label htmlFor="swissRoundCount">Round count</Label>
<select
value={bracket.settings.roundCount ?? 5}
onChange={(e) =>
updateBracket({
settings: {
...bracket.settings,
roundCount: Number(e.target.value),
},
})
}
className="w-max"
name="swissRoundCount"
id="swissRoundCount"
disabled={bracket.disabled}
>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
</select>
</div>
) : null}
<div>
<div className="stack horizontal sm">
<Label htmlFor={createId("source")}>Source</Label>{" "}
</div>
{isFirstBracket ? (
<FormMessage type="info">
{isInvitationalTournament ? (
<>Participants added by the organizer</>
) : (
<>Participants join from sign-up</>
)}
</FormMessage>
) : (
<SourcesSelector
brackets={brackets.filter(
(bracket2) => bracket.id !== bracket2.id && bracket2.name,
)}
source={bracket.sources?.[0] ?? null}
onChange={(source) => updateBracket({ sources: [source] })}
/>
)}
</div>
</div>
</div>
);
}
function SourcesSelector({
brackets,
source,
onChange,
}: {
brackets: Progression.InputBracket[];
source: Progression.EditableSource | null;
onChange: (sources: Progression.EditableSource) => void;
}) {
const id = React.useId();
const createId = (label: string) => {
return `${id}-${label}`;
};
return (
<div className="stack horizontal sm items-end">
<div>
<Label htmlFor={createId("bracket")}>Bracket</Label>
<select
id={createId("bracket")}
value={source?.bracketId ?? brackets[0].id}
onChange={(e) =>
onChange({ placements: "", ...source, bracketId: e.target.value })
}
>
{brackets.map((bracket) => (
<option key={bracket.id} value={bracket.id}>
{bracket.name}
</option>
))}
</select>
</div>
<div>
<Label htmlFor={createId("placements")}>Placements</Label>
<Input
id={createId("placements")}
placeholder="1,2,3"
value={source?.placements ?? ""}
testId="placements-input"
onChange={(e) =>
onChange({
bracketId: brackets[0].id,
...source,
placements: e.target.value,
})
}
/>
</div>
</div>
);
}
function ErrorMessage({ error }: { error: Progression.ValidationError }) {
const { t } = useTranslation(["tournament"]);
const bracketIdxsArr = (() => {
if (typeof (error as { bracketIdx: number }).bracketIdx === "number") {
return [(error as { bracketIdx: number }).bracketIdx];
}
if ((error as { bracketIdxs: number[] }).bracketIdxs) {
return (error as { bracketIdxs: number[] }).bracketIdxs;
}
return null;
})();
return (
<FormMessage type="error">
Problems with the bracket progression
{bracketIdxsArr ? (
<> (Bracket {bracketIdxsArr.map((idx) => `#${idx + 1}`).join(", ")})</>
) : null}
: {t(`tournament:progression.error.${error.type}`)}
</FormMessage>
);
}