Commit 87a5ae54 authored by Stefan Probst's avatar Stefan Probst
Browse files

refactor: swap form and textfield impl

parent a079609b
import cx from 'clsx'
import type { PropsWithChildren } from 'react'
import type { FieldError } from 'react-hook-form'
import FormFieldErrorMessage from '@/modules/hook-form/FormFieldErrorMessage'
import FormFieldLabel from '@/modules/hook-form/FormFieldLabel'
import VStack from '@/modules/layout/VStack'
export default function FormField({
label,
error,
className,
children,
}: PropsWithChildren<{
label?: string
error?: FieldError
className?: string
}>): JSX.Element {
return (
<VStack className={cx('space-y-1', className)}>
{label !== undefined ? (
<FormFieldLabel label={label}>{children}</FormFieldLabel>
) : (
children
)}
<FormFieldErrorMessage message={error?.message} />
</VStack>
)
}
export default function FormFieldErrorMessage({
message,
}: {
message?: string
}): JSX.Element | null {
if (message === undefined) return null
return (
<span role="alert" className="text-error-600">
{message}
</span>
)
}
import type { PropsWithChildren } from 'react'
import VStack from '@/modules/layout/VStack'
export default function FormFieldLabel({
label,
children,
}: PropsWithChildren<{ label: string }>): JSX.Element {
return (
<VStack as="label" className="space-y-1 flex-1">
<span className="font-medium">{label}</span>
{children}
</VStack>
)
}
import type { PropsWithChildren } from 'react'
import { createContext, useContext } from 'react'
import type {
ArrayField,
Control,
FieldError,
// UseFormMethods,
} from 'react-hook-form'
import { useFieldArray } from 'react-hook-form'
import FormFieldErrorMessage from '@/modules/hook-form/FormFieldErrorMessage'
type FormFieldListItem = [
field: Partial<ArrayField>,
index: number,
remove: (index: number) => void,
]
const FormFieldListContext = createContext<FormFieldListItem | null>(null)
export function useFormFieldListItem(): FormFieldListItem {
const item = useContext(FormFieldListContext)
if (item === null) {
throw new Error(
'`useFormFieldListItem` must be nested inside a `FormFieldListContext.Provider`',
)
}
return item
}
export default function FormFieldList({
name,
label,
error,
control,
// trigger,
children,
}: PropsWithChildren<{
name: string
label: string
error?: FieldError
control: Control
// trigger: UseFormMethods['trigger']
}>): JSX.Element {
const { fields, append, remove } = useFieldArray({
name,
control,
keyName: '_id',
})
/** trigger array level validation since `react-hook-form` only handles field validation */
// useEffect(() => {
// trigger(name)
// }, [trigger, name])
return (
<fieldset className="flex flex-col space-y-1">
<legend className="font-medium text-sm">{label}</legend>
<ul className="flex flex-col space-y-2">
{fields.map((field, index) => {
return (
<li key={field._id} className="flex">
{typeof children === 'function' ? (
children(field, index, remove)
) : (
<FormFieldListContext.Provider value={[field, index, remove]}>
{children}
</FormFieldListContext.Provider>
)}
</li>
)
})}
</ul>
<div className="py-1">
<button
type="button"
onClick={() => append({})}
className="self-start text-primary-750 text-sm"
>
+ Add next
</button>
</div>
<FormFieldErrorMessage message={error?.message} />
</fieldset>
)
}
import { Transition } from '@headlessui/react'
import type { PropsWithChildren } from 'react'
export default function FadeIn({
show,
children,
}: PropsWithChildren<{ show: boolean }>): JSX.Element {
return (
<Transition
show={show}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
{children}
</Transition>
)
}
import cx from 'clsx'
import type { Ref } from 'react'
import type { PropsWithAs } from '@/utils/ts/as'
import { forwardRefWithAs } from '@/utils/ts/as'
type TextFieldComponentProps = PropsWithAs<unknown, 'input'>
function TextFieldComponent(
{
as: Type = 'input',
children,
className,
...props
}: TextFieldComponentProps,
ref: Ref<HTMLInputElement>,
): JSX.Element {
const classNames = cx(
'border border-gray-200 rounded p-3 bg-gray-50 shadow-none',
props['aria-invalid'] === true && 'border-red-600',
className,
)
return (
<Type ref={ref} className={classNames} {...props}>
{children}
</Type>
)
}
const TextField = forwardRefWithAs<unknown, 'input'>(TextFieldComponent)
export default TextField
......@@ -22,7 +22,6 @@ import 'tailwindcss/tailwind.css'
import '@/styles/nprogress.css'
/** should use ReactToastify.minimal.css */
import 'react-toastify/dist/ReactToastify.css'
import '@/styles/combobox.css'
import '@/styles/dialog.css'
/**
......
import type { GetStaticPropsResult } from 'next'
import { getLastUpdatedTimestamp } from '@/api/git'
// import type { GetStaticPropsResult } from 'next'
// import { getLastUpdatedTimestamp } from '@/api/git'
import ContactScreen from '@/screens/contact/ContactScreen'
export type PageProps = {
lastUpdatedAt: string
}
// export type PageProps = {
// lastUpdatedAt: string
// }
/**
* Contact page.
*/
export default function ContactPage(props: PageProps): JSX.Element {
return <ContactScreen {...props} />
export default function ContactPage(): JSX.Element {
return <ContactScreen />
}
export async function getStaticProps(): Promise<
GetStaticPropsResult<PageProps>
> {
const pageId = 'contact'
const lastUpdatedAt = (await getLastUpdatedTimestamp(pageId)).toISOString()
return { props: { lastUpdatedAt } }
}
// export async function getStaticProps(): Promise<
// GetStaticPropsResult<PageProps>
// > {
// const pageId = 'contact'
// const lastUpdatedAt = (await getLastUpdatedTimestamp(pageId)).toISOString()
// return { props: { lastUpdatedAt } }
// }
import cx from 'clsx'
import Image from 'next/image'
import { useRouter } from 'next/router'
import { Fragment, useEffect, useMemo } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'react-toastify'
import {
useSignInUser,
useValidateImplicitGrantTokenWithoutRegistration,
} from '@/api/sshoc/client'
import { Button } from '@/elements/Button/Button'
import { useAuth } from '@/modules/auth/AuthContext'
import FormField from '@/modules/hook-form/FormField'
import { FormTextField } from '@/modules/form/components/FormTextField/FormTextField'
import { Form } from '@/modules/form/Form'
import { isEmail } from '@/modules/form/validate'
import ContentColumn from '@/modules/layout/ContentColumn'
import GridLayout from '@/modules/layout/GridLayout'
import HStack from '@/modules/layout/HStack'
import VStack from '@/modules/layout/VStack'
import Metadata from '@/modules/metadata/Metadata'
import TextField from '@/modules/ui/TextField'
import { SubSectionTitle } from '@/modules/ui/typography/SubSectionTitle'
import { Title } from '@/modules/ui/typography/Title'
import { createUrlFromPath } from '@/utils/createUrlFromPath'
......@@ -84,16 +84,10 @@ function SignInForm() {
const router = useRouter()
const auth = useAuth()
useValidateToken()
const { mutate: signInUser } = useSignInUser()
const { handleSubmit, register, errors, formState } = useForm<SignInFormData>(
{
mode: 'onChange',
},
)
const signInUser = useSignInUser()
function onSubmit(formData: SignInFormData) {
/** return promise to set formState.isSubmitting correctly */
return signInUser([formData], {
return signInUser.mutateAsync([formData], {
onSuccess({ token }) {
if (token !== null) {
auth.signIn(token)
......@@ -111,40 +105,40 @@ function SignInForm() {
})
}
const isDisabled = !formState.isValid || formState.isSubmitting
function onValidate(values: Partial<SignInFormData>) {
const errors: Partial<Record<keyof typeof values, string>> = {}
if (values.username === undefined) {
errors.username = 'Username is required.'
}
if (values.password === undefined) {
errors.password = 'Password is required.'
}
return errors
}
return (
<VStack as="form" onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<FormField label="Username" error={errors.username}>
<TextField
name="username"
aria-invalid={Boolean(errors.username)}
ref={register({ required: 'Username is required.' })}
/>
</FormField>
<FormField label="Password" error={errors.password}>
<TextField
name="password"
type="password"
aria-invalid={Boolean(errors.password)}
ref={register({ required: 'Password is required.' })}
/>
</FormField>
<div className="self-end py-2">
<button
type="submit"
disabled={isDisabled}
className={cx(
'py-3 px-6 w-40 rounded transition-colors duration-150',
isDisabled
? 'pointer-events-none text-gray-500 bg-gray-200'
: 'text-white bg-primary-800 hover:bg-primary-700',
)}
>
Sign in
</button>
</div>
</VStack>
<Form onSubmit={onSubmit} validate={onValidate}>
{({ handleSubmit, pristine, submitting, invalid }) => {
return (
<VStack as="form" onSubmit={handleSubmit} className="space-y-5">
<FormTextField name="username" label="Username" />
<FormTextField name="password" label="Password" type="password" />
<div className="self-end py-2">
<Button
type="submit"
isDisabled={pristine || invalid || submitting}
className="w-40 px-6 py-3 transition-colors duration-150 rounded"
>
Sign in
</Button>
</div>
</VStack>
)
}}
</Form>
)
}
......
import cx from 'clsx'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { Fragment, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { useQueryClient } from 'react-query'
import { toast } from 'react-toastify'
import { useRegisterOAuth2User } from '@/api/sshoc'
import { useValidateImplicitGrantTokenWithRegistration } from '@/api/sshoc/client'
import { Button } from '@/elements/Button/Button'
import { ProgressSpinner } from '@/elements/ProgressSpinner/ProgressSpinner'
import { useAuth } from '@/modules/auth/AuthContext'
import FormField from '@/modules/hook-form/FormField'
import { FormCheckBox } from '@/modules/form/components/FormCheckBox/FormCheckBox'
import { FormTextField } from '@/modules/form/components/FormTextField/FormTextField'
import { Form } from '@/modules/form/Form'
import { FormField } from '@/modules/form/FormField'
import { isEmail } from '@/modules/form/validate'
import ContentColumn from '@/modules/layout/ContentColumn'
import GridLayout from '@/modules/layout/GridLayout'
import VStack from '@/modules/layout/VStack'
import Metadata from '@/modules/metadata/Metadata'
import { Anchor } from '@/modules/ui/Anchor'
import Checkbox from '@/modules/ui/Checkbox'
import TextField from '@/modules/ui/TextField'
import { Title } from '@/modules/ui/typography/Title'
import { createUrlFromPath } from '@/utils/createUrlFromPath'
import { getRedirectPath } from '@/utils/getRedirectPath'
......@@ -73,29 +75,16 @@ type SignUpFormData = {
function SignUpForm(): JSX.Element {
const router = useRouter()
const auth = useAuth()
const { data: registrationData } = useValidateAuthCode()
const { mutate: registerUser } = useRegisterOAuth2User()
const registration = useValidateAuthCode()
const registerUser = useRegisterOAuth2User()
const queryCache = useQueryClient()
const { register, handleSubmit, errors, reset, formState } = useForm<
SignUpFormData
>({ mode: 'onChange' })
useEffect(() => {
if (registrationData !== undefined) {
reset({
id: registrationData.id,
displayName: registrationData.displayName,
email: registrationData.email,
})
}
}, [registrationData, reset])
function onSubmit(formData: SignUpFormData) {
if (registrationData === undefined) return
const { token } = registrationData
if (registration.data === undefined) return
const { token } = registration.data
if (token === null) return
/** return promise to set formState.isSubmitting correctly */
return registerUser([formData, { token }], {
return registerUser.mutateAsync([formData, { token }], {
onSuccess(userData) {
auth.signIn(token)
/**
......@@ -116,62 +105,80 @@ function SignUpForm(): JSX.Element {
})
}
const isDisabled =
registrationData === undefined ||
registrationData.token === null ||
!formState.isValid ||
formState.isSubmitting
function onValidate(values: Partial<SignUpFormData>) {
const errors: Partial<Record<keyof typeof values, string>> = {}
if (values.displayName === undefined) {
errors.displayName = 'Display name is required.'
}
if (values.email === undefined) {
errors.email = 'Email is required.'
} else if (!isEmail(values.email)) {
errors.email = 'Please enter a valid email address.'
}
if (values.acceptedRegulations !== true) {
errors.acceptedRegulations = 'Accepting the privacy policy is required.'
}
return errors
}
if (registration.status !== 'success' || registration.data === undefined) {
return (
<div className="flex flex-col items-center justify-center">
<ProgressSpinner />
</div>
)
}
const initialValues = {
id: registration.data.id,
displayName: registration.data.displayName,
email: registration.data.email,
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" name="id" ref={register({ required: true })} />
<VStack className="space-y-6">
<FormField label="Name" error={errors.displayName}>
<TextField
name="displayName"
aria-invalid={Boolean(errors.displayName)}
ref={register({ required: 'Name is required.' })}
/>
</FormField>
<FormField label="Email" error={errors.email}>
<TextField
type="email"
name="email"
aria-invalid={Boolean(errors.email)}
ref={register({ required: 'Email is required.' })}
/>
</FormField>
<FormField error={errors.acceptedRegulations}>
<Checkbox
name="acceptedRegulations"
aria-invalid={Boolean(errors.acceptedRegulations)}
ref={register({
required: 'Accepting the privacy policy is required.',
})}
>
I have read and understood the{' '}
<Link href={{ pathname: '/privacy-policy' }} passHref>
<Anchor>Privacy policy</Anchor>
</Link>{' '}
and I accept it.
</Checkbox>
</FormField>
<div className="self-end py-2">
<button
type="submit"
disabled={isDisabled}
className={cx(
'py-3 px-6 w-40 transition-colors duration-150 rounded',
isDisabled
? 'bg-gray-200 text-gray-500 pointer-events-none'
: 'bg-primary-800 text-white',
)}
>
Sign up
</button>
</div>
</VStack>
</form>
<Form
onSubmit={onSubmit}
validate={onValidate}
initialValues={initialValues}
>
{({ handleSubmit, pristine, submitting, invalid }) => {
return (
<form onSubmit={handleSubmit}>
<FormField type="hidden" name="id" component="input" />
<VStack className="space-y-6">
<FormTextField name="displayName" label="Name" />
<FormTextField type="email" name="email" label="Email" />
<FormCheckBox name="acceptedRegulations">
I have read and understood the{' '}
<Link href={{ pathname: '/privacy-policy' }} passHref>
<Anchor>Privacy policy</Anchor>
</Link>{' '}
and I accept it.
</FormCheckBox>
<div className="self-end py-2">
<Button
type="submit"
isDisabled={
registration.data === undefined ||
registration.data.token === null ||
pristine ||
submitting ||
invalid
}
className="w-40 px-6 py-3 transition-colors duration-150 rounded"
>
Sign up
</Button>
</div>
</VStack>
</form>
)
}}
</Form>
)
}
......
</
import cx from 'clsx'
import Image from 'next/image'
import { useRouter } from 'next/router'
import { Fragment, useRef, useEffect } from 'react'
import { Fragment, useEffect, useRef } from 'react'
import ReCaptcha from 'react-google-recaptcha'
import { useForm } from 'react-hook-form'
import { toast } from 'react-toastify'
import FormField from '@/modules/hook-form/FormField'
import { Button } from '@/elements/Button/Button'