Skip to content

Commit 5ac0d36

Browse files
committed
Enhance error handling in StrongForm and useFormToaster with user-facing error mapping
1 parent cd15356 commit 5ac0d36

File tree

2 files changed

+250
-104
lines changed

2 files changed

+250
-104
lines changed

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

Lines changed: 221 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,105 @@ import {
44
ParseError,
55
RequestBodyParseError,
66
RequestQueryParamsParseError,
7-
} from "../../../../../packages/thunderstore-api/src";
7+
UserFacingError,
8+
mapApiErrorToUserFacingError,
9+
} from "@thunderstore/thunderstore-api";
810

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

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

49-
useEffect(() => {
50-
if (refining || submitting) {
51-
return;
130+
const ensureSubmissionDataShape = (value: Inputs): SubmissionDataShape => {
131+
if (
132+
value === null ||
133+
(typeof value !== "object" && typeof value !== "function")
134+
) {
135+
throw new Error(
136+
"useStrongForm received primitive form inputs without a refiner; provide a refiner or ensure the input type matches the submission data shape."
137+
);
138+
}
139+
140+
return value as SubmissionDataShape;
141+
};
142+
143+
const defaultErrorMapper = (error: unknown): UserFacingError => {
144+
if (error instanceof UserFacingError) {
145+
return error;
52146
}
147+
return mapApiErrorToUserFacingError(error);
148+
};
149+
150+
const mapError = props.errorMapper ?? defaultErrorMapper;
151+
152+
useEffect(() => {
153+
let cancelled = false;
154+
53155
setSubmitOutput(undefined);
54156
setSubmitError(undefined);
55157
setInputErrors(undefined);
56-
if (props.refiner) {
57-
setSubmissionData(undefined);
158+
159+
if (!props.refiner) {
160+
setSubmissionData(ensureSubmissionDataShape(props.inputs));
161+
setRefining(false);
58162
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-
}
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) {
74199
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);
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);
79219
}
80-
}, [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+
};
81233

82-
const submit = async () => {
234+
const submit = async (): Promise<SubmissionOutput> => {
83235
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;
236+
return emitSubmissionError(
237+
createGuardSubmissionError("Form is already submitting.")
238+
);
89239
}
240+
90241
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;
242+
return emitSubmissionError(
243+
createGuardSubmissionError("Form is still refining.")
244+
);
96245
}
246+
97247
if (refineError) {
98-
const error = new Error("Form refinement failed!");
99-
if (props.onSubmitError) {
100-
props.onSubmitError(error as SubmissionError);
101-
}
102-
throw refineError;
248+
return emitSubmissionError(toSubmissionError(refineError));
103249
}
250+
104251
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;
252+
return emitSubmissionError(
253+
createGuardSubmissionError("Form has not been refined yet.")
254+
);
110255
}
111256

112257
setSubmitting(true);
258+
setSubmitError(undefined);
259+
setInputErrors(undefined);
260+
113261
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;
262+
const output = await props.submitor(submissionData);
263+
setSubmitOutput(output);
264+
if (props.onSubmitSuccess) {
265+
props.onSubmitSuccess(output);
266+
}
267+
return output;
150268
} catch (error) {
151-
if (props.onSubmitError) {
152-
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);
153275
}
154-
throw error;
276+
277+
const mappedError = toSubmissionError(error);
278+
return emitSubmissionError(mappedError);
155279
} finally {
156280
setSubmitting(false);
157281
}

0 commit comments

Comments
 (0)