Implement parsing report scores action

This commit is contained in:
Kalle 2022-08-05 00:35:10 +03:00
parent 45d1ef8f45
commit 762cb5e215
4 changed files with 135 additions and 26 deletions

View File

@ -22,6 +22,9 @@ export const CALENDAR_EVENT_TAGS = Object.keys(
export const CALENDAR_EVENT_RESULT = {
MAX_PARTICIPANTS_COUNT: 1000,
MAX_PLAYERS_LENGTH: 8,
MAX_TEAM_NAME_LENGTH: 100,
MAX_TEAM_PLACEMENT: 256,
MAX_PLAYER_NAME_LENGTH: 100,
} as const;
export const PLUS_TIERS = [1, 2, 3];

View File

@ -1,5 +1,5 @@
import type { ActionFunction, LoaderArgs } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { type ActionFunction, type LoaderArgs, json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { z } from "zod";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
@ -7,8 +7,14 @@ 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 { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
import {
actualNumber,
id,
processMany,
safeJSONParse,
toArray,
} from "~/utils/zod";
import * as React from "react";
import type { User } from "~/db/types";
import { Button } from "~/components/Button";
@ -16,10 +22,63 @@ import clsx from "clsx";
import { UserCombobox } from "~/components/Combobox";
import { FormMessage } from "~/components/FormMessage";
const reportWinnersActionSchema = z.object({
participantsCount: z.preprocess(
actualNumber,
z
.number()
.int()
.positive()
.max(CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT)
),
team: z
.array(
z.preprocess(
processMany(safeJSONParse, toArray),
z.object({
teamName: z
.string()
.min(1)
.max(CALENDAR_EVENT_RESULT.MAX_TEAM_NAME_LENGTH),
placement: z.preprocess(
actualNumber,
z
.number()
.int()
.positive()
.max(CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT)
),
players: z
.array(
z.union([
z
.string()
.min(1)
.max(CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH),
z.object({ id }),
])
)
.nonempty()
.max(CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH),
})
)
)
// don't allow repeating same team name
.refine(
(val) => val.length === new Set(val.map((team) => team.teamName)).size
),
});
export const action: ActionFunction = async ({ request }) => {
const formData = await request.formData();
// eslint-disable-next-line no-console
console.log({ formData });
// xxx: return nice errors to frontend to render
// try {
// const data = await parseRequestFormData({
// request,
// schema: reportWinnersActionSchema,
// });
// } catch (e) {
// return null;
// }
return null;
};
@ -44,26 +103,18 @@ export const loader = async ({ request, params }: LoaderArgs) => {
401
);
return null;
return json({
name: event.name,
});
};
interface TeamResults {
teamName: string;
placement: string;
players: Array<
| {
id: User["id"];
}
| string
>;
}
export default function ReportWinnersPage() {
const data = useLoaderData<typeof loader>();
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>
<h1 className="text-lg">Reporting results of {data.name}</h1>
<ParticipantsCountInput />
<FormMessage type="info">
You choose how many results to report. It can be just the winning
@ -123,6 +174,7 @@ function TeamInputs() {
: undefined
}
hidden={hidden}
initialPlacement={String(i + 1)}
/>
{!hidden && <hr className="w-full" />}
</React.Fragment>
@ -140,19 +192,32 @@ function TeamInputs() {
const NEW_PLAYER = { id: 0 } as const;
interface TeamResults {
teamName: string;
placement: string;
players: Array<
| {
id: User["id"];
}
| string
>;
}
function Team({
onRemoveTeam,
hidden,
initialPlacement,
}: {
onRemoveTeam?: () => void;
hidden: boolean;
initialPlacement: string;
}) {
const teamNameId = React.useId();
const placementId = React.useId();
const [results, setResults] = React.useState<TeamResults>({
teamName: "",
// xxx: could use i + 1
placement: "1",
placement: initialPlacement,
players: [NEW_PLAYER, NEW_PLAYER, NEW_PLAYER, NEW_PLAYER],
});
@ -167,15 +232,28 @@ function Team({
return (
<div className={clsx("stack md items-start", { hidden })}>
{!hidden && (
<input type="hidden" name="team" value={JSON.stringify(results)} />
<input
type="hidden"
name="team"
value={JSON.stringify({
...results,
players: results.players.filter(
(player) =>
(typeof player === "string" && player !== "") ||
(typeof player === "object" && player.id !== 0)
),
})}
/>
)}
<div className="stack horizontal md">
<div className="stack horizontal md flex-wrap">
<div>
<Label htmlFor={teamNameId}>Team name</Label>
<input
id={teamNameId}
value={results.teamName}
onChange={handleTeamNameChange}
min={1}
max={CALENDAR_EVENT_RESULT.MAX_TEAM_NAME_LENGTH}
/>
</div>
<div>
@ -186,6 +264,7 @@ function Team({
type="number"
onChange={handlePlacementChange}
min={1}
max={CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT}
className="w-24"
/>
</div>
@ -260,6 +339,8 @@ function Players({
<input
id={formId}
onChange={(e) => handleInputChange(i, e.target.value)}
min={1}
max={CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH}
/>
) : (
<UserCombobox

View File

@ -23,7 +23,7 @@ export async function parseRequestFormData<T extends z.ZodTypeAny>({
try {
// False alarm
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return schema.parse(Object.fromEntries(await request.formData()));
return schema.parse(formDataToObject(await request.formData()));
} catch (e) {
if (e instanceof z.ZodError) {
console.error(e);
@ -38,6 +38,25 @@ export async function parseRequestFormData<T extends z.ZodTypeAny>({
}
}
function formDataToObject(formData: FormData) {
const result: Record<string, string | string[]> = {};
for (const [key, value] of formData.entries()) {
const newValue = String(value);
const existingValue = result[key];
if (Array.isArray(existingValue)) {
existingValue.push(newValue);
} else if (typeof existingValue === "string") {
result[key] = [existingValue, newValue];
} else {
result[key] = newValue;
}
}
return result;
}
/** Asserts condition is truthy. Throws a new `Response` with given status code if falsy. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- same format as TS docs: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions
export function validate(condition: any, status = 400): asserts condition {

View File

@ -55,3 +55,9 @@ export function removeDuplicates(value: unknown) {
return Array.from(new Set(value));
}
export function toArray<T>(value: T | Array<T>) {
if (Array.isArray(value)) return value;
return [value];
}