mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
fix(auth): guard stale submissions and rework form error mapping (#2358)
This commit is contained in:
1 parent
f703969e80
commit
25f4332b9e
43 files changed
+739
-683
No files matched your search
@@ -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<string, FormField>;
|
||||
|
||||
class FormSubmissionOwner {}
|
||||
|
||||
interface FormLifecycle {
|
||||
activeSubmission: FormSubmissionOwner | null;
|
||||
mounted: boolean;
|
||||
}
|
||||
|
||||
export interface FormSubmission {
|
||||
getValue: (fieldName: string) => string;
|
||||
isCurrent: () => boolean;
|
||||
}
|
||||
|
||||
interface UseFormOptions {
|
||||
initialValues?: Record<string, string>;
|
||||
onSubmit: (values: Record<string, string>) => Promise<void>;
|
||||
initialValues: Record<string, string>;
|
||||
onSubmit: (submission: FormSubmission) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UseFormReturn {
|
||||
setValue: (fieldName: string, value: string) => void;
|
||||
setError: (fieldName: string, error: string) => void;
|
||||
setErrors: (errors: Record<string, string>) => void;
|
||||
setErrors: (errors: ReadonlyMap<string, string>) => void;
|
||||
getValue: (fieldName: string) => string;
|
||||
getError: (fieldName: string) => string | undefined;
|
||||
handleSubmit: (e?: FormEvent) => Promise<void>;
|
||||
handleSubmit: (event?: FormEvent) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export function useForm({initialValues = {}, onSubmit}: UseFormOptions): UseFormReturn {
|
||||
const [fields, setFields] = useState<FormState>(() => {
|
||||
const initial: FormState = {};
|
||||
for (const [key, value] of Object.entries(initialValues)) {
|
||||
initial[key] = {value};
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
function createFormState(initialValues: Record<string, string>): FormState {
|
||||
const fields = new Map<string, FormField>();
|
||||
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<string, string> {
|
||||
const values = new Map<string, string>();
|
||||
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<FormState>(() => createFormState(initialValues));
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [lifecycle] = useState<FormLifecycle>(() => ({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<string, string>) => {
|
||||
setFields((prev) => {
|
||||
const updated = {...prev};
|
||||
for (const [fieldName, error] of Object.entries(errors)) {
|
||||
updated[fieldName] = {...updated[fieldName], error};
|
||||
const setErrors = useCallback((errors: ReadonlyMap<string, string>) => {
|
||||
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<string, string> => {
|
||||
const values: Record<string, string> = {};
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user