Skip to content

Commit

Permalink
Merge pull request #231 from osu-tournament-rating/beatmap-submission
Browse files Browse the repository at this point in the history
Refactor tournament submission to use the otr-api-client
  • Loading branch information
myssto authored Dec 6, 2024
2 parents 5c35de9 + db04199 commit 642c4e3
Show file tree
Hide file tree
Showing 20 changed files with 374 additions and 312 deletions.
92 changes: 0 additions & 92 deletions app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,105 +2,13 @@

import {
LeaderboardsQuerySchema,
MatchesSubmitFormSchema,
TournamentsQuerySchema,
UserpageQuerySchema
} from '@/lib/types';
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);
}
Expand Down
87 changes: 87 additions & 0 deletions app/actions/tournaments.ts
Original file line number Diff line number Diff line change
@@ -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<TournamentSubmissionDTO>,
formData: FormData
): Promise<FormState<TournamentSubmissionDTO>> {
const result: FormState<TournamentSubmissionDTO> = {
success: false,
message: "",
errors: {}
};

try {
const parsedForm = TournamentSubmissionFormSchema.parse(extractFormData<TournamentSubmissionDTO>(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;
}
8 changes: 4 additions & 4 deletions app/submit/page.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -15,7 +15,7 @@ export default async function page() {
return (
<main className={styles.pageContainer}>
<Guidelines />
<MatchForm userScopes={scopes} />
<SubmissionForm userScopes={scopes} />
</main>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
5 changes: 1 addition & 4 deletions components/Form/Form.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
11 changes: 5 additions & 6 deletions components/Form/Form.tsx
Original file line number Diff line number Diff line change
@@ -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<DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>, 'className'> & { children: React.ReactNode }
) {
return (
<form action={action} className={styles.form} id={'tournament-form'}>
<form className={styles.form} {...rest}>
{children}
</form>
);
Expand Down
9 changes: 9 additions & 0 deletions components/Form/InputError/InputError.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.inputError {
color: hsla(var(--red-600));
font-weight: 500;
font-size: 0.8rem;
}

.inputError:empty {
display: none;
}
15 changes: 15 additions & 0 deletions components/Form/InputError/InputError.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className={styles.inputError}>
{Array.isArray(message) ? message.map((m, idx) => (<p key={`error${idx}`}>{m}</p>)) : (<p>{message}</p>)}
</span>
)
}
File renamed without changes.
15 changes: 3 additions & 12 deletions components/Toast/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
className={clsx(
styles.toast,
status === 'success'
? styles.success
: status === 'error'
? styles.error
: ''
)}
>
<div className={clsx(styles.toast, success ? styles.success : styles.error)}>
{message}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
'use client';

import Card from '@/components/Card/Card';
import styles from './Guidelines.module.css';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading

0 comments on commit 642c4e3

Please sign in to comment.