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