diff --git a/package-lock.json b/package-lock.json index a9a334d5..98a73b7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@tanstack/react-store": "^0.7.0", "@tanstack/store": "^0.7.0", "@types/iso-3166-2": "^1.0.3", + "@types/lodash": "^4.17.24", "browser-image-compression": "^2.0.2", "change-case": "^5.4.4", "clsx": "^2.1.1", @@ -43,6 +44,7 @@ "image-blob-reduce": "^4.1.0", "iso-3166-2": "^1.0.0", "jest-environment-jsdom": "^29.7.0", + "lodash": "^4.18.1", "lucide-react": "^0.572.0", "nanoid": "^5.1.5", "nuqs": "^2.7.3", @@ -5210,6 +5212,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, "node_modules/@types/luxon": { "version": "3.6.2", "dev": true, @@ -9310,6 +9318,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "dev": true, diff --git a/package.json b/package.json index 0c968c43..d5f12b56 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@tanstack/react-store": "^0.7.0", "@tanstack/store": "^0.7.0", "@types/iso-3166-2": "^1.0.3", + "@types/lodash": "^4.17.24", "browser-image-compression": "^2.0.2", "change-case": "^5.4.4", "clsx": "^2.1.1", @@ -55,6 +56,7 @@ "image-blob-reduce": "^4.1.0", "iso-3166-2": "^1.0.0", "jest-environment-jsdom": "^29.7.0", + "lodash": "^4.18.1", "lucide-react": "^0.572.0", "nanoid": "^5.1.5", "nuqs": "^2.7.3", diff --git a/src/components/ChangeEmailForm/ChangeEmailForm.tsx b/src/components/ChangeEmailForm/ChangeEmailForm.tsx index fea713a9..98900f33 100644 --- a/src/components/ChangeEmailForm/ChangeEmailForm.tsx +++ b/src/components/ChangeEmailForm/ChangeEmailForm.tsx @@ -53,7 +53,7 @@ export const ChangeEmailForm = ({ }, [isDirty, setDirty]); const handleSubmit: SubmitHandler = async (formData): Promise => { - const validFormData = validateForm(schema, formData, form.setError); + const validFormData = validateForm(schema, formData, form); if (validFormData) { onSubmit(validFormData); } diff --git a/src/components/ChangePasswordForm/ChangePasswordForm.tsx b/src/components/ChangePasswordForm/ChangePasswordForm.tsx index ad3eac38..e72c6a23 100644 --- a/src/components/ChangePasswordForm/ChangePasswordForm.tsx +++ b/src/components/ChangePasswordForm/ChangePasswordForm.tsx @@ -56,7 +56,7 @@ export const ChangePasswordForm = ({ }, [isDirty, setDirty]); const handleSubmit: SubmitHandler = async (formData): Promise => { - const validFormData = validateForm(schema, formData, form.setError); + const validFormData = validateForm(schema, formData, form); if (validFormData) { onSubmit({ currentPassword: validFormData.currentPassword, password: validFormData.password }); } diff --git a/src/components/ListCommentForm/ListCommentForm.tsx b/src/components/ListCommentForm/ListCommentForm.tsx index 52e9ddae..e8be27b9 100644 --- a/src/components/ListCommentForm/ListCommentForm.tsx +++ b/src/components/ListCommentForm/ListCommentForm.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; import { Button, InputTextArea } from '@ianpaschal/combat-command-components'; import clsx from 'clsx'; +import { merge } from 'lodash'; import { MessageCircle, MessageCircleCheck, @@ -49,11 +50,7 @@ export const ListCommentForm = ({ }: ListCommentFormProps): JSX.Element => { const user = useAuth(); const form = useForm({ - defaultValues: { - ...defaultValues, - ...existingValues, - ...forcedValues, - }, + defaultValues: merge({}, defaultValues, existingValues, forcedValues), mode: 'onSubmit', }); @@ -64,11 +61,7 @@ export const ListCommentForm = ({ }, [isDirty, setDirty]); const handleSubmit: SubmitHandler = async (formData): Promise => { - const validFormData = validateForm(schema, { - ...formData, - ...forcedValues, - userId: user?._id, - }, form.setError); + const validFormData = validateForm(schema, merge({}, formData, forcedValues, { userId: user?._id }), form); if (validFormData) { onSubmit({ ...validFormData }); form.reset(); @@ -76,11 +69,7 @@ export const ListCommentForm = ({ }; const handleApprove = (): void => { - const validFormData = validateForm(schema, { - ...form.watch(), - ...forcedValues, - userId: user?._id, - }, form.setError); + const validFormData = validateForm(schema, merge({}, form.watch(), forcedValues, { userId: user?._id }), form); if (validFormData) { onSubmit({ ...validFormData, control: 'approved' }); form.reset(); @@ -88,11 +77,7 @@ export const ListCommentForm = ({ }; const handleReject = (): void => { - const validFormData = validateForm(schema, { - ...form.watch(), - ...forcedValues, - userId: user?._id, - }, form.setError); + const validFormData = validateForm(schema, merge({}, form.watch(), forcedValues, { userId: user?._id }), form); if (validFormData) { onSubmit({ ...validFormData, control: 'rejected' }); form.reset(); diff --git a/src/components/ListForm/ListForm.tsx b/src/components/ListForm/ListForm.tsx index 715090df..6ca36837 100644 --- a/src/components/ListForm/ListForm.tsx +++ b/src/components/ListForm/ListForm.tsx @@ -3,6 +3,7 @@ import { useDropzone } from 'react-dropzone'; import { SubmitHandler, useForm } from 'react-hook-form'; import { getStyleClassNames, PdfViewer } from '@ianpaschal/combat-command-components'; import clsx from 'clsx'; +import { merge } from 'lodash'; import { Upload } from 'lucide-react'; import { List } from '~/api'; @@ -48,11 +49,7 @@ export const ListForm = ({ }: ListFormProps): JSX.Element => { const user = useAuth(); const form = useForm({ - defaultValues: { - ...defaultValues, - ...existingValues, - ...forcedValues, - }, + defaultValues: merge({}, defaultValues, existingValues, forcedValues), mode: 'onSubmit', }); @@ -63,11 +60,7 @@ export const ListForm = ({ }, [isDirty, setDirty]); const handleSubmit: SubmitHandler = async (formData): Promise => { - const validFormData = validateForm(schema, { - ...formData, - ...forcedValues, - userId: user?._id, - }, form.setError); + const validFormData = validateForm(schema, merge({}, formData, forcedValues, { userId: user?._id }), form); if (validFormData) { onSubmit(validFormData); } diff --git a/src/components/MatchResultForm/MatchResultForm.module.scss b/src/components/MatchResultForm/MatchResultForm.module.scss index 42f25ff8..78879b20 100644 --- a/src/components/MatchResultForm/MatchResultForm.module.scss +++ b/src/components/MatchResultForm/MatchResultForm.module.scss @@ -21,7 +21,6 @@ &_CoreFields { display: grid; grid-template-areas: - "player0Header player1Header" "player0Fields player1Fields" "matchResultDetails matchResultDetails"; grid-template-columns: 1fr 1fr; @@ -35,20 +34,10 @@ margin-top: 1rem; } - &_Player0Header { - grid-area: player0Header; - text-align: center; - } - &_Player0Fields { grid-area: player0Fields; } - &_Player1Header { - grid-area: player1Header; - text-align: center; - } - &_Player1Fields { grid-area: player1Fields; } diff --git a/src/components/MatchResultForm/MatchResultForm.schema.ts b/src/components/MatchResultForm/MatchResultForm.schema.ts index 52966aa8..4e5745ea 100644 --- a/src/components/MatchResultForm/MatchResultForm.schema.ts +++ b/src/components/MatchResultForm/MatchResultForm.schema.ts @@ -18,9 +18,9 @@ export const createSchema = (gameSystem: GameSystem) => { return z.object({ tournamentPairingId: z.union([z.null().transform(() => undefined), z.string().transform((val) => val as TournamentPairingId)]), player0Placeholder: z.optional(z.string()), - player0UserId: z.optional(z.string().transform((val) => val.length ? val as UserId : undefined)), + player0UserId: z.union([z.null().transform(() => undefined), z.undefined(), z.string().transform((val) => val.length ? val as UserId : undefined)]), player1Placeholder: z.optional(z.string()), - player1UserId: z.optional(z.string().transform((val) => val.length ? val as UserId : undefined)), + player1UserId: z.union([z.null().transform(() => undefined), z.undefined(), z.string().transform((val) => val.length ? val as UserId : undefined)]), // Non-editable gameSystem: z.nativeEnum(GameSystem), @@ -28,6 +28,16 @@ export const createSchema = (gameSystem: GameSystem) => { }).extend({ details: matchResultDetails.schema, gameSystemConfig: gameSystemConfig.schema, + }).superRefine((data, ctx) => { + ([0, 1] as const).forEach((i) => { + if (!data[`player${i}UserId`] && !data[`player${i}Placeholder`]) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Required', + path: [`player${i}UserId`], + }); + } + }); }); }; diff --git a/src/components/MatchResultForm/MatchResultForm.tsx b/src/components/MatchResultForm/MatchResultForm.tsx index 3aa15311..c7359dc8 100644 --- a/src/components/MatchResultForm/MatchResultForm.tsx +++ b/src/components/MatchResultForm/MatchResultForm.tsx @@ -11,6 +11,7 @@ import { getGameSystemOptions, } from '@ianpaschal/combat-command-game-systems/common'; import clsx from 'clsx'; +import { merge } from 'lodash'; import { MatchResult } from '~/api'; import { useAuth } from '~/components/AuthProvider'; @@ -61,11 +62,7 @@ export const MatchResultForm = ({ } = useGetActiveTournamentPairingsByUser(currentUser ? { userId: currentUser._id } : 'skip'); const form = useForm({ - defaultValues: { - ...defaultValues, - ...existingValues, - ...forcedValues, - }, + defaultValues: merge({}, defaultValues, existingValues, forcedValues), mode: 'onSubmit', }); @@ -94,24 +91,19 @@ export const MatchResultForm = ({ throw new Error('Cannot edit match results while not authenticated!'); } const tournamentPairing = (tournamentPairings ?? []).find((r) => r._id === value); - form.reset({ - ...defaultValues, - ...existingValues, - ...forcedValues, - ...(tournamentPairing ? { - player0Placeholder: tournamentPairing.tournamentCompetitor0 === null ? 'Bye' : '', - player0UserId: tournamentPairing.tournamentCompetitor0?.registrations[0].userId ?? currentUser?._id ?? null, - player1Placeholder: tournamentPairing.tournamentCompetitor1 === null ? 'Bye' : '', - player1UserId: tournamentPairing.tournamentCompetitor1?.registrations[0].userId ?? null, - tournamentPairingId: tournamentPairing._id, - } : { - player0Placeholder: '', - player0UserId: currentUser?._id ?? null, - player1Placeholder: '', - player1UserId: null, - tournamentPairingId: null, - }), - }); + form.reset(merge({}, defaultValues, existingValues, forcedValues, tournamentPairing ? { + player0Placeholder: tournamentPairing.tournamentCompetitor0 === null ? 'Bye' : '', + player0UserId: tournamentPairing.tournamentCompetitor0?.registrations[0].userId ?? currentUser?._id ?? null, + player1Placeholder: tournamentPairing.tournamentCompetitor1 === null ? 'Bye' : '', + player1UserId: tournamentPairing.tournamentCompetitor1?.registrations[0].userId ?? null, + tournamentPairingId: tournamentPairing._id, + } : { + player0Placeholder: '', + player0UserId: currentUser?._id ?? null, + player1Placeholder: '', + player1UserId: null, + tournamentPairingId: null, + })); }; const gameSystemOptions = getGameSystemOptions(); @@ -129,10 +121,8 @@ export const MatchResultForm = ({ }; const handleSubmit: SubmitHandler = async (formData): Promise => { - const validFormData = validateForm(createSchema(formData.gameSystem), { - ...formData, - ...forcedValues, - }, form.setError); + const submitData = merge({}, formData, forcedValues); + const validFormData = validateForm(createSchema(submitData.gameSystem), submitData, form); if (validFormData) { const score = getGameSystem(validFormData.gameSystem).calculateMatchResultScore(validFormData.details); openConfirmDialog({ @@ -188,13 +178,11 @@ export const MatchResultForm = ({ )}
-

Player 1

-

Player 2

(); + const { watch, setValue, formState: { errors } } = useFormContext(); + const error = get(errors, `player${index}UserId`); const userId = watch(`player${index}UserId`) as UserId | null; const opponentUserId = watch(`player${index === 0 ? 1 : 0}UserId`) as UserId | undefined; @@ -101,49 +103,56 @@ export const PlayerField = ({ const isOwnSingleMatch = !tournamentPairing && currentUser?._id === userId; const readOnly = (competitorUserOptions.length === 1) || isBye || isOwnSingleMatch; + const fieldId = `player${index}UserId`; + return ( - - {...props} - closeOnChange - disablePadding - disableScroll - fullHeight - fullWidth - readOnly={readOnly} - maxHeight={isMobile ? '80dvh' : '50dvh'} - maxWidth={MOBILE_BREAKPOINT} - mobile={isMobile} - onChange={handleChange} - renderValue={(value) => ( - - )} - renderContent={({ onChange }) => ( -
- onChange({ userId })} - value={selectedUser} +
+ + + id={fieldId} + {...props} + closeOnChange + disablePadding + disableScroll + fullHeight + fullWidth + readOnly={readOnly} + maxHeight={isMobile ? '80dvh' : '50dvh'} + maxWidth={MOBILE_BREAKPOINT} + mobile={isMobile} + onChange={handleChange} + renderValue={(value) => ( + - {!tournamentCompetitor && ( -
- -
- )} -
- )} - /> + )} + renderContent={({ onChange }) => ( +
+ onChange({ userId })} + value={selectedUser} + /> + {!tournamentCompetitor && ( +
+ +
+ )} +
+ )} + /> + {error &&
{error.message as string}
} +
); }; diff --git a/src/components/MatchResultProvider/actions/useCreateAction.tsx b/src/components/MatchResultProvider/actions/useCreateAction.tsx index da61e3c0..8c061277 100644 --- a/src/components/MatchResultProvider/actions/useCreateAction.tsx +++ b/src/components/MatchResultProvider/actions/useCreateAction.tsx @@ -24,12 +24,8 @@ export const useCreateAction = ( diff --git a/src/components/MatchResultProvider/components/MatchResultPhotoForm/MatchResultPhotoForm.tsx b/src/components/MatchResultProvider/components/MatchResultPhotoForm/MatchResultPhotoForm.tsx index 13fbb5ce..72130a9d 100644 --- a/src/components/MatchResultProvider/components/MatchResultPhotoForm/MatchResultPhotoForm.tsx +++ b/src/components/MatchResultProvider/components/MatchResultPhotoForm/MatchResultPhotoForm.tsx @@ -40,7 +40,7 @@ export const MatchResultPhotoForm = ({ }, [isDirty, setDirty]); const handleSubmit: SubmitHandler = async (formData): Promise => { - const validFormData = validateForm(schema, formData, form.setError); + const validFormData = validateForm(schema, formData, form); if (validFormData) { onSubmit(validFormData); } diff --git a/src/components/TournamentCompetitorForm/TournamentCompetitorForm.tsx b/src/components/TournamentCompetitorForm/TournamentCompetitorForm.tsx index 1da2fcde..6f5eae54 100644 --- a/src/components/TournamentCompetitorForm/TournamentCompetitorForm.tsx +++ b/src/components/TournamentCompetitorForm/TournamentCompetitorForm.tsx @@ -10,6 +10,7 @@ import { Select, } from '@ianpaschal/combat-command-components'; import clsx from 'clsx'; +import { merge } from 'lodash'; import { Plus } from 'lucide-react'; import { Tournament, TournamentCompetitor } from '~/api'; @@ -86,11 +87,7 @@ export const TournamentCompetitorForm = ({ const schema = createSchema(tournament.useTeams, otherCompetitors); const form = useForm({ - defaultValues: { - ...defaultValues, - ...existingValues, - ...forcedValues, - }, + defaultValues: merge({}, defaultValues, existingValues, forcedValues), mode: 'onSubmit', }); @@ -120,10 +117,7 @@ export const TournamentCompetitorForm = ({ }; const handleSubmit: SubmitHandler = async (formData): Promise => { - const validFormData = validateForm(schema, { - ...formData, - ...forcedValues, - }, form.setError); + const validFormData = validateForm(schema, merge({}, formData, forcedValues), form); if (validFormData) { onSubmit(validFormData); } diff --git a/src/components/TournamentForm/TournamentForm.tsx b/src/components/TournamentForm/TournamentForm.tsx index 300cf075..c577bba8 100644 --- a/src/components/TournamentForm/TournamentForm.tsx +++ b/src/components/TournamentForm/TournamentForm.tsx @@ -1,9 +1,4 @@ -import { - FieldValues, - SubmitHandler, - useForm, - UseFormSetError, -} from 'react-hook-form'; +import { SubmitHandler, useForm } from 'react-hook-form'; import { Card, Select } from '@ianpaschal/combat-command-components'; import { getGameSystemOptions } from '@ianpaschal/combat-command-game-systems/common'; import clsx from 'clsx'; @@ -64,7 +59,7 @@ export const TournamentForm = ({ const onSubmit: SubmitHandler = async (formData) => { const data = validateForm(createSchema({ competitorCount: tournamentCompetitors?.length ?? 0, - }), formData, form.setError as UseFormSetError); + }), formData, form); if (data) { handleSubmit({ ...data, diff --git a/src/components/TournamentPairingConfigForm/TournamentPairingConfigForm.tsx b/src/components/TournamentPairingConfigForm/TournamentPairingConfigForm.tsx index 14317b28..84fd9b86 100644 --- a/src/components/TournamentPairingConfigForm/TournamentPairingConfigForm.tsx +++ b/src/components/TournamentPairingConfigForm/TournamentPairingConfigForm.tsx @@ -5,6 +5,7 @@ import { tournamentPairingConfig, tournamentPairingConfigSchema, } from '@ianpaschal/combat-command-game-systems/common'; +import { merge } from 'lodash'; import { Form } from '~/components/generic/Form'; import { validateForm } from '~/utils/validateForm'; @@ -32,11 +33,7 @@ export const TournamentPairingConfigForm = ({ setDirty, }: TournamentPairingConfigFormProps): JSX.Element => { const form = useForm({ - defaultValues: { - ...tournamentPairingConfig.defaultValues, - ...existingValues, - ...forcedValues, - }, + defaultValues: merge({}, tournamentPairingConfig.defaultValues, existingValues, forcedValues), mode: 'onSubmit', }); @@ -46,7 +43,7 @@ export const TournamentPairingConfigForm = ({ }, [form.formState.isDirty, setDirty]); const handleSubmit: SubmitHandler = async (formData): Promise => { - const validFormData = validateForm(tournamentPairingConfigSchema, formData, form.setError); + const validFormData = validateForm(tournamentPairingConfigSchema, formData, form); if (validFormData) { onSubmit?.(validFormData); } diff --git a/src/components/TournamentRegistrationForm/TournamentRegistrationForm.tsx b/src/components/TournamentRegistrationForm/TournamentRegistrationForm.tsx index e39f4925..e1b8a998 100644 --- a/src/components/TournamentRegistrationForm/TournamentRegistrationForm.tsx +++ b/src/components/TournamentRegistrationForm/TournamentRegistrationForm.tsx @@ -11,6 +11,7 @@ import { Select, } from '@ianpaschal/combat-command-components'; import clsx from 'clsx'; +import { merge } from 'lodash'; import { Plus } from 'lucide-react'; import { @@ -65,11 +66,7 @@ export const TournamentRegistrationForm = ({ const currentUser = useAuth(); const schema = createSchema(tournament, currentUser); const form = useForm({ - defaultValues: { - ...defaultValues, - ...existingValues, - ...forcedValues, - }, + defaultValues: merge({}, defaultValues, existingValues, forcedValues), mode: 'onSubmit', }); @@ -133,7 +130,7 @@ export const TournamentRegistrationForm = ({ }, [teamName, setValue]); const handleSubmit: SubmitHandler = async (formData): Promise => { - const validFormData = validateForm(schema, formData, form.setError); + const validFormData = validateForm(schema, formData, form); if (validFormData) { onSubmit(validFormData); } diff --git a/src/components/generic/Form/Form.tsx b/src/components/generic/Form/Form.tsx index 7e89f2d2..047e74fb 100644 --- a/src/components/generic/Form/Form.tsx +++ b/src/components/generic/Form/Form.tsx @@ -63,6 +63,7 @@ export const Form = ({ const handleSubmit = async (e: BaseSyntheticEvent): Promise => { e.stopPropagation(); + form.clearErrors(); blockNavigation.current = false; await form.handleSubmit(onSubmit)(e); }; diff --git a/src/utils/validateForm.ts b/src/utils/validateForm.ts index 5d40656c..572551e4 100644 --- a/src/utils/validateForm.ts +++ b/src/utils/validateForm.ts @@ -1,22 +1,23 @@ -import { - FieldValues, - Path, - UseFormSetError, -} from 'react-hook-form'; +import { FieldPath, FieldValues } from 'react-hook-form'; import { ZodTypeAny } from 'zod'; import { toast } from '~/components/ToastProvider'; -export const validateForm = ( - schema: T, +interface FormHandle { + setError(name: FieldPath, error: { message: string }): void; + clearErrors(): void; +} + +export const validateForm = ( + schema: TSchema, formData: unknown, - setError: UseFormSetError, -): ReturnType | void => { + form: FormHandle, +): ReturnType | void => { + form.clearErrors(); const result = schema.safeParse(formData); if (!result.success) { result.error.issues.forEach((issue) => { - const fieldPath = issue.path.join('.') as Path; - setError(fieldPath, { message: issue.message }); + form.setError(issue.path.join('.') as FieldPath, { message: issue.message }); toast.error('Error', { description: 'Please check the form for errors.' }); }); }