From e825d9f7d2230375ab77d333af0daee4d344e2f7 Mon Sep 17 00:00:00 2001 From: Placni Date: Sun, 1 Dec 2024 22:33:38 -0600 Subject: [PATCH 01/10] refactor tournament submission with client package usage --- app/actions/tournaments.ts | 92 +++++++++ app/submit/page.tsx | 8 +- .../FiltersCollapsible/FiltersCollapsible.tsx | 2 +- components/Form/Form.tsx | 11 +- .../Form/InputError/InputError.module.css | 9 + components/Form/InputError/InputError.tsx | 15 ++ .../InfoIcon/InfoIcon.module.css | 0 .../{Form => Icons}/InfoIcon/InfoIcon.tsx | 0 components/Toast/Toast.tsx | 17 +- .../Guidelines/Guidelines.module.css | 0 .../Submission}/Guidelines/Guidelines.tsx | 1 + .../SubmissionForm/SubmissionForm.module.css} | 16 +- .../SubmissionForm/SubmissionForm.tsx} | 186 +++++++++--------- lib/api.ts | 22 +++ lib/regex.ts | 5 + lib/schemas.ts | 51 +++++ lib/types.ts | 14 +- 17 files changed, 315 insertions(+), 134 deletions(-) create mode 100644 app/actions/tournaments.ts create mode 100644 components/Form/InputError/InputError.module.css create mode 100644 components/Form/InputError/InputError.tsx rename components/{Form => Icons}/InfoIcon/InfoIcon.module.css (100%) rename components/{Form => Icons}/InfoIcon/InfoIcon.tsx (100%) rename components/{SubmitMatches => Tournaments/Submission}/Guidelines/Guidelines.module.css (100%) rename components/{SubmitMatches => Tournaments/Submission}/Guidelines/Guidelines.tsx (99%) rename components/{SubmitMatches/MatchForm/MatchForm.module.css => Tournaments/Submission/SubmissionForm/SubmissionForm.module.css} (84%) rename components/{SubmitMatches/MatchForm/MatchForm.tsx => Tournaments/Submission/SubmissionForm/SubmissionForm.tsx} (63%) create mode 100644 lib/api.ts create mode 100644 lib/regex.ts create mode 100644 lib/schemas.ts diff --git a/app/actions/tournaments.ts b/app/actions/tournaments.ts new file mode 100644 index 0000000..0d5a08e --- /dev/null +++ b/app/actions/tournaments.ts @@ -0,0 +1,92 @@ +'use server'; + +import { isHttpValidationProblemDetails } from "@/lib/api"; +import { apiWrapperConfiguration } from "@/lib/auth"; +import { BeatmapLinkPattern, MatchLinkPattern } from "@/lib/regex"; +import { TournamentSubmissionFormSchema } from "@/lib/schemas"; +import { FormState } from "@/lib/types"; +import { TournamentSubmissionDTO, TournamentsWrapper } from "@osu-tournament-rating/otr-api-client"; +import { ZodError } from "zod"; + +export async function handleTournamentFormState( + previousState: FormState, + formData: FormData +): Promise> { + const result: FormState = { + success: false, + message: "", + errors: {} + }; + + try { + // Format mp and beatmap ids + const ids = (formData.get('ids') as string) + // Split at new lines + .split(/\r?\n/g) + // Filter out empty strings + .filter(s => s.trim() !== '') + .map(s => { + // Trim whitespace + s = s.trim(); + + // If the string is parseable to an int as is, do so + if (!isNaN(parseFloat(s))) { + return parseFloat(s); + } + + // Try to extract the id using regex + const match = MatchLinkPattern.exec(s); + return match ? parseFloat(match[1]) : s; + }); + + const beatmapIds = (formData.get('beatmapIds') as string) + .split(/\r?\n/g) + .filter(s => s.trim() !== '') + .map(s => { + s = s.trim(); + + if (!isNaN(parseFloat(s))) { + return parseFloat(s); + } + + // Try to extract the beatmap id using regex + const match = BeatmapLinkPattern.exec(s); + return match ? parseFloat(match[1]) : s; + }); + + // Parse form data + const parsedForm = TournamentSubmissionFormSchema.parse({ + name: formData.get('name'), + abbreviation: formData.get('abbreviation'), + forumUrl: formData.get('forumPostURL'), + rankRangeLowerBound: parseInt(formData.get('rankRangeLowerBound') as string), + lobbySize: parseInt(formData.get('lobbySize') as string), + ruleset: parseInt(formData.get('ruleset') as string), + ids, + beatmapIds + }); + + const wrapper = new TournamentsWrapper(apiWrapperConfiguration); + await wrapper.create({ body: parsedForm }); + + result.message = "Successfully processed your submission. Thank you for contributing!"; + result.success = true; + } catch (err) { + result.message = "Submission was not successful."; + result.success = false; + + // Handle parsing errors + if (err instanceof ZodError) { + Object.assign(result.errors, err.flatten().fieldErrors); + result.message += " There was a problem processing your submission."; + } + + // Handle API errors + if (isHttpValidationProblemDetails(err)) { + Object.assign(result.errors, err.errors); + result.message += " The server rejected your submission."; + } + } + + return result; +} \ No newline at end of file diff --git a/app/submit/page.tsx b/app/submit/page.tsx index f55c690..d31d743 100644 --- a/app/submit/page.tsx +++ b/app/submit/page.tsx @@ -1,12 +1,12 @@ -import Guidelines from '@/components/SubmitMatches/Guidelines/Guidelines'; -import MatchForm from '@/components/SubmitMatches/MatchForm/MatchForm'; +import Guidelines from '@/components/Tournaments/Submission/Guidelines/Guidelines'; +import SubmissionForm from '@/components/Tournaments/Submission/SubmissionForm/SubmissionForm'; import { getSessionData } from '../actions/session'; import styles from './page.module.css'; import type { Metadata } from 'next'; export const metadata: Metadata = { - title: 'Submit', + title: 'Tournament Submission', }; export default async function page() { @@ -15,7 +15,7 @@ export default async function page() { return (
- +
); } diff --git a/components/Collapsible/FiltersCollapsible/FiltersCollapsible.tsx b/components/Collapsible/FiltersCollapsible/FiltersCollapsible.tsx index 26b2c34..bba5647 100644 --- a/components/Collapsible/FiltersCollapsible/FiltersCollapsible.tsx +++ b/components/Collapsible/FiltersCollapsible/FiltersCollapsible.tsx @@ -3,7 +3,7 @@ import { applyLeaderboardFilters, resetLeaderboardFilters, } from '@/app/actions'; -import InfoIcon from '@/components/Form/InfoIcon/InfoIcon'; +import InfoIcon from '@/components/Icons/InfoIcon/InfoIcon'; import RangeSlider from '@/components/Range/RangeSlider'; import TierSelector from '@/components/TierSelector/TierSelector'; import { AnimatePresence, motion } from 'framer-motion'; diff --git a/components/Form/Form.tsx b/components/Form/Form.tsx index 2ac9ab2..5f3297d 100644 --- a/components/Form/Form.tsx +++ b/components/Form/Form.tsx @@ -1,14 +1,13 @@ +import { DetailedHTMLProps, FormHTMLAttributes } from 'react'; import styles from './Form.module.css'; export default function Form({ - action, children, -}: { - action: any; - children: React.ReactNode; -}) { + ...rest +}: Omit, HTMLFormElement>, 'className'> & { children: React.ReactNode } +) { return ( -
+ {children}
); diff --git a/components/Form/InputError/InputError.module.css b/components/Form/InputError/InputError.module.css new file mode 100644 index 0000000..842ff04 --- /dev/null +++ b/components/Form/InputError/InputError.module.css @@ -0,0 +1,9 @@ +.inputError { + color: hsla(var(--red-600)); + font-weight: 500; + font-size: 0.8rem; +} + +.inputError:empty { + display: none; +} \ No newline at end of file diff --git a/components/Form/InputError/InputError.tsx b/components/Form/InputError/InputError.tsx new file mode 100644 index 0000000..a6ff86b --- /dev/null +++ b/components/Form/InputError/InputError.tsx @@ -0,0 +1,15 @@ +'use client'; + +import styles from './InputError.module.css'; + +export default function FormInputError({ message }: { message: string | string[] | undefined }) { + if (!message) { + return; + } + + return ( + + {Array.isArray(message) ? message.map(m => (

{m}

)) : (

{message}

)} +
+ ) +} \ No newline at end of file diff --git a/components/Form/InfoIcon/InfoIcon.module.css b/components/Icons/InfoIcon/InfoIcon.module.css similarity index 100% rename from components/Form/InfoIcon/InfoIcon.module.css rename to components/Icons/InfoIcon/InfoIcon.module.css diff --git a/components/Form/InfoIcon/InfoIcon.tsx b/components/Icons/InfoIcon/InfoIcon.tsx similarity index 100% rename from components/Form/InfoIcon/InfoIcon.tsx rename to components/Icons/InfoIcon/InfoIcon.tsx diff --git a/components/Toast/Toast.tsx b/components/Toast/Toast.tsx index c7d3630..208445c 100644 --- a/components/Toast/Toast.tsx +++ b/components/Toast/Toast.tsx @@ -2,23 +2,14 @@ import clsx from 'clsx'; import styles from './Toast.module.css'; export default function Toast({ - status, + success, message, }: { - status: string; - message: string; + success: boolean; + message: string | React.JSX.Element; }) { return ( -
+
{message}
); diff --git a/components/SubmitMatches/Guidelines/Guidelines.module.css b/components/Tournaments/Submission/Guidelines/Guidelines.module.css similarity index 100% rename from components/SubmitMatches/Guidelines/Guidelines.module.css rename to components/Tournaments/Submission/Guidelines/Guidelines.module.css diff --git a/components/SubmitMatches/Guidelines/Guidelines.tsx b/components/Tournaments/Submission/Guidelines/Guidelines.tsx similarity index 99% rename from components/SubmitMatches/Guidelines/Guidelines.tsx rename to components/Tournaments/Submission/Guidelines/Guidelines.tsx index 1e0ad9b..7a1bd56 100644 --- a/components/SubmitMatches/Guidelines/Guidelines.tsx +++ b/components/Tournaments/Submission/Guidelines/Guidelines.tsx @@ -1,4 +1,5 @@ 'use client'; + import Card from '@/components/Card/Card'; import styles from './Guidelines.module.css'; diff --git a/components/SubmitMatches/MatchForm/MatchForm.module.css b/components/Tournaments/Submission/SubmissionForm/SubmissionForm.module.css similarity index 84% rename from components/SubmitMatches/MatchForm/MatchForm.module.css rename to components/Tournaments/Submission/SubmissionForm/SubmissionForm.module.css index ef3d6ae..7622ead 100644 --- a/components/SubmitMatches/MatchForm/MatchForm.module.css +++ b/components/Tournaments/Submission/SubmissionForm/SubmissionForm.module.css @@ -70,28 +70,18 @@ padding: 0.08rem; } -.field #gamemode { +.field #ruleset { max-width: 30%; } -.field#tournamentAbbreviation { +.field #abbreviation { width: 65%; } -.field #teamsize { +.field #lobbySize { max-width: fit-content; } .fields .row.checkbox { align-items: flex-start; } - -.field .inputError { - color: hsla(var(--red-600)); - font-weight: 500; - font-size: 0.8rem; -} - -.field .inputError:empty { - display: none; -} diff --git a/components/SubmitMatches/MatchForm/MatchForm.tsx b/components/Tournaments/Submission/SubmissionForm/SubmissionForm.tsx similarity index 63% rename from components/SubmitMatches/MatchForm/MatchForm.tsx rename to components/Tournaments/Submission/SubmissionForm/SubmissionForm.tsx index 83a2321..5ebc341 100644 --- a/components/SubmitMatches/MatchForm/MatchForm.tsx +++ b/components/Tournaments/Submission/SubmissionForm/SubmissionForm.tsx @@ -1,89 +1,69 @@ 'use client'; -import { saveTournamentMatches } from '@/app/actions'; +import { handleTournamentFormState } from '@/app/actions/tournaments'; import Form from '@/components/Form/Form'; -import InfoIcon from '@/components/Form/InfoIcon/InfoIcon'; +import InfoIcon from '@/components/Icons/InfoIcon/InfoIcon'; import Toast from '@/components/Toast/Toast'; -import { useSetError } from '@/util/hooks'; import clsx from 'clsx'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useFormState, useFormStatus } from 'react-dom'; -import styles from './MatchForm.module.css'; +import styles from './SubmissionForm.module.css'; +import FormInputError from '@/components/Form/InputError/InputError'; -const initialState = { - message: null, -}; - -function SubmitButton() { +function SubmitButton({ rulesAccepted }: { rulesAccepted: boolean }) { const { pending } = useFormStatus(); return ( - ); } -export default function MatchForm({ - userScopes, -}: { - userScopes: Array; -}) { - const [state, formAction] = useFormState(saveTournamentMatches, initialState); +export default function SubmissionForm({ userScopes }: { userScopes: Array }) { + const [formState, formAction] = useFormState(handleTournamentFormState, { success: false, message: '', errors: {} }); + const formRef = useRef(null); const [rulesAccepted, setRulesAccepted] = useState(false); - const [verifierAccepted, setVerifierAccepted] = useState(false); const [showToast, setShowToast] = useState(false); - const setError = useSetError(); - useEffect(() => { - // Shows toast for both success or error, but need better implementation for errors - /* if (state?.status) { - setShowToast(true); - setTimeout(() => { - setShowToast(false); - }, 6000); - } */ - - if (state?.error) { - setError(state?.error); + // Clear the form after successful submission + if (formState.success) { + formRef.current?.reset(); } - if (state?.success) { - document.getElementById('tournament-form')?.reset(); + // If there is a message, display it in a toast + if (formState.message !== '') { setShowToast(true); setTimeout(() => { setShowToast(false); }, 6000); } - - return () => {}; - }, [state]); + }, [formState]); return ( <>
-
+

Tournament Submission

- Any tournament, regardless of badge status, may be submitted, so - long as it follows our rules. + Any tournament, regardless of badge status, may be submitted so + long as it adheres to our submission guidelines.

+ {/* Ruleset */}
- - - {state?.errors?.ruleset} - + +
+ {/* Name */}
- - - {state?.errors?.tournamentName} - + +
-
- - - {state?.errors?.abbreviation} - + {/* Abbreviation */} +
+ +
+ {/* Rank restriction */}
-
+ {/* Lobby size */}
-
+
+
+ {/** Mappool links */} +
+
+

+ Beatmap links + +

+
+
+
+
+ + +
+
+
+
+ {/** Accept rules and submit */} +
+
+ {/** Rules checkbox */}
setRulesAccepted(e.target.checked)} /> setRulesAccepted((prev) => !prev)}> - I read the rules and I understand that submitting irrelevant - matches can lead to a restriction + I have read the rules and understand that abusing tournament submission can lead to a restriction
+
-
- - {state?.errors?.serverError} - -
-
- {showToast && ( - - )} + {showToast && ()} ); } diff --git a/lib/api.ts b/lib/api.ts new file mode 100644 index 0000000..34e240f --- /dev/null +++ b/lib/api.ts @@ -0,0 +1,22 @@ +import { HttpValidationProblemDetails, ProblemDetails } from "@osu-tournament-rating/otr-api-client"; + +export function isProblemDetails(obj: any): obj is ProblemDetails { + return ( + obj !== null + && obj !== undefined + && typeof obj === 'object' + && 'title' in obj + && 'status' in obj + ); +} + +export function isHttpValidationProblemDetails(obj: any): obj is HttpValidationProblemDetails { + return ( + isProblemDetails(obj) + && 'errors' in obj + && typeof obj.errors === 'object' + && Object.values(obj.errors).every( + (value) => Array.isArray(value) && value.every((v) => typeof v === "string") + ) + ); +} \ No newline at end of file diff --git a/lib/regex.ts b/lib/regex.ts new file mode 100644 index 0000000..ff76dce --- /dev/null +++ b/lib/regex.ts @@ -0,0 +1,5 @@ +/** Regex pattern for extracting osu! beatmap ids */ +export const BeatmapLinkPattern = /https?:\/\/(?:osu|old)\.ppy\.sh\/(?:beatmapsets\/\d+(?:#osu\/|%23osu\/)|b\/|beatmaps\/|p\/beatmap\?b=)(\d+)/; + +/** Regex pattern for extracting osu! multiplayer match ids */ +export const MatchLinkPattern = /(https?:\/\/osu\.ppy\.sh\/community\/matches\/\d+|https?:\/\/osu\.ppy\.sh\/mp\/\d+)/; \ No newline at end of file diff --git a/lib/schemas.ts b/lib/schemas.ts new file mode 100644 index 0000000..26db8c9 --- /dev/null +++ b/lib/schemas.ts @@ -0,0 +1,51 @@ +import { Ruleset, TournamentRejectionReason } from "@osu-tournament-rating/otr-api-client"; +import { EnumLike, CustomErrorParams, z } from "zod"; + +function nativeBitwiseEnum(enumType: T, params?: CustomErrorParams) { + const validFlags = Object.values(enumType).filter(v => typeof v === 'number'); + const allFlags = validFlags.reduce((acc, flag) => acc | flag, 0); + + return z.custom((value) => { + return typeof value === 'number' && (validFlags.includes(value) || (value & ~allFlags) === 0); + }, params); +} + +/** Helper function to create an error map while exposing the original value for use */ +const makeErrorMap = (messages: { [Code in z.ZodIssueCode]?: (value: unknown) => string; }): { errorMap: z.ZodErrorMap } => { + return { + errorMap: (issue, ctx) => { + return { + message: messages[issue.code]?.(ctx.data) || ctx.defaultError, + }; + }, + }; +}; + +export const TournamentSubmissionFormSchema = z.object({ + name: z.string().min(1), + abbreviation: z.string().min(1), + forumUrl: z.string().url().refine( + (value) => value.startsWith('https://osu.ppy.sh/community/forums/topics/') || value.startsWith('https://osu.ppy.sh/wiki/en/Tournaments/'), + { message: 'Forum URL must be from "https://osu.ppy.sh/community/forums/topics/" or "https://osu.ppy.sh/wiki/en/Tournaments/"' } + ), + rankRangeLowerBound: z.number().min(1), + lobbySize: z.number().min(1).max(8), + ruleset: z.nativeEnum(Ruleset), + rejectionReason: z.nativeEnum(TournamentRejectionReason).optional(), + // rejectionReason: nativeBitwiseEnum(TournamentRejectionReason).optional(), + ids: z.array( + z + .number(makeErrorMap({ + invalid_type: value => `Could not determine osu! match id for entry: "${value}"` + })) + .positive() + ) + .nonempty('At least one valid osu! match link or id is required'), + beatmapIds: z.array( + z + .number(makeErrorMap({ + invalid_type: value => `Could not determine osu! beatmap id for entry: "${value}"` + })) + .positive() + ) +}); \ No newline at end of file diff --git a/lib/types.ts b/lib/types.ts index 995afc6..c1687a7 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -172,4 +172,16 @@ export enum CookieNames { export type GetSessionParams = { req: NextRequest, res: NextResponse -}; \ No newline at end of file +}; + +/** Describes the state of a form */ +export type FormState = { + /** Denotes if the submission was successful */ + success: boolean; + + /** Seccess / fail detail to display in a toast */ + message: string | React.JSX.Element; + + /** Any errors specific to a form property */ + errors: { [K in keyof T]?: string[]; }; +} \ No newline at end of file From eb36337a5560b71fa86b9bcfcaec790861042572 Mon Sep 17 00:00:00 2001 From: Placni Date: Sun, 1 Dec 2024 23:50:39 -0600 Subject: [PATCH 02/10] use ruleset enum for selector --- .../SubmissionForm/SubmissionForm.tsx | 18 +++++++++++------- lib/api.ts | 6 +++++- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/components/Tournaments/Submission/SubmissionForm/SubmissionForm.tsx b/components/Tournaments/Submission/SubmissionForm/SubmissionForm.tsx index 5ebc341..38de681 100644 --- a/components/Tournaments/Submission/SubmissionForm/SubmissionForm.tsx +++ b/components/Tournaments/Submission/SubmissionForm/SubmissionForm.tsx @@ -2,13 +2,16 @@ import { handleTournamentFormState } from '@/app/actions/tournaments'; import Form from '@/components/Form/Form'; +import FormInputError from '@/components/Form/InputError/InputError'; import InfoIcon from '@/components/Icons/InfoIcon/InfoIcon'; import Toast from '@/components/Toast/Toast'; +import { isAdmin } from '@/lib/api'; import clsx from 'clsx'; import { useEffect, useRef, useState } from 'react'; import { useFormState, useFormStatus } from 'react-dom'; import styles from './SubmissionForm.module.css'; -import FormInputError from '@/components/Form/InputError/InputError'; +import { Ruleset } from '@osu-tournament-rating/otr-api-client'; +import { rulesetIcons } from '@/lib/types'; function SubmitButton({ rulesAccepted }: { rulesAccepted: boolean }) { const { pending } = useFormStatus(); @@ -23,6 +26,7 @@ function SubmitButton({ rulesAccepted }: { rulesAccepted: boolean }) { export default function SubmissionForm({ userScopes }: { userScopes: Array }) { const [formState, formAction] = useFormState(handleTournamentFormState, { success: false, message: '', errors: {} }); const formRef = useRef(null); + const userIsAdmin = isAdmin(userScopes); const [rulesAccepted, setRulesAccepted] = useState(false); const [showToast, setShowToast] = useState(false); @@ -66,12 +70,12 @@ export default function SubmissionForm({ userScopes }: { userScopes: Array - - - - - - + + + + {userIsAdmin && ()} + +
diff --git a/lib/api.ts b/lib/api.ts index 34e240f..528885a 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -1,4 +1,4 @@ -import { HttpValidationProblemDetails, ProblemDetails } from "@osu-tournament-rating/otr-api-client"; +import { HttpValidationProblemDetails, ProblemDetails, Roles } from "@osu-tournament-rating/otr-api-client"; export function isProblemDetails(obj: any): obj is ProblemDetails { return ( @@ -19,4 +19,8 @@ export function isHttpValidationProblemDetails(obj: any): obj is HttpValidationP (value) => Array.isArray(value) && value.every((v) => typeof v === "string") ) ); +} + +export function isAdmin(scopes: string[]) { + return scopes.includes(Roles.Admin); } \ No newline at end of file From e4ce4cfe51ef95f9906297dc87c68275cbbcc01f Mon Sep 17 00:00:00 2001 From: Placni Date: Mon, 2 Dec 2024 01:22:27 -0600 Subject: [PATCH 03/10] reorganize form fields --- .../SubmissionForm/SubmissionForm.module.css | 12 +-- .../SubmissionForm/SubmissionForm.tsx | 96 +++++++------------ 2 files changed, 37 insertions(+), 71 deletions(-) diff --git a/components/Tournaments/Submission/SubmissionForm/SubmissionForm.module.css b/components/Tournaments/Submission/SubmissionForm/SubmissionForm.module.css index 7622ead..e076c17 100644 --- a/components/Tournaments/Submission/SubmissionForm/SubmissionForm.module.css +++ b/components/Tournaments/Submission/SubmissionForm/SubmissionForm.module.css @@ -70,16 +70,8 @@ padding: 0.08rem; } -.field #ruleset { - max-width: 30%; -} - -.field #abbreviation { - width: 65%; -} - -.field #lobbySize { - max-width: fit-content; +.field#abbreviation { + max-width: 33%; } .fields .row.checkbox { diff --git a/components/Tournaments/Submission/SubmissionForm/SubmissionForm.tsx b/components/Tournaments/Submission/SubmissionForm/SubmissionForm.tsx index 38de681..3ec1dc7 100644 --- a/components/Tournaments/Submission/SubmissionForm/SubmissionForm.tsx +++ b/components/Tournaments/Submission/SubmissionForm/SubmissionForm.tsx @@ -59,52 +59,15 @@ export default function SubmissionForm({ userScopes }: { userScopes: Array
-
- {/* Ruleset */} -
- - - -
-
-
- {/* Forum post URL */} -
- - - -
-
{/* Name */} -
+
@@ -113,20 +76,44 @@ export default function SubmissionForm({ userScopes }: { userScopes: ArrayAbbreviation
+ {/* Forum post URL */} +
+ + + +
+
+
+ {/* Ruleset */} +
+ + + +
{/* Rank restriction */}
-
-
{/* Lobby size */}
- @@ -226,10 +204,8 @@ export default function SubmissionForm({ userScopes }: { userScopes: Array @@ -219,6 +221,7 @@ export default function SubmissionForm({ userScopes }: { userScopes: Array

Beatmap links + {/** Info text is placeholder and should be replaced. Ideally when moving submission guidelines to docs */} diff --git a/lib/schemas.ts b/lib/schemas.ts index 26db8c9..314b476 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -1,4 +1,4 @@ -import { Ruleset, TournamentRejectionReason } from "@osu-tournament-rating/otr-api-client"; +import { Ruleset, TournamentRejectionReason, TournamentSubmissionDTO } from "@osu-tournament-rating/otr-api-client"; import { EnumLike, CustomErrorParams, z } from "zod"; function nativeBitwiseEnum(enumType: T, params?: CustomErrorParams) { @@ -11,13 +11,9 @@ function nativeBitwiseEnum(enumType: T, params?: CustomError } /** Helper function to create an error map while exposing the original value for use */ -const makeErrorMap = (messages: { [Code in z.ZodIssueCode]?: (value: unknown) => string; }): { errorMap: z.ZodErrorMap } => { - return { - errorMap: (issue, ctx) => { - return { - message: messages[issue.code]?.(ctx.data) || ctx.defaultError, - }; - }, +const makeErrorMap = (messages: { [Code in z.ZodIssueCode]?: (value: unknown) => string; }): z.ZodErrorMap => { + return (issue, ctx) => { + return { message: messages[issue.code]?.(ctx.data) || ctx.defaultError }; }; }; @@ -28,24 +24,33 @@ export const TournamentSubmissionFormSchema = z.object({ (value) => value.startsWith('https://osu.ppy.sh/community/forums/topics/') || value.startsWith('https://osu.ppy.sh/wiki/en/Tournaments/'), { message: 'Forum URL must be from "https://osu.ppy.sh/community/forums/topics/" or "https://osu.ppy.sh/wiki/en/Tournaments/"' } ), - rankRangeLowerBound: z.number().min(1), - lobbySize: z.number().min(1).max(8), + rankRangeLowerBound: z.number({ coerce: true }).min(1), + lobbySize: z.number({ coerce: true }).min(1).max(8), ruleset: z.nativeEnum(Ruleset), rejectionReason: z.nativeEnum(TournamentRejectionReason).optional(), // rejectionReason: nativeBitwiseEnum(TournamentRejectionReason).optional(), ids: z.array( z - .number(makeErrorMap({ - invalid_type: value => `Could not determine osu! match id for entry: "${value}"` - })) + .number({ + errorMap: makeErrorMap({ + invalid_type: value => `Could not determine osu! match id for entry: "${value}"` + }) + }) .positive() ) .nonempty('At least one valid osu! match link or id is required'), beatmapIds: z.array( z - .number(makeErrorMap({ - invalid_type: value => `Could not determine osu! beatmap id for entry: "${value}"` - })) + .number({ + errorMap: makeErrorMap({ + invalid_type: value => `Could not determine osu! beatmap id for entry: "${value}"` + }) + }) .positive() ) -}); \ No newline at end of file +/** + * Using the 'satisfies' keyword, we can ensure that the defined schema implements every field + * from the target type, which in this case is {@link TournamentSubmissionDTO}. By doing this we + * will ensure that type errors are raised if this DTO happens to change in the future + */ +}) satisfies z.ZodSchema; \ No newline at end of file diff --git a/util/forms.ts b/util/forms.ts new file mode 100644 index 0000000..7c5b468 --- /dev/null +++ b/util/forms.ts @@ -0,0 +1,27 @@ +/** + * Gets the names of each property on a type + * @returns An object containing the names of each property of the given type +*/ +export const keysOf = () => new Proxy( + {}, + { get: (_, prop: string) => prop } +) as { [K in keyof T]-?: NonNullable }; + +/** + * Extracts the data from a form for a given type + * @param formData Form data + * @param transform A map of optional functions for each property that are used to transform the raw form data + * @returns An object containing data for each property in T extracted from the given form data + */ +export function extractFormData( + formData: FormData, + transform?: { [K in keyof T]?: (value: string) => any } +) { + const result: { [K in keyof T]?: any | undefined } = {}; + + for (const [k, v] of Array.from(formData.entries())) { + result[k as keyof T] = transform?.[k as keyof T]?.(v as string) ?? v; + } + + return result; +} \ No newline at end of file