Report results UI

This commit is contained in:
Kalle
2022-08-02 21:35:30 +03:00
parent e39e72c840
commit 45d1ef8f45
6 changed files with 328 additions and 2 deletions

View File

@@ -22,6 +22,8 @@ Calendar
## Other
- [ ] User selector allow passing users from top level
- [ ] Constants use CALENDAR_EVENT object
- [ ] New unfriendly crash if omits dates
- [x] badges inside tag?
- [x] Tags selector can remove

View File

@@ -15,6 +15,7 @@ interface ComboboxProps<T> {
inputName: string;
placeholder: string;
className?: string;
id?: string;
isLoading?: boolean;
onChange?: (selectedOption?: ComboboxOption<T>) => void;
}
@@ -25,6 +26,7 @@ export function Combobox<T extends Record<string, string | null | number>>({
placeholder,
onChange,
className,
id,
isLoading = false,
}: ComboboxProps<T>) {
const [selectedOption, setSelectedOption] =
@@ -63,6 +65,7 @@ export function Combobox<T extends Record<string, string | null | number>>({
(option as Unpacked<typeof options>)?.label ?? ""
}
data-cy={`${inputName}-combobox-input`}
id={id}
/>
<HeadlessCombobox.Options
className={clsx("combobox-options", {
@@ -100,9 +103,10 @@ export function UserCombobox({
onChange,
userIdsToOmit,
className,
id,
}: Pick<
ComboboxProps<Pick<UserWithPlusTier, "discordId" | "plusTier">>,
"inputName" | "onChange" | "className"
"inputName" | "onChange" | "className" | "id"
> & { userIdsToOmit?: Set<number> }) {
const fetcher = useFetcher<UsersLoaderData>();
@@ -135,6 +139,7 @@ export function UserCombobox({
isLoading={isLoading}
onChange={onChange}
className={className}
id={id}
/>
);
}

View File

@@ -19,6 +19,11 @@ export const CALENDAR_EVENT_TAGS = Object.keys(
allTags
) as Array<CalendarEventTag>;
export const CALENDAR_EVENT_RESULT = {
MAX_PARTICIPANTS_COUNT: 1000,
MAX_PLAYERS_LENGTH: 8,
} as const;
export const PLUS_TIERS = [1, 2, 3];
export const PLUS_UPVOTE = 1;

View File

@@ -1,5 +1,299 @@
import type { ActionFunction, LoaderArgs } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { z } from "zod";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { CALENDAR_EVENT_RESULT } from "~/constants";
import { db } from "~/db";
import { requireUser } from "~/modules/auth";
import { canReportCalendarEventWinners } from "~/permissions";
import { notFoundIfFalsy, validate } from "~/utils/remix";
import { actualNumber, id } from "~/utils/zod";
import * as React from "react";
import type { User } from "~/db/types";
import { Button } from "~/components/Button";
import clsx from "clsx";
import { UserCombobox } from "~/components/Combobox";
import { FormMessage } from "~/components/FormMessage";
export const action: ActionFunction = async ({ request }) => {
const formData = await request.formData();
// eslint-disable-next-line no-console
console.log({ formData });
return null;
};
export const handle = {
i18n: "calendar",
};
export const loader = async ({ request, params }: LoaderArgs) => {
const parsedParams = z
.object({ id: z.preprocess(actualNumber, id) })
.parse(params);
const user = await requireUser(request);
const event = notFoundIfFalsy(db.calendarEvents.findById(parsedParams.id));
validate(
canReportCalendarEventWinners({
user,
event,
startTimes: event.startTimes,
}),
401
);
return null;
};
interface TeamResults {
teamName: string;
placement: string;
players: Array<
| {
id: User["id"];
}
| string
>;
}
export default function ReportWinnersPage() {
return <Main>erport</Main>;
return (
<Main halfWidth>
<Form method="post" className="stack smedium items-start">
{/* xxx: use real name */}
<h1 className="text-lg">Reporting results of TEST Tournament</h1>
<ParticipantsCountInput />
<FormMessage type="info">
You choose how many results to report. It can be just the winning
team, top 3 or whatever you decide.
</FormMessage>
<TeamInputs />
<Button type="submit" className="mt-4">
Submit
</Button>
</Form>
</Main>
);
}
function ParticipantsCountInput() {
// const { eventToEdit } = useLoaderData<typeof loader>();
return (
<div>
<Label htmlFor="name" required>
Participants count
</Label>
<input
name="participantsCount"
type="number"
required
min={1}
max={CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT}
// defaultValue={eventToEdit?.name}
data-cy="participants-count-input"
className="w-24"
/>
</div>
);
}
function TeamInputs() {
const [amountOfTeams, setAmountOfTeams] = React.useState(1);
const handleTeamDelete = () => {
setAmountOfTeams(amountOfTeams - 1);
};
return (
<>
<hr className="w-full" />
{new Array(amountOfTeams + 1).fill(null).map((_, i) => {
// last team is hidden so we can save its state even if user removes a filled team
const hidden = i === amountOfTeams;
return (
<React.Fragment key={i}>
<Team
onRemoveTeam={
i === amountOfTeams - 1 && amountOfTeams > 1
? handleTeamDelete
: undefined
}
hidden={hidden}
/>
{!hidden && <hr className="w-full" />}
</React.Fragment>
);
})}
<Button
onClick={() => setAmountOfTeams((amountOfTeams) => amountOfTeams + 1)}
tiny
>
Add team
</Button>
</>
);
}
const NEW_PLAYER = { id: 0 } as const;
function Team({
onRemoveTeam,
hidden,
}: {
onRemoveTeam?: () => void;
hidden: boolean;
}) {
const teamNameId = React.useId();
const placementId = React.useId();
const [results, setResults] = React.useState<TeamResults>({
teamName: "",
// xxx: could use i + 1
placement: "1",
players: [NEW_PLAYER, NEW_PLAYER, NEW_PLAYER, NEW_PLAYER],
});
const handleTeamNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setResults({ ...results, teamName: e.target.value });
};
const handlePlacementChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setResults({ ...results, placement: e.target.value });
};
return (
<div className={clsx("stack md items-start", { hidden })}>
{!hidden && (
<input type="hidden" name="team" value={JSON.stringify(results)} />
)}
<div className="stack horizontal md">
<div>
<Label htmlFor={teamNameId}>Team name</Label>
<input
id={teamNameId}
value={results.teamName}
onChange={handleTeamNameChange}
/>
</div>
<div>
<Label htmlFor={placementId}>Placement</Label>
<input
id={placementId}
value={results.placement}
type="number"
onChange={handlePlacementChange}
min={1}
className="w-24"
/>
</div>
</div>
<Players
players={results.players}
setPlayers={(players) => setResults({ ...results, players })}
/>
{onRemoveTeam && (
<Button
onClick={onRemoveTeam}
tiny
variant="minimal-destructive"
className="mt-4"
>
Remove team
</Button>
)}
</div>
);
}
function Players({
players,
setPlayers,
}: {
players: TeamResults["players"];
setPlayers: (newPlayers: TeamResults["players"]) => void;
}) {
const handleAddPlayer = () => {
setPlayers([...players, NEW_PLAYER]);
};
const handleRemovePlayer = () => {
setPlayers(players.slice(0, -1));
};
const handlePlayerInputTypeChange = (index: number) => {
const newPlayers = [...players];
newPlayers[index] = typeof newPlayers[index] === "string" ? NEW_PLAYER : "";
setPlayers(newPlayers);
};
const handleInputChange = (index: number, newValue: string | number) => {
const newPlayers = [...players];
newPlayers[index] =
typeof newValue === "string" ? newValue : { id: newValue };
setPlayers(newPlayers);
};
return (
<div className="stack md">
{players.map((player, i) => {
const formId = `player-${i + 1}`;
const asPlainInput = typeof player === "string";
return (
<div key={i}>
<div className="stack horizontal md items-center mb-1">
<label htmlFor={formId} className="mb-0">
Player {i + 1}
</label>
<Button
tiny
variant="minimal"
onClick={() => handlePlayerInputTypeChange(i)}
>
{asPlainInput ? "Add as user (recommended)" : "Add as text"}
</Button>
</div>
{asPlainInput ? (
<input
id={formId}
onChange={(e) => handleInputChange(i, e.target.value)}
/>
) : (
<UserCombobox
id={formId}
inputName="old-user"
onChange={(selected) =>
handleInputChange(
i,
selected?.value ? Number(selected?.value) : NEW_PLAYER.id
)
}
/>
)}
</div>
);
})}
<div className="stack horizontal sm mt-2">
<Button
tiny
onClick={handleAddPlayer}
disabled={players.length === CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH}
variant="outlined"
>
Add player
</Button>{" "}
<Button
tiny
variant="destructive"
onClick={handleRemovePlayer}
disabled={players.length === 1}
>
Remove player
</Button>
</div>
</div>
);
}

View File

@@ -482,6 +482,10 @@ dialog::backdrop {
gap: var(--s-4);
}
.stack.smedium {
gap: var(--s-6);
}
.stack.lg {
gap: var(--s-8);
}

View File

@@ -6,6 +6,10 @@
font-size: var(--fonts-xs);
}
.text-lg {
font-size: var(--fonts-lg);
}
.text-center {
text-align: center;
}
@@ -30,6 +34,10 @@
width: 100%;
}
.w-24 {
width: var(--s-24);
}
.mt-2 {
margin-block-start: var(--s-2);
}
@@ -38,6 +46,14 @@
margin-block-start: var(--s-4);
}
.mb-0 {
margin-block-end: 0;
}
.mb-1 {
margin-block-end: var(--s-1);
}
.ml-auto {
margin-inline-start: auto;
}