Last active
June 3, 2022 09:08
-
-
Save marcmartino/9cb274692fe5fb29635079f7d3fd0dc8 to your computer and use it in GitHub Desktop.
Example io-ts react final form based off blitzjs boilerplate
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { TypeOf, Type, InputOf, OutputOf } from "io-ts" | |
import { matchW } from "fp-ts/Either" | |
import { reduce } from "fp-ts/Array" | |
import { flow } from "fp-ts/function" | |
import { ReactNode, PropsWithoutRef } from "react" | |
import { Form as FinalForm, FormProps as FinalFormProps } from "react-final-form" | |
export { FORM_ERROR } from "final-form" | |
export interface FormProps<C extends Type<TypeOf<C>, OutputOf<C>, InputOf<C>>> | |
extends Omit<PropsWithoutRef<JSX.IntrinsicElements["form"]>, "onSubmit"> { | |
children?: ReactNode | |
submitText?: string | |
schema: C | |
initialValues: OutputOf<C> | |
onSubmit: FinalFormProps<OutputOf<C>>["onSubmit"] | |
} | |
export function Form<C extends Type<TypeOf<C>, OutputOf<C>, InputOf<C>>>({ | |
children, | |
submitText, | |
schema, | |
initialValues, | |
onSubmit, | |
...props | |
}: FormProps<C>) { | |
return ( | |
<FinalForm | |
initialValues={initialValues} | |
validate={flow( | |
schema.decode, | |
matchW( | |
flow( | |
reduce({}, (errs, err) => ({ | |
...errs, | |
...(err?.context?.[1]?.key ? { [err?.context?.[1]?.key]: err.message } : {}), | |
})) | |
), | |
() => undefined | |
) | |
)} | |
onSubmit={onSubmit} | |
render={({ handleSubmit, submitting, submitError }) => ( | |
<form onSubmit={handleSubmit} className="form" {...props}> | |
{/* Form fields supplied as children are rendered here */} | |
{children} | |
{submitError && ( | |
<div role="alert" style={{ color: "red" }}> | |
{submitError} | |
</div> | |
)} | |
{submitText && ( | |
<button type="submit" disabled={submitting}> | |
{submitText} | |
</button> | |
)} | |
<style global jsx>{` | |
.form > * + * { | |
margin-top: 1rem; | |
} | |
`}</style> | |
</form> | |
)} | |
/> | |
) | |
} | |
export default Form |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { AuthenticationError, Link, useMutation, Routes } from "blitz" | |
import { LabeledTextField } from "app/core/components/LabeledTextField" | |
import { Form, FORM_ERROR } from "app/core/components/Form" | |
import login from "app/auth/mutations/login" | |
import { LoginC } from "app/auth/validations" | |
type LoginFormProps = { | |
onSuccess?: () => void | |
} | |
const initialValues = { email: "", password: "" } | |
export const LoginForm = (props: LoginFormProps) => { | |
const [loginMutation] = useMutation(login) | |
return ( | |
<div> | |
<h1>Login</h1> | |
<Form | |
submitText="Login" | |
schema={LoginC} | |
initialValues={initialValues} | |
onSubmit={async (values) => { | |
try { | |
await loginMutation(values) | |
props.onSuccess?.() | |
} catch (error) { | |
if (error instanceof AuthenticationError) { | |
return { [FORM_ERROR]: "Sorry, those credentials are invalid" } | |
} else { | |
return { | |
[FORM_ERROR]: | |
"Sorry, we had an unexpected error. Please try again. - " + error.toString(), | |
} | |
} | |
} | |
}} | |
> | |
<LabeledTextField name="email" label="Email" placeholder="Email" /> | |
<LabeledTextField name="password" label="Password" placeholder="Password" type="password" /> | |
<div> | |
<Link href={Routes.ForgotPasswordPage()}> | |
<a>Forgot your password?</a> | |
</Link> | |
</div> | |
</Form> | |
<div style={{ marginTop: "1rem" }}> | |
Or <Link href={Routes.SignupPage()}>Sign Up</Link> | |
</div> | |
</div> | |
) | |
} | |
export default LoginForm |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { resolver, SecurePassword } from "blitz" | |
import db from "db" | |
import { SignupC } from "app/auth/validations" | |
import { Role } from "types" | |
import { flow, pipe } from "fp-ts/function" | |
import * as E from "fp-ts/Either" | |
import * as TE from "fp-ts/TaskEither" | |
export default resolver.pipe( | |
(formData, ctx) => | |
pipe( | |
formData, | |
SignupC.decode, | |
E.mapLeft(() => new Error("Failed to decode user sign up data")), | |
TE.fromEither, | |
TE.bindTo("signupData"), | |
TE.bind("hashedPassword", ({ signupData }) => | |
TE.tryCatch( | |
() => SecurePassword.hash(signupData.password.trim()), | |
() => new Error("Failed to create password hash") | |
) | |
), | |
TE.bind("user", ({ hashedPassword, signupData: { email } }) => | |
TE.tryCatch( | |
() => | |
db.user.create({ | |
data: { email: email.toLowerCase().trim(), hashedPassword, role: "USER" }, | |
select: { id: true, name: true, email: true, role: true }, | |
}), | |
() => new Error("Failed to store credentials") | |
) | |
), | |
TE.chain(({ user }) => | |
TE.tryCatch( | |
() => ctx.session.$create({ userId: user.id, role: user.role as Role }).then(() => user), | |
() => new Error("Failed to create new user session from sign up") | |
) | |
) | |
)(), | |
E.matchW( | |
(err) => err, | |
(user) => user | |
) | |
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { strict, string, brand, Branded, TypeOf, intersection, partial } from "io-ts" | |
import { NonEmptyString, withMessage } from "io-ts-types" | |
interface EmailAddressBrand { | |
readonly EmailAddress: unique symbol | |
} | |
// https://stackoverflow.com/a/201378/5202773 | |
const emailPattern = | |
/(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/i | |
export const EmailAddressC = withMessage( | |
brand( | |
string, | |
(s: string): s is Branded<string, EmailAddressBrand> => emailPattern.test(s), | |
"EmailAddress" | |
), | |
(input) => | |
typeof input === "undefined" || (typeof input === "string" && input.length === 0) | |
? `Email is required` | |
: `Email address value must be a valid email address, got: ${input}` | |
) | |
interface PasswordBrand { | |
readonly Password: unique symbol | |
} | |
export const PasswordC = withMessage( | |
brand( | |
string, | |
(s: string): s is Branded<string, PasswordBrand> => s.length > 5 && s.length <= 25, | |
"Password" | |
), | |
() => `Password must be between 5 and 25 characters` | |
) | |
export const LoginC = strict({ | |
email: EmailAddressC, | |
password: PasswordC, | |
}) | |
export const SignupC = intersection([ | |
LoginC, | |
partial({ | |
firstName: NonEmptyString, | |
lastName: NonEmptyString, | |
}), | |
]) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment