diff --git a/fluxer_app/src/features/app/hooks/useForm.ts b/fluxer_app/src/features/app/hooks/useForm.ts index a048cbec6..163698a48 100644 --- a/fluxer_app/src/features/app/hooks/useForm.ts +++ b/fluxer_app/src/features/app/hooks/useForm.ts @@ -1,82 +1,121 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {FormEvent} from 'react'; -import {useCallback, useState} from 'react'; +import {useCallback, useLayoutEffect, useState} from 'react'; interface FormField { value: string; error?: string; } -interface FormState { - [key: string]: FormField; +type FormState = ReadonlyMap; + +class FormSubmissionOwner {} + +interface FormLifecycle { + activeSubmission: FormSubmissionOwner | null; + mounted: boolean; +} + +export interface FormSubmission { + getValue: (fieldName: string) => string; + isCurrent: () => boolean; } interface UseFormOptions { - initialValues?: Record; - onSubmit: (values: Record) => Promise; + initialValues: Record; + onSubmit: (submission: FormSubmission) => Promise; } export interface UseFormReturn { setValue: (fieldName: string, value: string) => void; setError: (fieldName: string, error: string) => void; - setErrors: (errors: Record) => void; + setErrors: (errors: ReadonlyMap) => void; getValue: (fieldName: string) => string; getError: (fieldName: string) => string | undefined; - handleSubmit: (e?: FormEvent) => Promise; + handleSubmit: (event?: FormEvent) => Promise; isSubmitting: boolean; } -export function useForm({initialValues = {}, onSubmit}: UseFormOptions): UseFormReturn { - const [fields, setFields] = useState(() => { - const initial: FormState = {}; - for (const [key, value] of Object.entries(initialValues)) { - initial[key] = {value}; - } - return initial; - }); +function createFormState(initialValues: Record): FormState { + const fields = new Map(); + for (const [fieldName, value] of Object.entries(initialValues)) { + fields.set(fieldName, {value}); + } + return fields; +} + +function withFieldError(fields: FormState, fieldName: string, error: string): FormField { + const field = fields.get(fieldName); + return {value: field?.value ?? '', error}; +} + +function resolveFormValues(fields: FormState): ReadonlyMap { + const values = new Map(); + for (const [fieldName, field] of fields) { + values.set(fieldName, field.value); + } + return values; +} + +function isFormSubmissionCurrent(lifecycle: FormLifecycle, submission: FormSubmissionOwner): boolean { + return lifecycle.mounted && lifecycle.activeSubmission === submission; +} + +export function useForm({initialValues, onSubmit}: UseFormOptions): UseFormReturn { + const [fields, setFields] = useState(() => createFormState(initialValues)); const [isSubmitting, setIsSubmitting] = useState(false); + const [lifecycle] = useState(() => ({activeSubmission: null, mounted: false})); + useLayoutEffect(() => { + lifecycle.mounted = true; + return () => { + lifecycle.mounted = false; + lifecycle.activeSubmission = null; + }; + }, [lifecycle]); const setValue = useCallback((fieldName: string, value: string) => { - setFields((prev) => ({ - ...prev, - [fieldName]: {...prev[fieldName], value, error: undefined}, - })); + setFields((currentFields) => new Map(currentFields).set(fieldName, {value})); }, []); const setError = useCallback((fieldName: string, error: string) => { - setFields((prev) => ({ - ...prev, - [fieldName]: {...prev[fieldName], error}, - })); + setFields((currentFields) => + new Map(currentFields).set(fieldName, withFieldError(currentFields, fieldName, error)), + ); }, []); - const setErrors = useCallback((errors: Record) => { - setFields((prev) => { - const updated = {...prev}; - for (const [fieldName, error] of Object.entries(errors)) { - updated[fieldName] = {...updated[fieldName], error}; + const setErrors = useCallback((errors: ReadonlyMap) => { + setFields((currentFields) => { + const updatedFields = new Map(currentFields); + for (const [fieldName, error] of errors) { + updatedFields.set(fieldName, withFieldError(currentFields, fieldName, error)); } - return updated; + return updatedFields; }); }, []); - const getValue = useCallback((fieldName: string): string => fields[fieldName]?.value || '', [fields]); - const getError = useCallback((fieldName: string): string | undefined => fields[fieldName]?.error, [fields]); - const getValues = useCallback((): Record => { - const values: Record = {}; - for (const [key, field] of Object.entries(fields)) { - values[key] = field.value; - } - return values; - }, [fields]); + const getValue = useCallback((fieldName: string): string => fields.get(fieldName)?.value ?? '', [fields]); + const getError = useCallback((fieldName: string): string | undefined => fields.get(fieldName)?.error, [fields]); const handleSubmit = useCallback( - async (e?: FormEvent) => { - e?.preventDefault(); + async (event?: FormEvent) => { + event?.preventDefault(); + if (lifecycle.activeSubmission != null) { + return; + } + const submissionOwner = new FormSubmissionOwner(); + lifecycle.activeSubmission = submissionOwner; setIsSubmitting(true); + const submittedValues = resolveFormValues(fields); + const submission: FormSubmission = { + getValue: (fieldName) => submittedValues.get(fieldName) ?? '', + isCurrent: () => isFormSubmissionCurrent(lifecycle, submissionOwner), + }; try { - await onSubmit(getValues()); + await onSubmit(submission); } finally { - setIsSubmitting(false); + if (isFormSubmissionCurrent(lifecycle, submissionOwner)) { + lifecycle.activeSubmission = null; + setIsSubmitting(false); + } } }, - [onSubmit, getValues], + [fields, lifecycle, onSubmit], ); return { setValue, diff --git a/fluxer_app/src/features/auth/components/pages/EmailRevertPage.tsx b/fluxer_app/src/features/auth/components/pages/EmailRevertPage.tsx index 56b76ff22..b05018306 100644 --- a/fluxer_app/src/features/auth/components/pages/EmailRevertPage.tsx +++ b/fluxer_app/src/features/auth/components/pages/EmailRevertPage.tsx @@ -83,7 +83,7 @@ const EmailRevertPage = observer(function EmailRevertPage() { label={i18n._(NEW_PASSWORD_DESCRIPTOR)} value={form.getValue('password')} onChange={(value) => form.setValue('password', value)} - error={form.getError('password') || fieldErrors?.password} + error={form.getError('password') || fieldErrors?.get('password')} data-flx="auth.email-revert-page.form-field.set-value.password" /> { + onSubmit: async (submission) => { setError(null); try { - await AuthenticationCommands.forgotPassword(values.email); + await AuthenticationCommands.forgotPassword(submission.getValue('email')); + if (!submission.isCurrent()) { + return; + } setIsSuccess(true); - } catch (_err) { - form.setErrors({email: 'Failed to send reset link. Try again.'}); + } catch { + if (!submission.isCurrent()) { + return; + } + form.setError('email', 'Failed to send reset link. Try again.'); } }, }); diff --git a/fluxer_app/src/features/auth/components/pages/ResetPasswordPage.tsx b/fluxer_app/src/features/auth/components/pages/ResetPasswordPage.tsx index ef6c49d6c..57c21aad3 100644 --- a/fluxer_app/src/features/auth/components/pages/ResetPasswordPage.tsx +++ b/fluxer_app/src/features/auth/components/pages/ResetPasswordPage.tsx @@ -32,6 +32,24 @@ const CONFIRM_NEW_PASSWORD_DESCRIPTOR = msg({ type TokenStatus = 'validating' | 'valid' | 'invalid'; const API_RENDERED_FIELDS = new Set(['password']); + +function resolveBannerError( + error: string | null, + fieldErrors: ReadonlyMap | null | undefined, +): string | null { + if (error != null) { + return error; + } + if (fieldErrors == null) { + return null; + } + for (const [fieldName, message] of fieldErrors) { + if (!API_RENDERED_FIELDS.has(fieldName)) { + return message; + } + } + return null; +} const ResetPasswordPage = observer(function ResetPasswordPage() { const {i18n} = useLingui(); const passwordId = useId(); @@ -86,12 +104,7 @@ const ResetPasswordPage = observer(function ResetPasswordPage() { cancelled = true; }; }, [token]); - const unrenderedFieldErrors = fieldErrors - ? Object.entries(fieldErrors) - .filter(([field]) => !API_RENDERED_FIELDS.has(field)) - .map(([, message]) => message) - : []; - const bannerError = error ?? unrenderedFieldErrors[0] ?? null; + const bannerError = resolveBannerError(error, fieldErrors); if (tokenStatus === 'validating') { return ( <> @@ -144,7 +157,7 @@ const ResetPasswordPage = observer(function ResetPasswordPage() { label={i18n._(NEW_PASSWORD_DESCRIPTOR)} value={form.getValue('password')} onChange={(value) => form.setValue('password', value)} - error={form.getError('password') || fieldErrors?.password} + error={form.getError('password') || fieldErrors?.get('password')} data-flx="auth.reset-password-page.form-field.set-value.password" /> setDraftedFormValue('global_name', value)} - error={form.getError('global_name') || fieldErrors?.global_name} + error={form.getError('global_name') || fieldErrors?.get('global_name')} data-flx="auth.flow.auth-minimal-register-form-core.form-field.set-drafted-form-value.text" /> {collectDateOfBirth ? ( @@ -239,7 +239,7 @@ export const AuthMinimalRegisterFormCore = observer(function AuthMinimalRegister onMonthChange={handleMonthChange} onDayChange={handleDayChange} onYearChange={handleYearChange} - error={fieldErrors?.date_of_birth} + error={fieldErrors?.get('date_of_birth')} data-flx="auth.flow.auth-minimal-register-form-core.date-of-birth-field" /> ) : null} diff --git a/fluxer_app/src/features/auth/flow/AuthRegisterFormCore.tsx b/fluxer_app/src/features/auth/flow/AuthRegisterFormCore.tsx index 6027107e3..033f539af 100644 --- a/fluxer_app/src/features/auth/flow/AuthRegisterFormCore.tsx +++ b/fluxer_app/src/features/auth/flow/AuthRegisterFormCore.tsx @@ -331,7 +331,7 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({ label={i18n._(EMAIL_DESCRIPTOR)} value={form.getValue('email')} onChange={(value) => setDraftedFormValue('email', value)} - error={form.getError('email') || fieldErrors?.email} + error={form.getError('email') || fieldErrors?.get('email')} data-flx="auth.flow.auth-register-form-core.form-field.set-drafted-form-value.email" /> )} @@ -343,7 +343,7 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({ placeholder={i18n._(WHAT_SHOULD_PEOPLE_CALL_YOU_DESCRIPTOR)} value={form.getValue('global_name')} onChange={(value) => setDraftedFormValue('global_name', value)} - error={form.getError('global_name') || fieldErrors?.global_name} + error={form.getError('global_name') || fieldErrors?.get('global_name')} data-flx="auth.flow.auth-register-form-core.form-field.set-drafted-form-value.text" />
@@ -356,7 +356,7 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({ placeholder={i18n._(LEAVE_BLANK_FOR_A_RANDOM_USERNAME_DESCRIPTOR)} value={usernameValue} onChange={(value) => setDraftedFormValue('username', value)} - error={form.getError('username') || fieldErrors?.username} + error={form.getError('username') || fieldErrors?.get('username')} data-flx="auth.flow.auth-register-form-core.form-field.set-drafted-form-value.text--2" /> @@ -406,7 +406,7 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({ label={i18n._(PASSWORD_DESCRIPTOR)} value={form.getValue('password')} onChange={(value) => setDraftedFormValue('password', value)} - error={form.getError('password') || fieldErrors?.password} + error={form.getError('password') || fieldErrors?.get('password')} data-flx="auth.flow.auth-register-form-core.form-field.set-drafted-form-value.password" /> )} @@ -432,7 +432,7 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({ onMonthChange={handleMonthChange} onDayChange={handleDayChange} onYearChange={handleYearChange} - error={fieldErrors?.date_of_birth} + error={fieldErrors?.get('date_of_birth')} data-flx="auth.flow.auth-register-form-core.date-of-birth-field" /> ) : null} diff --git a/fluxer_app/src/features/auth/flow/MfaScreen.tsx b/fluxer_app/src/features/auth/flow/MfaScreen.tsx index ecf573d5d..d4ba604d2 100644 --- a/fluxer_app/src/features/auth/flow/MfaScreen.tsx +++ b/fluxer_app/src/features/auth/flow/MfaScreen.tsx @@ -89,7 +89,7 @@ const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps) label={i18n._(CODE_DESCRIPTOR)} value={form.getValue('code')} onChange={(value) => form.setValue('code', value)} - error={form.getError('code') || fieldErrors?.code} + error={form.getError('code') || fieldErrors?.get('code')} data-flx="auth.flow.mfa-screen.form-field.set-value.text" />