Crée un formulaire React Hook Form connecté à une mutation Relay avec validation yup et gestion d'erreurs. Utiliser pour formulaires avec soumission GraphQL.
Crée un formulaire React Hook Form connecté à une mutation Relay avec les patterns du projet.
Toujours définir un schéma yup +
resolver: yupResolver(schema), même pour un formulaire à un seul champ. Ne pas se fier uniquement au propisRequireddeFormControl.FormControletFieldInputappellent chacunuseControllerséparément pour le mêmename/control:FormControlenregistre la règlerequired,FieldInputenregistreminLength/maxLength/pattern. Selon l'ordre de montage des effets, l'un peut écraser silencieusement les règles de l'autre, ce qui rendisRequiredseul non fiable (un champ vide peut alors passer côté client et provoquer une erreur backend au lieu d'un message de validation). Unresolveryup valide l'objet complet en un seul passage et alimenteformState.errorsdirectement, indépendamment de ce risque de collision entre les deuxuseController.Pour un champ texte requis,
.required()ne suffit pas : une chaîne composée uniquement d'espaces (" ") passe.required()(ce n'est pas une chaîne vide). Utiliser la méthode custom réutilisable.notBlank(message)à la place — définie dansadmin-next/shared/utils/yupExtensions.ts, à importer une fois par fichier de schéma (import '@shared/utils/yupExtensions') pour que la méthode existe au runtime. VoirREFONTE_SONATA.mdpour le détail.
import { useForm, FormProvider } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import * as yup from 'yup'
import '@shared/utils/yupExtensions' // registers the notBlank() custom method
import { useIntl } from 'react-intl'
import { Button, Flex } from '@cap-collectif/ui'
import { FormControl, FieldInput } from '@cap-collectif/form'
import { mutationErrorToast } from '@utils/mutation-error-toast'
import UpdateEntityMutation from '@mutations/UpdateEntityMutation'
// 1. Form types
type FormValues = {
title: string
description: string | null
isActive: boolean
}
// 2. Yup validation schema
const getSchema = (intl: IntlShape) =>
yup.object().shape({
title: yup
.string()
.notBlank(intl.formatMessage({ id: 'global.required' }))
.min(2, intl.formatMessage({ id: 'global.min-length' }, { count: 2 })),
description: yup.string().nullable(),
isActive: yup.boolean(),
})
// 3. Initial values from Relay data
const getInitialValues = (data: EntityFragment$data): FormValues => ({
title: data.title,
description: data.description ?? null,
isActive: data.isActive,
})
// 4. Form component
export const EntityForm: React.FC<Props> = ({ entity }) => {
const intl = useIntl()
const [isSubmitting, setIsSubmitting] = React.useState(false)
const methods = useForm<FormValues>({
mode: 'onChange',
defaultValues: getInitialValues(entity),
resolver: yupResolver(getSchema(intl)),
})
const { handleSubmit, formState: { isValid } } = methods
const onSubmit = (values: FormValues) => {
setIsSubmitting(true)
const input = {
entityId: entity.id,
title: values.title,
description: values.description,
isActive: values.isActive,
}
UpdateEntityMutation.commit({ input })
.then(() => {
toast({
variant: 'success',
content: intl.formatMessage({ id: 'global.changes.saved' }),
})
})
.catch(() => {
mutationErrorToast(intl)
})
.finally(() => {
setIsSubmitting(false)
})
}
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)}>
<FormControl name="title" control={methods.control} isRequired>
<FormControl.Label>
{intl.formatMessage({ id: 'global.title' })}
</FormControl.Label>
<FieldInput
name="title"
control={methods.control}
type="text"
/>
</FormControl>
<FormControl name="description" control={methods.control}>
<FormControl.Label>
{intl.formatMessage({ id: 'global.description' })}
</FormControl.Label>
<FieldInput
name="description"
control={methods.control}
type="textarea"
/>
</FormControl>
<Button
type="submit"
variant="primary"
isLoading={isSubmitting}
disabled={!isValid || isSubmitting}
>
{intl.formatMessage({ id: 'global.save' })}
</Button>
</form>
</FormProvider>
)
}
const schema = yup.object().shape({
isExternal: yup.boolean(),
externalLink: yup.string().when('isExternal', {
is: true,
then: yup
.string()
.required(intl.formatMessage({ id: 'global.required' }))
.url(intl.formatMessage({ id: 'global.invalid-url' })),
otherwise: yup.string().nullable(),
}),
})
Pour frontend/js/, utiliser le pattern hook :
// mutations/UpdateEntityMutation.ts
import { graphql, useMutation } from 'react-relay'
import type { UpdateEntityMutation as MutationType } from '@relay/UpdateEntityMutation.graphql'
const mutation = graphql`
mutation UpdateEntityMutation($input: UpdateEntityInput!) {
updateEntity(input: $input) {
entity {
id
title
}
errorCode
}
}
`
export const useUpdateEntityMutation = () => {
const [commit, isLoading] = useMutation<MutationType>(mutation)
return { commit, isLoading }
}
// In component
const { commit, isLoading } = useUpdateEntityMutation()
const onSubmit = (values: FormValues) => {
commit({
variables: { input: { ...values, entityId: entity.id } },
onCompleted: (response, errors) => {
if (errors?.length || response.updateEntity?.errorCode) {
mutationErrorToast(intl)
return
}
successToast()
},
onError: () => mutationErrorToast(intl),
})
}
Pour admin-next/, utiliser le pattern promise :
// mutations/UpdateEntityMutation.ts
import { graphql } from 'react-relay'
import { commitMutation } from './commitMutation'
import type { UpdateEntityMutation } from '@relay/UpdateEntityMutation.graphql'
const mutation = graphql`
mutation UpdateEntityMutation($input: UpdateEntityInput!) {
updateEntity(input: $input) {
entity { id title }
errorCode
}
}
`
const commit = (variables: UpdateEntityMutation['variables']) =>
commitMutation<UpdateEntityMutation>({
mutation,
variables,
})
export default { commit }
// In component
UpdateEntityMutation.commit({ input })
.then(response => { ... })
.catch(error => { ... })
const { setError } = useFormContext()
const onSubmit = (values: FormValues) => {
if (!values.address) {
setError('address', {
type: 'manual',
message: intl.formatMessage({ id: 'address.required' }),
})
return
}
// ... mutation
}
import { useDebounce } from 'use-debounce'
const { watch } = methods
const watchedValues = watch()
const [debouncedValues] = useDebounce(watchedValues, 500)
React.useEffect(() => {
if (methods.formState.isDirty && methods.formState.isValid) {
onSubmit(debouncedValues)
}
}, [debouncedValues])
<FieldInput type="text" />
<FieldInput type="textarea" />
<FieldInput type="number" />
<FieldInput type="email" />
<FieldInput type="password" />
<FieldInput type="select" options={[{ value: 'a', label: 'A' }]} />
<FieldInput type="checkbox" />
<FieldInput type="radio" choices={[...]} />
<FieldInput type="date" />
<FieldInput type="uploader" /> {/* File upload */}
FormValues définisisRequired seul)defaultValues depuis les données RelayFormProvider enveloppe le formisSubmitting / isLoading