Skip to content

Commit 689944f

Browse files
committed
Enhance error handling in StrongForm and useFormToaster with user-facing error mapping
1 parent 4a25139 commit 689944f

File tree

2 files changed

+255
-103
lines changed

2 files changed

+255
-103
lines changed

apps/cyberstorm-remix/cyberstorm/utils/StrongForm/useStrongForm.ts

Lines changed: 226 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,117 @@ import {
44
ParseError,
55
RequestBodyParseError,
66
RequestQueryParamsParseError,
7+
UserFacingError,
8+
mapApiErrorToUserFacingError,
79
} from "@thunderstore/thunderstore-api";
810

9-
interface UseStrongFormProps<
10-
Inputs,
11+
/**
12+
* Checks if two types are exactly identical.
13+
* Returns `true` if A and B are strictly equal, `false` otherwise.
14+
* This is useful for distinguishing between types that are assignable to each other
15+
* (e.g. `string` and `string | number`) but not identical.
16+
*/
17+
type IsExact<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B
18+
? 1
19+
: 2
20+
? (<T>() => T extends B ? 1 : 2) extends <T>() => T extends A ? 1 : 2
21+
? true
22+
: false
23+
: false;
24+
25+
/**
26+
* Enforces the presence of a `refiner` prop when the submission data shape
27+
* differs from the input shape.
28+
*
29+
* If `SubmissionDataShape` is identical to or a subtype of `Inputs`, the refiner
30+
* is optional (defaults to identity/cast).
31+
* Otherwise, a refiner is required to transform inputs into the submission shape.
32+
*/
33+
type RefinerRequirement<Inputs, SubmissionDataShape extends Inputs> = [
1134
SubmissionDataShape,
12-
RefinerError,
13-
SubmissionOutput,
35+
] extends [Inputs]
36+
? {
37+
refiner?: (inputs: Inputs) => Promise<SubmissionDataShape>;
38+
}
39+
: {
40+
refiner: (inputs: Inputs) => Promise<SubmissionDataShape>;
41+
};
42+
43+
/**
44+
* Enforces the presence of an `errorMapper` prop when a custom `SubmissionError` type is used.
45+
*
46+
* If `SubmissionError` is exactly `UserFacingError` (the default), the mapper is optional
47+
* as `mapApiErrorToUserFacingError` is used by default.
48+
* If a different error type is specified, a mapper must be provided to convert unknown errors
49+
* into the expected `SubmissionError` type.
50+
*/
51+
type ErrorMapperRequirement<SubmissionError> = IsExact<
1452
SubmissionError,
53+
UserFacingError
54+
> extends true
55+
? {
56+
errorMapper?: (error: unknown) => SubmissionError;
57+
}
58+
: {
59+
errorMapper: (error: unknown) => SubmissionError;
60+
};
61+
62+
interface UseStrongFormPropsBase<
63+
Inputs,
64+
SubmissionDataShape extends Inputs = Inputs,
65+
RefinerError extends Error = Error,
66+
SubmissionOutput = unknown,
67+
SubmissionError = UserFacingError,
1568
> {
1669
inputs: Inputs;
17-
refiner?: (inputs: Inputs) => Promise<SubmissionDataShape>;
18-
onRefineSuccess?: (output: SubmissionDataShape) => void;
19-
onRefineError?: (error: RefinerError) => void;
2070
submitor: (data: SubmissionDataShape) => Promise<SubmissionOutput>;
71+
onRefineSuccess?: (data: SubmissionDataShape) => void;
72+
onRefineError?: (error: RefinerError) => void;
2173
onSubmitSuccess?: (output: SubmissionOutput) => void;
2274
onSubmitError?: (error: SubmissionError) => void;
2375
}
2476

