@@ -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