Skip to content

Instantly share code, notes, and snippets.

@marcmartino
Last active June 3, 2022 09:08
Show Gist options
  • Save marcmartino/9cb274692fe5fb29635079f7d3fd0dc8 to your computer and use it in GitHub Desktop.
Save marcmartino/9cb274692fe5fb29635079f7d3fd0dc8 to your computer and use it in GitHub Desktop.
Example io-ts react final form based off blitzjs boilerplate
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
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
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
)
)
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