25-
export function useStrongForm<
77+
export type UseStrongFormProps<
78+
Inputs,
79+
SubmissionDataShape extends Inputs = Inputs,
80+
RefinerError extends Error = Error,
81+
SubmissionOutput = unknown,
82+
SubmissionError = UserFacingError,
83+
> = UseStrongFormPropsBase<
2684
Inputs,
2785
SubmissionDataShape,
2886
RefinerError,
2987
SubmissionOutput,
30-
SubmissionError,
31-
InputErrors,
88+
SubmissionError
89+
> &
90+
RefinerRequirement<Inputs, SubmissionDataShape> &
91+
ErrorMapperRequirement<SubmissionError>;
92+
93+
export interface UseStrongFormReturn<
94+
Inputs,
95+
SubmissionDataShape extends Inputs = Inputs,
96+
RefinerError extends Error = Error,
97+
SubmissionOutput = unknown,
98+
SubmissionError = UserFacingError,
99+
InputErrors = Record<string, unknown>,
100+
> {
101+
submit: () => Promise<SubmissionOutput>;
102+
submitting: boolean;
103+
submitOutput?: SubmissionOutput;
104+
submitError?: SubmissionError;
105+
submissionData?: SubmissionDataShape;
106+
refining: boolean;
107+
refineError?: RefinerError;
108+
inputErrors?: InputErrors;
109+
}
110+
111+
export function useStrongForm<
112+
Inputs,
113+
SubmissionDataShape extends Inputs = Inputs,
114+
RefinerError extends Error = Error,
115+
SubmissionOutput = unknown,
116+
SubmissionError = UserFacingError,
117+
InputErrors = Record<string, unknown>,
32118
>(
33119
props: UseStrongFormProps<
34120
Inputs,
@@ -37,7 +123,14 @@ export function useStrongForm<
37123
SubmissionOutput,
38124
SubmissionError
39125
>
40-
) {
126+
): UseStrongFormReturn<
127+
Inputs,
128+
SubmissionDataShape,
129+
RefinerError,
130+
SubmissionOutput,
131+
SubmissionError,
132+
InputErrors
133+
> {
41134
const [refining, setRefining] = useState(false);
42135
const [submissionData, setSubmissionData] = useState<SubmissionDataShape>();
43136
const [refineError, setRefineError] = useState<RefinerError>();
@@ -46,112 +139,149 @@ export function useStrongForm<
46139
const [submitError, setSubmitError] = useState<SubmissionError>();
47140
const [inputErrors, setInputErrors] = useState<InputErrors>();
48141

49-
useEffect(() => {
50-
if (refining || submitting) {
51-
return;
142+
const ensureSubmissionDataShape = (value: Inputs): SubmissionDataShape => {
143+
if (
144+
value === null ||
145+
(typeof value !== "object" && typeof value !== "function")
146+
) {
147+
throw new Error(
148+
"useStrongForm received primitive form inputs without a refiner; provide a refiner or ensure the input type matches the submission data shape."
149+
);
52150
}
151+
152+
return value as SubmissionDataShape;
153+
};
154+
155+
useEffect(() => {
156+
let cancelled = false;
157+
53158
setSubmitOutput(undefined);
54159
setSubmitError(undefined);
55160
setInputErrors(undefined);
56-
if (props.refiner) {
57-
setSubmissionData(undefined);
161+
162+
if (!props.refiner) {
163+
setSubmissionData(ensureSubmissionDataShape(props.inputs));
164+
setRefining(false);
58165
setRefineError(undefined);
59-
setRefining(true);
60-
props
61-
.refiner(props.inputs)
62-
.then((refiningOutput) => {
63-
if (props.onRefineSuccess) {
64-
props.onRefineSuccess(refiningOutput);
65-
}
66-
setSubmissionData(refiningOutput);
67-
setRefining(false);
68-
})
69-
.catch((error) => {
70-
setRefineError(error);
71-
if (props.onRefineError) {
72-
props.onRefineError(error);
73-
}
166+
return () => {
167+
cancelled = true;
168+
};
169+
}
170+
171+
setSubmissionData(undefined);
172+
setRefineError(undefined);
173+
setRefining(true);
174+
175+
props
176+
.refiner(props.inputs)
177+
.then((result) => {
178+
if (cancelled) {
179+
return;
180+
}
181+
182+
setSubmissionData(result);
183+
if (props.onRefineSuccess) {
184+
props.onRefineSuccess(result);
185+
}
186+
})
187+
.catch((error) => {
188+
if (cancelled) {
189+
return;
190+
}
191+
192+
const normalizedError =
193+
error instanceof Error ? error : new Error(String(error));
194+
const castError = normalizedError as RefinerError;
195+
setRefineError(castError);
196+
if (props.onRefineError) {
197+
props.onRefineError(castError);
198+
}
199+
})
200+
.finally(() => {
201+
if (!cancelled) {
74202
setRefining(false);
75-
});
76-
} else {
77-
// A quick hack to allow the form to work without a refiner.
78-
setSubmissionData(props.inputs as unknown as SubmissionDataShape);
203+
}
204+
});
205+
206+
return () => {
207+
cancelled = true;
208+
};
209+
}, [props.inputs, props.refiner, props.onRefineSuccess, props.onRefineError]);
210+
211+
const toSubmissionError = (error: unknown): SubmissionError => {
212+
if (props.errorMapper) {
213+
return props.errorMapper(error);
214+
}
215+
216+
// If errorMapper is not provided, we assume SubmissionError is UserFacingError.
217+
// This is enforced by the ErrorMapperRequirement type.
218+
return mapApiErrorToUserFacingError(error) as unknown as SubmissionError;
219+
};
220+
221+
const emitSubmissionError = (error: SubmissionError): never => {
222+
setSubmitError(error);
223+
if (props.onSubmitError) {
224+
props.onSubmitError(error);
79225
}
80-
}, [props.inputs]);
226+
throw error;
227+
};
228+
229+
const createGuardSubmissionError = (message: string): SubmissionError => {
230+
return toSubmissionError(
231+
new UserFacingError({
232+
category: "validation",
233+
headline: message,
234+
description: undefined,
235+
originalError: new Error(message),
236+
})
237+
);
238+
};
81239

82-
const submit = async () => {
240+
const submit = async (): Promise<SubmissionOutput> => {
83241
if (submitting) {
84-
const error = new Error("Form is already submitting!");
85-
if (props.onSubmitError) {
86-
props.onSubmitError(error as SubmissionError);
87-
}
88-
throw error;
242+
return emitSubmissionError(
243+
createGuardSubmissionError("Form is already submitting.")
244+
);
89245
}
246+
90247
if (refining) {
91-
const error = new Error("Form is still refining!");
92-
if (props.onSubmitError) {
93-
props.onSubmitError(error as SubmissionError);
94-
}
95-
throw error;
248+
return emitSubmissionError(
249+
createGuardSubmissionError("Form is still refining.")
250+
);
96251
}
252+
97253
if (refineError) {
98-
const error = new Error("Form refinement failed!");
99-
if (props.onSubmitError) {
100-
props.onSubmitError(error as SubmissionError);
101-
}
102-
throw refineError;
254+
return emitSubmissionError(toSubmissionError(refineError));
103255
}
256+
104257
if (!submissionData) {
105-
const error = new Error("Form has not been refined yet!");
106-
if (props.onSubmitError) {
107-
props.onSubmitError(error as SubmissionError);
108-
}
109-
throw error;
258+
return emitSubmissionError(
259+
createGuardSubmissionError("Form has not been refined yet.")
260+
);
110261
}
111262

112263
setSubmitting(true);
264+
setSubmitError(undefined);
265+
setInputErrors(undefined);
266+
113267
try {
114-
await props
115-
.submitor(submissionData)
116-
.then((output) => {
117-
setSubmitOutput(output);
118-
if (props.onSubmitSuccess) {
119-
props.onSubmitSuccess(output);
120-
}
121-
})
122-
.catch((error) => {
123-
if (error instanceof RequestBodyParseError) {
124-
setSubmitError(
125-
new Error(
126-
"Some of the field values are invalid"
127-
) as SubmissionError
128-
);
129-
setInputErrors(error.error.formErrors as InputErrors);
130-
} else if (error instanceof RequestQueryParamsParseError) {
131-
setSubmitError(
132-
new Error(
133-
"Some of the query parameters are invalid"
134-
) as SubmissionError
135-
);
136-
setInputErrors(error.error.formErrors as InputErrors);
137-
} else if (error instanceof ParseError) {
138-
setSubmitError(
139-
new Error(
140-
"Request succeeded, but the response was invalid"
141-
) as SubmissionError
142-
);
143-
setInputErrors(error.error.formErrors as InputErrors);
144-
throw error;
145-
} else {
146-
throw error;
147-
}
148-
});
149-
return submitOutput;
268+
const output = await props.submitor(submissionData);
269+
setSubmitOutput(output);
270+
if (props.onSubmitSuccess) {
271+
props.onSubmitSuccess(output);
272+
}
273+
return output;
150274
} catch (error) {
151-
if (props.onSubmitError) {
152-
props.onSubmitError(error as SubmissionError);
275+
if (error instanceof RequestBodyParseError) {
276+
setInputErrors(error.error.formErrors.fieldErrors as InputErrors);
277+
} else if (error instanceof RequestQueryParamsParseError) {
278+
setInputErrors(error.error.formErrors.fieldErrors as InputErrors);
279+
} else if (error instanceof ParseError) {
280+
setInputErrors(error.error.formErrors.fieldErrors as InputErrors);
153281
}
154-
throw error;
282+
283+
const mappedError = toSubmissionError(error);
284+
return emitSubmissionError(mappedError);
155285
} finally {
156286
setSubmitting(false);
157287
}

0 commit comments

Comments
 (0)