Skip to content

Commit cd40580

Browse files
committed
Enhance error handling in StrongForm and useFormToaster with user-facing error mapping
1 parent 3b981c5 commit cd40580

File tree

2 files changed

+251
-104
lines changed

2 files changed

+251
-104
lines changed

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

Lines changed: 222 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,105 @@ import {
33
ParseError,
44
RequestBodyParseError,
55
RequestQueryParamsParseError,
6-
} from "../../../../../packages/thunderstore-api/src";
6+
UserFacingError,
7+
mapApiErrorToUserFacingError,
8+
} from "@thunderstore/thunderstore-api";
79

8-
interface UseStrongFormProps<
9-
Inputs,
10+
type IsExact<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B
11+
? 1
12+
: 2
13+
? (<T>() => T extends B ? 1 : 2) extends <T>() => T extends A ? 1 : 2
14+
? true
15+
: false
16+
: false;
17+
18+
type RefinerRequirement<Inputs, SubmissionDataShape extends Inputs> = [
1019
SubmissionDataShape,
11-
RefinerError,
12-
SubmissionOutput,
20+
] extends [Inputs]
21+
? {
22+
refiner?: (inputs: Inputs) => Promise<SubmissionDataShape>;
23+
}
24+
: {
25+
refiner: (inputs: Inputs) => Promise<SubmissionDataShape>;
26+
};
27+
28+
type ErrorMapperRequirement<SubmissionError extends UserFacingError> = IsExact<
1329
SubmissionError,
30+
UserFacingError
31+
> extends true
32+
? {
33+
errorMapper?: (error: unknown) => SubmissionError;
34+
}
35+
: {
36+
errorMapper: (error: unknown) => SubmissionError;
37+
};
38+
39+
interface UseStrongFormPropsBase<
40+
Inputs,
41+
SubmissionDataShape extends Inputs = Inputs,
42+
RefinerError extends Error = Error,
43+
SubmissionOutput = unknown,
44+
SubmissionError extends UserFacingError = UserFacingError,
1445
> {
1546
inputs: Inputs;
16-
refiner?: (inputs: Inputs) => Promise<SubmissionDataShape>;
17-
onRefineSuccess?: (output: SubmissionDataShape) => void;
18-
onRefineError?: (error: RefinerError) => void;
1947
submitor: (data: SubmissionDataShape) => Promise<SubmissionOutput>;
48+
onRefineSuccess?: (data: SubmissionDataShape) => void;
49+
onRefineError?: (error: RefinerError) => void;
2050
onSubmitSuccess?: (output: SubmissionOutput) => void;
2151
onSubmitError?: (error: SubmissionError) => void;
2252
}
2353

