react-vite-demo
react and vite demo
git clone https://9o.is/git/react-vite-demo.git
commit 76c407aad1609b053e3d1410af21f7cfd6f9f221 parent c07e5a803be1dec9933ff010debb66a93db6b954 Author: Jul <jul@9o.is> Date: Wed, 14 Aug 2024 23:37:53 +0800 create form errors Diffstat:
| M | src/components/headless/Form.tsx | | | 67 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------- |
| M | src/components/page/EventsPage/EventsPage.tsx | | | 44 | ++++++++++++++++++++++++++++---------------- |
| M | src/components/page/EventsPage/useFilteredSHEvents.ts | | | 2 | +- |
3 files changed, 85 insertions(+), 28 deletions(-)
diff --git a/src/components/headless/Form.tsx b/src/components/headless/Form.tsx @@ -1,34 +1,79 @@ -import { ReactNode, useRef } from "react" +import { createContext, ReactNode, useContext, useRef, useState } from "react" -type FormProps = { - onSubmit: (data: FormDataMap) => void +type FormProps<T> = ({ children: ReactNode ['aria-label']?: string -} +}) & ({ + onSubmit?: (data: FormDataMap) => void + validator: undefined +} | { + onSubmit: (data: T) => void + validator: (data: FormDataMap) => T +}) export type FormDataMap = { [key: string]: FormDataEntryValue | undefined } -export function Form({ onSubmit, children, ...props }: FormProps) { +type FormContextProps = { + formError?: string +} + +const FormContext = createContext<FormContextProps | undefined>(undefined) + +export function Form<T>({ onSubmit, validator, children, ...props }: FormProps<T>) { const formRef = useRef<HTMLFormElement>(null) + const [formError, setFormError] = useState<string | undefined>() const internalOnSubmit = (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault() const formData = new FormData(formRef.current || undefined) - const result: FormDataMap = {} + const data: FormDataMap = {} for (const [k, v] of formData.entries()) { - result[k] = v + data[k] = v } - onSubmit(result) + if (validator) { + try { + onSubmit(validator(data)) + } catch (error) { + setFormError((error as Error).message) + } + return + } + + onSubmit?.(data) } return ( - <form onSubmit={internalOnSubmit} ref={formRef} {...props}> - {children} - </form> + <FormContext.Provider value={{ formError }}> + <form onSubmit={internalOnSubmit} ref={formRef} {...props}> + {children} + </form> + </FormContext.Provider> ) } + +type FormErrorProps = { + children: (error: string) => ReactNode +} + +function FormError({ children }: FormErrorProps) { + const { formError } = useFormContext() + + return ( + <div role="alert"> + {formError ? children(formError) : <> </>} + </div> + ) +} + +function useFormContext() { + const formContext = useContext(FormContext) + if (!formContext) throw new Error('useFormContext() must be used within a Form') + return formContext +} + +Form.Error = FormError diff --git a/src/components/page/EventsPage/EventsPage.tsx b/src/components/page/EventsPage/EventsPage.tsx @@ -1,7 +1,7 @@ import { Checkbox, ComboBox, Select, Progress, Alert, Form, FormDataMap, Button } from '../../headless' import { useNumberFormatter } from '../../hooks' import { useSHEvents } from './useSHEvents' -import { useFilteredSHEvents } from './useFilteredSHEvents' +import { FilteredSHEventsInput, useFilteredSHEvents } from './useFilteredSHEvents' import { invariant, isSortType } from '../../../lib' export function EventsPage() { @@ -15,26 +15,15 @@ export function EventsPage() { const cities = [...new Set(loadedEvents.map(({ city }) => city))] - const onSubmit = ({ city, priceSort, cheapest }: FormDataMap) => { - invariant(typeof city === 'string') - invariant(priceSort === '' || (typeof priceSort === 'string' && isSortType(priceSort))) - invariant(cheapest === undefined || cheapest === 'on') - - filterSHEvents({ - city, - priceSort: priceSort || undefined, - cheapest: cheapest === 'on', - }) - } - return ( <> <Alert error={error} /> - <Form onSubmit={onSubmit} aria-label='Filter Events'> + <Form aria-label='Filter Events' onSubmit={filterSHEvents} validator={validator}> <ComboBox label="City" name="city" options={cities} /> <Select label="Sort by price" name="priceSort" options={['ascending', 'descending']} /> <Checkbox label="Find Cheapest" name="cheapest" /> <Button type="submit" disabled={loading}>Search</Button> + <Form.Error>{(error) => error}</Form.Error> </Form> <Progress loading={loading}> <Progress.Bar> @@ -53,4 +42,28 @@ export function EventsPage() { </Progress> </> ) - } -\ No newline at end of file +} + + +function validator ({ city, priceSort, cheapest }: FormDataMap): FilteredSHEventsInput { + invariant( + typeof city === 'string' && city.length < 255, + "City is invalid" + ) + + invariant( + cheapest === undefined || cheapest === 'on', + "Find cheapest input is invalid" + ) + + invariant( + priceSort === '' || (typeof priceSort === 'string' && isSortType(priceSort)), + "Sort by price is invalid" + ) + + return { + city, + priceSort: priceSort || undefined, + cheapest: cheapest === 'on', + } +} diff --git a/src/components/page/EventsPage/useFilteredSHEvents.ts b/src/components/page/EventsPage/useFilteredSHEvents.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import { asArray, filterByProp, minByProp, pipe, sortByProp, SortType } from "../../../lib" import { SHEvent } from "./useSHEvents" -type FilteredSHEventsInput = { +export type FilteredSHEventsInput = { city?: string priceSort?: SortType cheapest?: boolean