1
1
import type { LoaderArgs } from "@remix-run/node" ;
2
+ import type { FormEventHandler } from "react" ;
3
+ import { useRef } from "react" ;
2
4
import { json , redirect } from "@remix-run/node" ;
3
5
import {
4
6
Form ,
@@ -14,13 +16,26 @@ import { mockProducts } from "~/lib/product.server";
14
16
export const loader = ( { request } : LoaderArgs ) => {
15
17
const { searchParams } = new URL ( request . url ) ;
16
18
17
- const schema = z . object ( {
18
- name : z . string ( ) . optional ( ) ,
19
- minPrice : z . coerce . number ( ) . min ( 1 ) . optional ( ) ,
20
- maxPrice : z . coerce . number ( ) . min ( 1 ) . optional ( ) ,
21
- page : z . coerce . number ( ) . min ( 1 ) . optional ( ) ,
22
- size : z . coerce . number ( ) . min ( 5 ) . max ( 10 ) . step ( 5 ) . optional ( ) ,
23
- } ) ;
19
+ const schema = z
20
+ . object ( {
21
+ name : z . string ( ) . optional ( ) ,
22
+ minPrice : z . coerce . number ( ) . gt ( 0 ) . optional ( ) ,
23
+ maxPrice : z . coerce . number ( ) . gt ( 0 ) . optional ( ) ,
24
+ page : z . coerce . number ( ) . min ( 1 ) . step ( 1 ) . optional ( ) ,
25
+ size : z . coerce . number ( ) . min ( 5 ) . max ( 10 ) . step ( 5 ) . optional ( ) ,
26
+ } )
27
+ . refine (
28
+ ( { minPrice, maxPrice } ) => {
29
+ if ( minPrice && maxPrice && minPrice > maxPrice ) {
30
+ return false ;
31
+ }
32
+ return true ;
33
+ } ,
34
+ {
35
+ message : "Max price cannot be less than min price" ,
36
+ path : [ "maxPrice" ] ,
37
+ } ,
38
+ ) ;
24
39
25
40
// filter out empty string values from query params
26
41
// otherwise zod will throw while coercing them to number
@@ -29,8 +44,18 @@ export const loader = ({ request }: LoaderArgs) => {
29
44
) ;
30
45
31
46
if ( ! parseResult . success ) {
32
- console . log ( parseResult . error ) ;
33
- throw new Error ( "Invalid query params" ) ;
47
+ return json ( {
48
+ products : [ ] ,
49
+ searchParams : {
50
+ name : searchParams . get ( "name" ) || "" ,
51
+ minPrice : searchParams . get ( "minPrice" ) || "" ,
52
+ maxPrice : searchParams . get ( "maxPrice" ) || "" ,
53
+ page : 1 ,
54
+ size : searchParams . get ( "size" ) === "10" ? 10 : 5 ,
55
+ } ,
56
+ fieldErrors : parseResult . error . flatten ( ) . fieldErrors ,
57
+ totalPageCount : 1 ,
58
+ } ) ;
34
59
}
35
60
36
61
const { name, minPrice, maxPrice, page, size } = parseResult . data ;
@@ -87,6 +112,7 @@ export const loader = ({ request }: LoaderArgs) => {
87
112
page : pagination . page ,
88
113
size : pagination . size ,
89
114
} ,
115
+ fieldErrors : null ,
90
116
totalPageCount,
91
117
} ,
92
118
{
@@ -97,39 +123,83 @@ export const loader = ({ request }: LoaderArgs) => {
97
123
) ;
98
124
} ;
99
125
126
+ const errorTextStyle : React . CSSProperties = {
127
+ fontWeight : "bold" ,
128
+ color : "red" ,
129
+ marginInline : 0 ,
130
+ marginBlock : "0.25rem" ,
131
+ } ;
132
+
100
133
export default function ProductsView ( ) {
101
134
const loaderData = useLoaderData < typeof loader > ( ) ;
102
135
const submit = useSubmit ( ) ; // used for select onChange
103
136
const navigation = useNavigation ( ) ;
104
137
const isLoading = navigation . state === "loading" ;
105
138
139
+ // Debounced onChange handler to submit the form after a delay
140
+ // Create a ref to hold the debounce timer
141
+ const debounceTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
142
+ const formRef = useRef < HTMLFormElement > ( null ) ;
143
+ const onChangeHandler : FormEventHandler < HTMLFormElement > = ( event ) => {
144
+ // On input change, clear the previous debounce timer first
145
+ if ( debounceTimerRef . current ) {
146
+ clearTimeout ( debounceTimerRef . current ) ;
147
+ }
148
+
149
+ // Set a new debounce timer to trigger submission after a delay
150
+ debounceTimerRef . current = setTimeout ( ( ) => {
151
+ submit ( formRef . current ) ;
152
+ } , 300 ) ; // Adjust the debounce delay as needed (in milliseconds)
153
+ } ;
154
+
106
155
return (
107
156
< div >
108
157
< h1 > Products</ h1 >
109
158
110
- < Form method = "get" >
159
+ < Form method = "get" ref = { formRef } onChange = { onChangeHandler } >
111
160
{ /* Filters */ }
112
- < label htmlFor = "name" > Product Name:</ label >
113
- < input
114
- type = "text"
115
- id = "name"
116
- name = "name"
117
- defaultValue = { loaderData . searchParams . name }
118
- /> { " " }
119
- < label htmlFor = "minPrice" > Min Price:</ label >
120
- < input
121
- type = "number"
122
- id = "minPrice"
123
- name = "minPrice"
124
- defaultValue = { loaderData . searchParams . minPrice }
125
- /> { " " }
126
- < label htmlFor = "maxPrice" > Max Price:</ label >
127
- < input
128
- type = "number"
129
- id = "maxPrice"
130
- name = "maxPrice"
131
- defaultValue = { loaderData . searchParams . maxPrice }
132
- /> { " " }
161
+ < div >
162
+ < label htmlFor = "name" > Product Name:</ label >
163
+ < input
164
+ type = "text"
165
+ id = "name"
166
+ name = "name"
167
+ defaultValue = { loaderData . searchParams . name }
168
+ />
169
+ { loaderData ?. fieldErrors ?. name ?. map ( ( error , index ) => (
170
+ < p style = { errorTextStyle } key = { `name-error-${ index } ` } >
171
+ { error }
172
+ </ p >
173
+ ) ) }
174
+ </ div >
175
+ < div >
176
+ < label htmlFor = "minPrice" > Min Price:</ label >
177
+ < input
178
+ type = "number"
179
+ id = "minPrice"
180
+ name = "minPrice"
181
+ defaultValue = { loaderData . searchParams . minPrice }
182
+ />
183
+ { loaderData ?. fieldErrors ?. minPrice ?. map ( ( error , index ) => (
184
+ < p style = { errorTextStyle } key = { `min-price-error-${ index } ` } >
185
+ { error }
186
+ </ p >
187
+ ) ) }
188
+ </ div >
189
+ < div >
190
+ < label htmlFor = "maxPrice" > Max Price:</ label >
191
+ < input
192
+ type = "number"
193
+ id = "maxPrice"
194
+ name = "maxPrice"
195
+ defaultValue = { loaderData . searchParams . maxPrice }
196
+ />
197
+ { loaderData ?. fieldErrors ?. maxPrice ?. map ( ( error , index ) => (
198
+ < p style = { errorTextStyle } key = { `max-price-error-${ index } ` } >
199
+ { error }
200
+ </ p >
201
+ ) ) }
202
+ </ div >
133
203
< button type = "submit" disabled = { isLoading } >
134
204
Search
135
205
</ button >
@@ -152,41 +222,52 @@ export default function ProductsView() {
152
222
</ ul >
153
223
< hr />
154
224
{ /* Pagination */ }
155
- < span >
156
- Page { loaderData . searchParams . page } of { loaderData . totalPageCount }
157
- </ span > { " " }
158
- < button
159
- type = "submit"
160
- name = "page"
161
- value = { loaderData . searchParams . page - 1 }
162
- disabled = { isLoading || loaderData . searchParams . page === 1 }
163
- >
164
- Prev
165
- </ button > { " " }
166
- < button
167
- type = "submit"
168
- name = "page"
169
- value = { loaderData . searchParams . page + 1 }
170
- disabled = {
171
- isLoading ||
172
- loaderData . searchParams . page === loaderData . totalPageCount
173
- }
174
- >
175
- Next
176
- </ button > { " " }
177
- < label htmlFor = "size" > Items per Page:</ label >
178
- < select
179
- id = "size"
180
- name = "size"
181
- defaultValue = { loaderData . searchParams . size }
182
- onChange = { ( event ) => {
183
- submit ( event . currentTarget . form ) ;
184
- } }
185
- disabled = { isLoading }
186
- >
187
- < option value = { 5 } > 5</ option >
188
- < option value = { 10 } > 10</ option >
189
- </ select >
225
+ < div >
226
+ < span >
227
+ Page { loaderData . searchParams . page } of { loaderData . totalPageCount }
228
+ </ span > { " " }
229
+ < button
230
+ type = "submit"
231
+ name = "page"
232
+ value = { loaderData . searchParams . page - 1 }
233
+ disabled = { isLoading || loaderData . searchParams . page === 1 }
234
+ >
235
+ Prev
236
+ </ button > { " " }
237
+ < button
238
+ type = "submit"
239
+ name = "page"
240
+ value = { loaderData . searchParams . page + 1 }
241
+ disabled = {
242
+ isLoading ||
243
+ loaderData . searchParams . page === loaderData . totalPageCount
244
+ }
245
+ >
246
+ Next
247
+ </ button >
248
+ { loaderData ?. fieldErrors ?. page ?. map ( ( error , index ) => (
249
+ < p style = { errorTextStyle } key = { `page-error-${ index } ` } >
250
+ { error }
251
+ </ p >
252
+ ) ) }
253
+ </ div >
254
+ < div >
255
+ < label htmlFor = "size" > Items per Page:</ label >
256
+ < select
257
+ id = "size"
258
+ name = "size"
259
+ defaultValue = { loaderData . searchParams . size }
260
+ disabled = { isLoading }
261
+ >
262
+ < option value = { 5 } > 5</ option >
263
+ < option value = { 10 } > 10</ option >
264
+ </ select >
265
+ { loaderData ?. fieldErrors ?. size ?. map ( ( error , index ) => (
266
+ < p style = { errorTextStyle } key = { `size-error-${ index } ` } >
267
+ { error }
268
+ </ p >
269
+ ) ) }
270
+ </ div >
190
271
</ Form >
191
272
< hr />
192
273
< Link to = "/" prefetch = "intent" >
0 commit comments