24-
export function useStrongForm<
54+
/**
55+
* Configuration for wiring a StrongForm instance to refiners, submitters and lifecycle hooks.
56+
*/
57+
export type UseStrongFormProps<
58+
Inputs,
59+
SubmissionDataShape extends Inputs = Inputs,
60+
RefinerError extends Error = Error,
61+
SubmissionOutput = unknown,
62+
SubmissionError extends UserFacingError = UserFacingError,
63+
> = UseStrongFormPropsBase<
2564
Inputs,
2665
SubmissionDataShape,
2766
RefinerError,
2867
SubmissionOutput,
29-
SubmissionError,
30-
InputErrors,
68+
SubmissionError
69+
> &
70+
RefinerRequirement<Inputs, SubmissionDataShape> &
71+
ErrorMapperRequirement<SubmissionError>;
72+
73+
/**
74+
* Return shape emitted by `useStrongForm`, exposing state for UI bindings.
75+
*/
76+
export interface UseStrongFormReturn<
77+
Inputs,
78+
SubmissionDataShape extends Inputs = Inputs,
79+
RefinerError extends Error = Error,
80+
SubmissionOutput = unknown,
81+
SubmissionError extends UserFacingError = UserFacingError,
82+
InputErrors = Record<string, unknown>,
83+
> {
84+
submit: () => Promise<SubmissionOutput>;
85+
submitting: boolean;
86+
submitOutput?: SubmissionOutput;
87+
submitError?: SubmissionError;
88+
submissionData?: SubmissionDataShape;
89+
refining: boolean;
90+
refineError?: RefinerError;
91+
inputErrors?: InputErrors;
92+
}
93+
94+
/**
95+
* React hook that orchestrates StrongForm refinement and submission flows while exposing
96+
* typed state for consumer components.
97+
*/
98+
export function useStrongForm<
99+
Inputs,
100+
SubmissionDataShape extends Inputs = Inputs,
101+
RefinerError extends Error = Error,
102+
SubmissionOutput = unknown,
103+
SubmissionError extends UserFacingError = UserFacingError,
104+
InputErrors = Record<string, unknown>,
31105
>(
32106
props: UseStrongFormProps<
33107
Inputs,
@@ -36,7 +110,14 @@ export function useStrongForm<
36110
SubmissionOutput,
37111
SubmissionError
38112
>
39-
) {
113+
): UseStrongFormReturn<
114+
Inputs,
115+
SubmissionDataShape,
116+
RefinerError,
117+
SubmissionOutput,
118+
SubmissionError,
119+
InputErrors
120+
> {
40121
const [refining, setRefining] = useState(false);
41122
const [submissionData, setSubmissionData] = useState<SubmissionDataShape>();
42123
const [refineError, setRefineError] = useState<RefinerError>();
@@ -45,112 +126,156 @@ export function useStrongForm<
45126
const [submitError, setSubmitError] = useState<SubmissionError>();
46127
const [inputErrors, setInputErrors] = useState<InputErrors>();
47128

48-
useEffect(() => {
49-
if (refining || submitting) {
50-
return;
129+
const ensureSubmissionDataShape = (value: Inputs): SubmissionDataShape => {
130+
if (
131+
value === null ||
132+
(typeof value !== "object" && typeof value !== "function")
133+
) {
134+
throw new Error(
135+
"useStrongForm received primitive form inputs without a refiner; provide a refiner or ensure the input type matches the submission data shape."
136+
);
137+
}
138+
139+
return value as SubmissionDataShape;
140+
};
141+
142+
const defaultErrorMapper = (error: unknown): UserFacingError => {
143+
if (error instanceof UserFacingError) {
144+
return error;
51145
}
146+
return mapApiErrorToUserFacingError(error);
147+
};
148+
149+
const mapError: (error: unknown) => SubmissionError =
150+
props.errorMapper ?? defaultErrorMapper;
151+
152+
useEffect(() => {
153+
let cancelled = false;
154+
52155
setSubmitOutput(undefined);
53156
setSubmitError(undefined);
54157
setInputErrors(undefined);
55-
if (props.refiner) {
56-
setSubmissionData(undefined);
158+
159+
if (!props.refiner) {
160+
setSubmissionData(ensureSubmissionDataShape(props.inputs));
161+
setRefining(false);
57162
setRefineError(undefined);
58-
setRefining(true);
59-
props
60-
.refiner(props.inputs)
61-
.then((refiningOutput) => {
62-
if (props.onRefineSuccess) {
63-
props.onRefineSuccess(refiningOutput);
64-
}
65-
setSubmissionData(refiningOutput);
66-
setRefining(false);
67-
})
68-
.catch((error) => {
69-
setRefineError(error);
70-
if (props.onRefineError) {
71-
props.onRefineError(error);
72-
}
163+
return () => {
164+
cancelled = true;
165+
};
166+
}
167+
168+
setSubmissionData(undefined);
169+
setRefineError(undefined);
170+
setRefining(true);
171+
172+
props
173+
.refiner(props.inputs)
174+
.then((result) => {
175+
if (cancelled) {
176+
return;
177+
}
178+
179+
setSubmissionData(result);
180+
if (props.onRefineSuccess) {
181+
props.onRefineSuccess(result);
182+
}
183+
})
184+
.catch((error) => {
185+
if (cancelled) {
186+
return;
187+
}
188+
189+
const normalizedError =
190+
error instanceof Error ? error : new Error(String(error));
191+
const castError = normalizedError as RefinerError;
192+
setRefineError(castError);
193+
if (props.onRefineError) {
194+
props.onRefineError(castError);
195+
}
196+
})
197+
.finally(() => {
198+
if (!cancelled) {
73199
setRefining(false);
74-
});
75-
} else {
76-
// A quick hack to allow the form to work without a refiner.
77-
setSubmissionData(props.inputs as unknown as SubmissionDataShape);
200+
}
201+
});
202+
203+
return () => {
204+
cancelled = true;
205+
};
206+
}, [props.inputs, props.refiner, props.onRefineSuccess, props.onRefineError]);
207+
208+
const toSubmissionError = (error: unknown): SubmissionError => {
209+
if (error instanceof UserFacingError) {
210+
return error as SubmissionError;
211+
}
212+
return mapError(error);
213+
};
214+
215+
const emitSubmissionError = (error: SubmissionError): never => {
216+
setSubmitError(error);
217+
if (props.onSubmitError) {
218+
props.onSubmitError(error);
78219
}
79-
}, [props.inputs]);
220+
throw error;
221+
};
222+
223+
const createGuardSubmissionError = (message: string): SubmissionError => {
224+
return toSubmissionError(
225+
new UserFacingError({
226+
category: "validation",
227+
headline: message,
228+
description: undefined,
229+
originalError: new Error(message),
230+
})
231+
);
232+
};
80233

81-
const submit = async () => {
234+
const submit = async (): Promise<SubmissionOutput> => {
82235
if (submitting) {
83-
const error = new Error("Form is already submitting!");
84-
if (props.onSubmitError) {
85-
props.onSubmitError(error as SubmissionError);
86-
}
87-
throw error;
236+
return emitSubmissionError(
237+
createGuardSubmissionError("Form is already submitting.")
238+
);
88239
}
240+
89241
if (refining) {
90-
const error = new Error("Form is still refining!");
91-
if (props.onSubmitError) {
92-
props.onSubmitError(error as SubmissionError);
93-
}
94-
throw error;
242+
return emitSubmissionError(
243+
createGuardSubmissionError("Form is still refining.")
244+
);
95245
}
246+
96247
if (refineError) {
97-
const error = new Error("Form refinement failed!");
98-
if (props.onSubmitError) {
99-
props.onSubmitError(error as SubmissionError);
100-
}
101-
throw refineError;
248+
return emitSubmissionError(toSubmissionError(refineError));
102249
}
250+
103251
if (!submissionData) {
104-
const error = new Error("Form has not been refined yet!");
105-
if (props.onSubmitError) {
106-
props.onSubmitError(error as SubmissionError);
107-
}
108-
throw error;
252+
return emitSubmissionError(
253+
createGuardSubmissionError("Form has not been refined yet.")
254+
);
109255
}
110256

111257
setSubmitting(true);
258+
setSubmitError(undefined);
259+
setInputErrors(undefined);
260+
112261
try {
113-
await props
114-
.submitor(submissionData)
115-
.then((output) => {
116-
setSubmitOutput(output);
117-
if (props.onSubmitSuccess) {
118-
props.onSubmitSuccess(output);
119-
}
120-
})
121-
.catch((error) => {
122-
if (error instanceof RequestBodyParseError) {
123-
setSubmitError(
124-
new Error(
125-
"Some of the field values are invalid"
126-
) as SubmissionError
127-
);
128-
setInputErrors(error.error.formErrors as InputErrors);
129-
} else if (error instanceof RequestQueryParamsParseError) {
130-
setSubmitError(
131-
new Error(
132-
"Some of the query parameters are invalid"
133-
) as SubmissionError
134-
);
135-
setInputErrors(error.error.formErrors as InputErrors);
136-
} else if (error instanceof ParseError) {
137-
setSubmitError(
138-
new Error(
139-
"Request succeeded, but the response was invalid"
140-
) as SubmissionError
141-
);
142-
setInputErrors(error.error.formErrors as InputErrors);
143-
throw error;
144-
} else {
145-
throw error;
146-
}
147-
});
148-
return submitOutput;
262+
const output = await props.submitor(submissionData);
263+
setSubmitOutput(output);
264+
if (props.onSubmitSuccess) {
265+
props.onSubmitSuccess(output);
266+
}
267+
return output;
149268
} catch (error) {
150-
if (props.onSubmitError) {
151-
props.onSubmitError(error as SubmissionError);
269+
if (error instanceof RequestBodyParseError) {
270+
setInputErrors(error.error.formErrors.fieldErrors as InputErrors);
271+
} else if (error instanceof RequestQueryParamsParseError) {
272+
setInputErrors(error.error.formErrors.fieldErrors as InputErrors);
273+
} else if (error instanceof ParseError) {
274+
setInputErrors(error.error.formErrors.fieldErrors as InputErrors);
152275
}
153-
throw error;
276+
277+
const mappedError = toSubmissionError(error);
278+
return emitSubmissionError(mappedError);
154279
} finally {
155280
setSubmitting(false);
156281
}

0 commit comments

Comments
 (0)