From 45d1ef8f453e25b5f589c7bf923dc0d2c7069a8d Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 2 Aug 2022 21:35:30 +0300 Subject: [PATCH] Report results UI --- TODO.md | 2 + app/components/Combobox.tsx | 7 +- app/constants.ts | 5 + app/routes/calendar/$id/report-winners.tsx | 296 ++++++++++++++++++++- app/styles/common.css | 4 + app/styles/utils.css | 16 ++ 6 files changed, 328 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 6215fcbf6..a40141c40 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/app/components/Combobox.tsx b/app/components/Combobox.tsx index 44d43c908..3e989298f 100644 --- a/app/components/Combobox.tsx +++ b/app/components/Combobox.tsx @@ -15,6 +15,7 @@ interface ComboboxProps { inputName: string; placeholder: string; className?: string; + id?: string; isLoading?: boolean; onChange?: (selectedOption?: ComboboxOption) => void; } @@ -25,6 +26,7 @@ export function Combobox>({ placeholder, onChange, className, + id, isLoading = false, }: ComboboxProps) { const [selectedOption, setSelectedOption] = @@ -63,6 +65,7 @@ export function Combobox>({ (option as Unpacked)?.label ?? "" } data-cy={`${inputName}-combobox-input`} + id={id} /> >, - "inputName" | "onChange" | "className" + "inputName" | "onChange" | "className" | "id" > & { userIdsToOmit?: Set }) { const fetcher = useFetcher(); @@ -135,6 +139,7 @@ export function UserCombobox({ isLoading={isLoading} onChange={onChange} className={className} + id={id} /> ); } diff --git a/app/constants.ts b/app/constants.ts index 79c500097..d7a1f37ca 100644 --- a/app/constants.ts +++ b/app/constants.ts @@ -19,6 +19,11 @@ export const CALENDAR_EVENT_TAGS = Object.keys( allTags ) as Array; +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; diff --git a/app/routes/calendar/$id/report-winners.tsx b/app/routes/calendar/$id/report-winners.tsx index d9cd73d8b..d6ecc0661 100644 --- a/app/routes/calendar/$id/report-winners.tsx +++ b/app/routes/calendar/$id/report-winners.tsx @@ -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
erport
; + return ( +
+
+ {/* xxx: use real name */} +

Reporting results of TEST Tournament

+ + + You choose how many results to report. It can be just the winning + team, top 3 or whatever you decide. + + + + +
+ ); +} + +function ParticipantsCountInput() { + // const { eventToEdit } = useLoaderData(); + + return ( +
+ + +
+ ); +} + +function TeamInputs() { + const [amountOfTeams, setAmountOfTeams] = React.useState(1); + + const handleTeamDelete = () => { + setAmountOfTeams(amountOfTeams - 1); + }; + + return ( + <> +
+ {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 ( + + 1 + ? handleTeamDelete + : undefined + } + hidden={hidden} + /> + {!hidden &&
} +
+ ); + })} + + + ); +} + +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({ + teamName: "", + // xxx: could use i + 1 + placement: "1", + players: [NEW_PLAYER, NEW_PLAYER, NEW_PLAYER, NEW_PLAYER], + }); + + const handleTeamNameChange = (e: React.ChangeEvent) => { + setResults({ ...results, teamName: e.target.value }); + }; + + const handlePlacementChange = (e: React.ChangeEvent) => { + setResults({ ...results, placement: e.target.value }); + }; + + return ( + + ); +} + +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 ( +
+ {players.map((player, i) => { + const formId = `player-${i + 1}`; + const asPlainInput = typeof player === "string"; + + return ( +
+
+ + +
+ {asPlainInput ? ( + handleInputChange(i, e.target.value)} + /> + ) : ( + + handleInputChange( + i, + selected?.value ? Number(selected?.value) : NEW_PLAYER.id + ) + } + /> + )} +
+ ); + })} +
+ {" "} + +
+
+ ); } diff --git a/app/styles/common.css b/app/styles/common.css index 63435bce2..b4394b5ff 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -482,6 +482,10 @@ dialog::backdrop { gap: var(--s-4); } +.stack.smedium { + gap: var(--s-6); +} + .stack.lg { gap: var(--s-8); } diff --git a/app/styles/utils.css b/app/styles/utils.css index 727338e8b..30447c2c4 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -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; }