Skip to content

Commit 37c825b

Browse files
committed
feat: add debounced submission and field errors to products form
1 parent d3e3af0 commit 37c825b

File tree

1 file changed

+147
-66
lines changed

1 file changed

+147
-66
lines changed

zod/app/routes/products.tsx

Lines changed: 147 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { LoaderArgs } from "@remix-run/node";
2+
import type { FormEventHandler } from "react";
3+
import { useRef } from "react";
24
import { json, redirect } from "@remix-run/node";
35
import {
46
Form,
@@ -14,13 +16,26 @@ import { mockProducts } from "~/lib/product.server";
1416
export const loader = ({ request }: LoaderArgs) => {
1517
const { searchParams } = new URL(request.url);
1618

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+
);
2439

2540
// filter out empty string values from query params
2641
// otherwise zod will throw while coercing them to number
@@ -29,8 +44,18 @@ export const loader = ({ request }: LoaderArgs) => {
2944
);
3045

3146
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+
});
3459
}
3560

3661
const { name, minPrice, maxPrice, page, size } = parseResult.data;
@@ -87,6 +112,7 @@ export const loader = ({ request }: LoaderArgs) => {
87112
page: pagination.page,
88113
size: pagination.size,
89114
},
115+
fieldErrors: null,
90116
totalPageCount,
91117
},
92118
{
@@ -97,39 +123,83 @@ export const loader = ({ request }: LoaderArgs) => {
97123
);
98124
};
99125

126+
const errorTextStyle: React.CSSProperties = {
127+
fontWeight: "bold",
128+
color: "red",
129+
marginInline: 0,
130+
marginBlock: "0.25rem",
131+
};
132+
100133
export default function ProductsView() {
101134
const loaderData = useLoaderData<typeof loader>();
102135
const submit = useSubmit(); // used for select onChange
103136
const navigation = useNavigation();
104137
const isLoading = navigation.state === "loading";
105138

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+
106155
return (
107156
<div>
108157
<h1>Products</h1>
109158

110-
<Form method="get">
159+
<Form method="get" ref={formRef} onChange={onChangeHandler}>
111160
{/* 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>
133203
<button type="submit" disabled={isLoading}>
134204
Search
135205
</button>
@@ -152,41 +222,52 @@ export default function ProductsView() {
152222
</ul>
153223
<hr />
154224
{/* 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>
190271
</Form>
191272
<hr />
192273
<Link to="/" prefetch="intent">

0 commit comments

Comments
 (0)