diff --git a/app/actions.ts b/app/actions.ts index d7c1c88..a94c811 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -2,7 +2,6 @@ import { LeaderboardsQuerySchema, - MatchesSubmitFormSchema, TournamentsQuerySchema, UserpageQuerySchema } from '@/lib/types'; @@ -10,97 +9,6 @@ import { cookies } from 'next/headers'; import { notFound, redirect } from 'next/navigation'; import { getSessionData } from '@/app/actions/session'; -export async function saveTournamentMatches( - prevState: any, - formData: FormData -) { - const session = await getSessionData(); - - /* IF USER IS UNAUTHORIZED REDIRECT TO HOMEPAGE */ - if (!session.id) return redirect('/'); - - try { - /* REGEX TO REMOVE ALL SPACES AND ENTERS */ - let matchIDs = await formData - .get('matchLinks') - .split(/\r?\n/g) - .map((value: string) => { - if (value.startsWith('https://osu.ppy.sh/community/matches/')) - value = value.replace('https://osu.ppy.sh/community/matches/', ''); - - if (value.startsWith('https://osu.ppy.sh/mp/')) { - value = value.replace('https://osu.ppy.sh/mp/', ''); - } - - /* REGEX TO CHECK IF VALUE HAS ONLY DIGITS */ - if (!/^\d+$/.test(value)) { - return value; - } - - return parseFloat(value); - }); - - const data = MatchesSubmitFormSchema.parse({ - name: formData.get('tournamentName'), - abbreviation: formData.get('tournamentAbbreviation'), - forumUrl: formData.get('forumPostURL'), - rankRangeLowerBound: parseInt(formData.get('rankRestriction')), - lobbySize: parseInt(formData.get('teamSize')), - ruleset: parseInt(formData.get('gameMode')), - ids: matchIDs, - }); - - let tournamentSubmit = await fetch( - `${process.env.REACT_APP_API_URL}/tournaments`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': `${process.env.REACT_APP_ORIGIN_URL}`, - Authorization: `Bearer ${session.accessToken}`, - }, - credentials: 'include', - body: JSON.stringify(data), - } - ); - - if (!tournamentSubmit?.ok) { - const errorMessage = await tournamentSubmit.text(); - - return { - error: { - status: tournamentSubmit.status, - text: tournamentSubmit.statusText, - message: errorMessage, - }, - }; - } - - return { - success: { - status: tournamentSubmit.status, - text: tournamentSubmit.statusText, - message: 'Tournament submitted successfully', - }, - }; - } catch (error) { - let errors = {}; - - if (error) { - if (error?.issues?.length > 0) { - error?.issues.forEach((err) => { - return (errors[`${err.path[0]}`] = err.message); - }); - } - } - - return { - status: 'error', - errors, - }; - } -} - export async function resetLeaderboardFilters(string: string) { return redirect(string); } diff --git a/app/actions/tournaments.ts b/app/actions/tournaments.ts new file mode 100644 index 0000000..3cbe0b5 --- /dev/null +++ b/app/actions/tournaments.ts @@ -0,0 +1,87 @@ +'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 { extractFormData } from "@/util/forms"; +import { TournamentSubmissionDTO, TournamentsWrapper } from "@osu-tournament-rating/otr-api-client"; +import { ZodError } from "zod"; + +/** + * Handles parsing, submiting, and handling errors for tournament submission data + * @param _previousState Previous form state + * @param formData Form data + * @returns The state of the form after performing the action + */ +export async function tournamentSubmissionFormAction( + _previousState: FormState, + formData: FormData +): Promise> { + const result: FormState = { + success: false, + message: "", + errors: {} + }; + + try { + const parsedForm = TournamentSubmissionFormSchema.parse(extractFormData(formData, { + ruleset: value => parseInt(value), + ids: value => value + // 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; + }), + beatmapIds: value => value + .split(/\r?\n/g) + .filter(s => s.trim() !== '') + .map(s => { + s = s.trim(); + + if (!isNaN(parseFloat(s))) { + return parseFloat(s); + } + + const match = BeatmapLinkPattern.exec(s); + return match ? parseFloat(match[1]) : s; + }) + })); + + 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.module.css b/components/Form/Form.module.css index edfc051..df08e55 100644 --- a/components/Form/Form.module.css +++ b/components/Form/Form.module.css @@ -14,16 +14,13 @@ padding: 0.8rem 1rem; font-size: 1rem; font-weight: 500; + resize: none; } .form :is(input:is(:not([type='checkbox'])), select, button) { height: 3.2rem; } -.form input:is(:focus, :focus-visible) { - outline: 0; -} - .form :is(input, textarea)::placeholder { font-weight: 500; color: hsla(var(--gray-600)); 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..fd858d7 --- /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, idx) => (

{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..b325adc 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; + success: boolean; message: string; }) { 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 79% rename from components/SubmitMatches/MatchForm/MatchForm.module.css rename to components/Tournaments/Submission/SubmissionForm/SubmissionForm.module.css index ef3d6ae..e076c17 100644 --- a/components/SubmitMatches/MatchForm/MatchForm.module.css +++ b/components/Tournaments/Submission/SubmissionForm/SubmissionForm.module.css @@ -70,28 +70,10 @@ padding: 0.08rem; } -.field #gamemode { - max-width: 30%; -} - -.field#tournamentAbbreviation { - width: 65%; -} - -.field #teamsize { - max-width: fit-content; +.field#abbreviation { + max-width: 33%; } .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 50% rename from components/SubmitMatches/MatchForm/MatchForm.tsx rename to components/Tournaments/Submission/SubmissionForm/SubmissionForm.tsx index 83a2321..b57a034 100644 --- a/components/SubmitMatches/MatchForm/MatchForm.tsx +++ b/components/Tournaments/Submission/SubmissionForm/SubmissionForm.tsx @@ -1,153 +1,122 @@ 'use client'; -import { saveTournamentMatches } from '@/app/actions'; +import { tournamentSubmissionFormAction } from '@/app/actions/tournaments'; import Form from '@/components/Form/Form'; -import InfoIcon from '@/components/Form/InfoIcon/InfoIcon'; +import FormInputError from '@/components/Form/InputError/InputError'; +import InfoIcon from '@/components/Icons/InfoIcon/InfoIcon'; import Toast from '@/components/Toast/Toast'; -import { useSetError } from '@/util/hooks'; +import { isAdmin } from '@/lib/api'; +import { rulesetIcons } from '@/lib/types'; +import { keysOf } from '@/util/forms'; +import { Ruleset, TournamentSubmissionDTO } from '@osu-tournament-rating/otr-api-client'; 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'; -const initialState = { - message: null, -}; +const formFieldNames = keysOf(); -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(tournamentSubmissionFormAction, { success: false, message: '', errors: {} }); + const formRef = useRef(null); + const userIsAdmin = isAdmin(userScopes); 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.

-
- - - {state?.errors?.ruleset} - - + {/* Name */} +
+ + +
-
-
-
- - - {state?.errors?.forumPost} - + {/* Abbreviation */} +
+ +
+ {/* Forum post URL */}
- - - {state?.errors?.tournamentName} - - -
-
- - - {state?.errors?.abbreviation} - + +
+ {/* Ruleset */} +
+ + + +
+ {/* Rank restriction */}
-
-
-
+ {/* Lobby size */}
-
+ {/** MP links */}

Match links - {/* // ? ADD INFO TO MATCH */}
- - {state?.errors?.ids} - + + +
+
+

+
+ {/** Mappool links */} +
+
+

+ Beatmap links + {/** Info text is placeholder and should be replaced. Ideally when moving submission guidelines to docs */} + +

+
+
+
+
+
+
+
+ {/** 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..4df38e0 --- /dev/null +++ b/lib/api.ts @@ -0,0 +1,29 @@ +import { HttpValidationProblemDetails, ProblemDetails, Roles } from "@osu-tournament-rating/otr-api-client"; + +/** Type guard for determining if an object is {@link ProblemDetails} */ +export function isProblemDetails(obj: any): obj is ProblemDetails { + return ( + obj !== null + && obj !== undefined + && typeof obj === 'object' + && 'title' in obj + && 'status' in obj + ); +} + +/** Type guard for determining if an object is {@link HttpValidationProblemDetails} */ +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") + ) + ); +} + +/** Denotes if a list of scopes containes the admin scope */ +export function isAdmin(scopes: string[]) { + return scopes.includes(Roles.Admin); +} \ 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..314b476 --- /dev/null +++ b/lib/schemas.ts @@ -0,0 +1,56 @@ +import { Ruleset, TournamentRejectionReason, TournamentSubmissionDTO } 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; }): z.ZodErrorMap => { + return (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({ 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({ + 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({ + errorMap: makeErrorMap({ + invalid_type: value => `Could not determine osu! beatmap id for entry: "${value}"` + }) + }) + .positive() + ) +/** + * 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/lib/types.ts b/lib/types.ts index 995afc6..37b5946 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -105,37 +105,6 @@ export const TournamentsQuerySchema = z.object({ page: z.number().gte(1).default(1), }); -export const MatchesSubmitFormSchema = z.object({ - name: z.string().min(1), - abbreviation: z.string().min(1), - forumUrl: z.union([ - z - .string() - .url() - .startsWith('https://osu.ppy.sh/community/forums/topics/') - .min(1), - z - .string() - .url() - .startsWith('https://osu.ppy.sh/wiki/en/Tournaments/') - .min(1), - ]), - rankRangeLowerBound: z.number().min(1), - lobbySize: z.number().min(1).max(8), - ruleset: z.number().min(0).max(5), - ids: z - .array( - z - .number({ - required_error: 'osu! match link or lobby id required', - invalid_type_error: - 'Failed to parse one or more entries, ensure all entries are match IDs or osu! match URLs only, one per line', - }) - .positive() - ) - .min(1), -}); - export const matchesVerificationStatuses = { '0': {}, }; @@ -172,4 +141,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; + + /** Any errors specific to a form property */ + errors: { [K in keyof T]?: string[]; }; +} \ 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