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