diff --git a/src/app/(app)/equipe/AddEmployeeForm.tsx b/src/app/(app)/equipe/AddEmployeeForm.tsx index b23668d..3978a38 100644 --- a/src/app/(app)/equipe/AddEmployeeForm.tsx +++ b/src/app/(app)/equipe/AddEmployeeForm.tsx @@ -3,6 +3,7 @@ import { useActionState } from 'react'; import { Field, FormError, SubmitButton } from '@/components/ui/Form'; +import { PersistentForm } from '@/components/ui/PersistentForm'; import { createEmployeeAction, type ActionState, @@ -15,7 +16,11 @@ export function AddEmployeeForm() { ); return ( -
+
@@ -37,6 +42,6 @@ export function AddEmployeeForm() { Salarié ajouté. ) : null}
- +
); } diff --git a/src/app/(app)/equipe/[id]/DocumentsPanel.tsx b/src/app/(app)/equipe/[id]/DocumentsPanel.tsx index cd23aae..0bee07b 100644 --- a/src/app/(app)/equipe/[id]/DocumentsPanel.tsx +++ b/src/app/(app)/equipe/[id]/DocumentsPanel.tsx @@ -4,6 +4,7 @@ import { useActionState } from 'react'; import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; +import { PersistentForm } from '@/components/ui/PersistentForm'; import { ACCEPTED_MIME_TYPES, CATEGORY_LABELS, @@ -114,8 +115,9 @@ export function DocumentsPanel({ )} {canManage ? ( -
@@ -157,7 +159,7 @@ export function DocumentsPanel({ -
+ ) : null} {uploadState.error ? ( diff --git a/src/app/(app)/reglages/etablissements/AddLocationForm.tsx b/src/app/(app)/reglages/etablissements/AddLocationForm.tsx index 85f9ef6..1b67ce4 100644 --- a/src/app/(app)/reglages/etablissements/AddLocationForm.tsx +++ b/src/app/(app)/reglages/etablissements/AddLocationForm.tsx @@ -3,6 +3,7 @@ import { useActionState } from 'react'; import { Field, FormError, SubmitButton } from '@/components/ui/Form'; +import { PersistentForm } from '@/components/ui/PersistentForm'; import { createLocationAction, type ActionState, @@ -15,7 +16,11 @@ export function AddLocationForm() { ); return ( -
+
Établissement créé. ) : null}
- +
); } diff --git a/src/app/(app)/reglages/etablissements/AddTeamForm.tsx b/src/app/(app)/reglages/etablissements/AddTeamForm.tsx index f55d572..7aede9e 100644 --- a/src/app/(app)/reglages/etablissements/AddTeamForm.tsx +++ b/src/app/(app)/reglages/etablissements/AddTeamForm.tsx @@ -3,6 +3,7 @@ import { useActionState } from 'react'; import { Field, FormError, SubmitButton } from '@/components/ui/Form'; +import { PersistentForm } from '@/components/ui/PersistentForm'; import { createTeamAction, type ActionState, @@ -15,13 +16,17 @@ export function AddTeamForm({ locationId }: { locationId: string }) { ); return ( -
+ Ajouter
{state.error}
- +
); } diff --git a/src/app/(app)/reglages/registre/AddEntryForm.tsx b/src/app/(app)/reglages/registre/AddEntryForm.tsx index a4fdc76..dab5846 100644 --- a/src/app/(app)/reglages/registre/AddEntryForm.tsx +++ b/src/app/(app)/reglages/registre/AddEntryForm.tsx @@ -3,6 +3,7 @@ import { useActionState } from 'react'; import { Field, FormError, SubmitButton } from '@/components/ui/Form'; +import { PersistentForm } from '@/components/ui/PersistentForm'; import { LEGAL_DOMAINS } from '@/domain/legal/domains'; import { addLegalEntryAction, @@ -16,7 +17,11 @@ export function AddEntryForm() { ); return ( -
+
- +
); } diff --git a/src/components/absences/RequestForm.tsx b/src/components/absences/RequestForm.tsx index 3bede0b..2d20237 100644 --- a/src/components/absences/RequestForm.tsx +++ b/src/components/absences/RequestForm.tsx @@ -3,6 +3,7 @@ import { useActionState } from 'react'; import { Button } from '@/components/ui/Button'; +import { PersistentForm } from '@/components/ui/PersistentForm'; import { requestTimeOffAction, type AbsenceActionState, @@ -33,7 +34,8 @@ export function RequestForm({ ); return ( -
@@ -126,7 +128,7 @@ export function RequestForm({ {state.ok && !state.warning ? (

Demande enregistrée.

) : null} -
+ ); } diff --git a/src/components/hours/ActualHoursForm.tsx b/src/components/hours/ActualHoursForm.tsx index 939f7ea..ba42e81 100644 --- a/src/components/hours/ActualHoursForm.tsx +++ b/src/components/hours/ActualHoursForm.tsx @@ -3,6 +3,7 @@ import { useActionState } from 'react'; import { Button } from '@/components/ui/Button'; +import { PersistentForm } from '@/components/ui/PersistentForm'; import { saveActualHoursAction, validateHoursAction, @@ -37,7 +38,11 @@ export function ActualHoursForm({ shift }: { shift: HoursShiftRow }) { } return ( -
+
); } diff --git a/src/components/payroll/MappingForm.tsx b/src/components/payroll/MappingForm.tsx index a45070c..9f80fc7 100644 --- a/src/components/payroll/MappingForm.tsx +++ b/src/components/payroll/MappingForm.tsx @@ -3,6 +3,7 @@ import { useActionState } from 'react'; import { Button } from '@/components/ui/Button'; +import { PersistentForm } from '@/components/ui/PersistentForm'; import { saveMappingAction, type PayrollActionState, @@ -31,7 +32,8 @@ export function MappingForm({ const listId = `codes-${row.key}`; return ( -
@@ -85,6 +87,6 @@ export function MappingForm({ -
+ ); } diff --git a/src/components/planning/TeamSection.tsx b/src/components/planning/TeamSection.tsx index 48c8230..c5e7894 100644 --- a/src/components/planning/TeamSection.tsx +++ b/src/components/planning/TeamSection.tsx @@ -6,6 +6,7 @@ import { AlertPanel } from '@/components/planning/AlertPanel'; import { WeekGrid } from '@/components/planning/WeekGrid'; import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; +import { PersistentForm } from '@/components/ui/PersistentForm'; import { formatMinutes } from '@/domain/counters/week'; import { displayName, @@ -16,11 +17,15 @@ import { createShiftAction, deleteShiftAction, duplicateWeekAction, - publishWeekAction, - unpublishWeekAction, + setWeekPublicationAction, updateShiftAction, type PlanningActionState, } from '@/server/planning/actions'; +import { + PUBLICATION_INTENT_FIELD, + PUBLISH_INTENT, + UNPUBLISH_INTENT, +} from '@/domain/planning/publication'; import type { BoardLabel, BoardSection } from '@/server/planning/queries'; export interface TeamSectionProps { @@ -222,12 +227,15 @@ function PublishControl({ pendingWarnings: number; }) { const [state, formAction, pending] = useActionState( - published ? unpublishWeekAction : publishWeekAction, + setWeekPublicationAction, empty, ); return ( -
+ {/* Verrou optimiste : la version lue au rendu est renvoyée telle quelle. */} @@ -254,15 +262,20 @@ function PublishControl({ {state.error} ) : null} + {/* L'intention voyage sur le bouton, et non dans un champ caché : la + réinitialisation que React applique après chaque action remettrait un + champ caché à sa valeur d'origine, jamais un bouton. */} - +
); } diff --git a/src/components/settings/EmailSettingsForm.tsx b/src/components/settings/EmailSettingsForm.tsx index 05317ad..0239900 100644 --- a/src/components/settings/EmailSettingsForm.tsx +++ b/src/components/settings/EmailSettingsForm.tsx @@ -3,6 +3,7 @@ import { useActionState } from 'react'; import { Button } from '@/components/ui/Button'; +import { PersistentForm } from '@/components/ui/PersistentForm'; import { saveEmailSettingsAction, sendTestEmailAction, @@ -42,8 +43,9 @@ export function EmailSettingsForm({ ); return ( -
{state.message} ) : null} - + ); } @@ -160,7 +162,7 @@ export function TestEmailForm({ defaultTo }: { defaultTo: string }) { ); return ( -
@@ -189,7 +191,7 @@ export function TestEmailForm({ defaultTo }: { defaultTo: string }) { {state.message ? (

{state.message}

) : null} -
+ ); } diff --git a/src/components/settings/RetentionForms.tsx b/src/components/settings/RetentionForms.tsx index c1e4e6e..5422832 100644 --- a/src/components/settings/RetentionForms.tsx +++ b/src/components/settings/RetentionForms.tsx @@ -3,6 +3,7 @@ import { useActionState } from 'react'; import { Button } from '@/components/ui/Button'; +import { PersistentForm } from '@/components/ui/PersistentForm'; import { START_POINT_LABELS, START_POINTS, @@ -23,7 +24,11 @@ export function RetentionPolicyForm() { ); return ( -
+ - +
); } diff --git a/src/components/ui/PersistentForm.tsx b/src/components/ui/PersistentForm.tsx index a101336..e00735c 100644 --- a/src/components/ui/PersistentForm.tsx +++ b/src/components/ui/PersistentForm.tsx @@ -7,13 +7,13 @@ import { useEffect, useRef, type FormHTMLAttributes, type ReactNode } from 'reac * * React 19 **vide les champs non contrôlés dès que l'action se termine**, sans * distinguer le succès de l'échec. Sur un refus, l'utilisateur retrouve donc un - * formulaire vierge et doit tout ressaisir — sur l'écran de première - * installation, huit champs pour un mot de passe trop court. + * formulaire vierge et doit tout ressaisir. * * Pire que l'agacement : les champs vidés portent `required`, si bien que le * clic suivant est arrêté par la validation du navigateur **avant** d'émettre * un `submit`. Le formulaire paraît alors mort — le bouton répond, et rien ne - * part. + * part. Il faut ressaisir *tous* les champs requis pour qu'un second envoi + * puisse seulement atteindre le serveur. * * Le remède : photographier la saisie à l'envoi, et la rétablir quand React * remet le formulaire à zéro. Les mots de passe sont exclus de la photo — les @@ -21,54 +21,105 @@ import { useEffect, useRef, type FormHTMLAttributes, type ReactNode } from 'reac * extension qui la lit. */ +type Restorable = + | HTMLInputElement + | HTMLSelectElement + | HTMLTextAreaElement; + +interface Captured { + name: string; + value: string; + checked: boolean; + /** Sélection multiple : `value` seul ne la décrirait pas. */ + selected?: string[]; +} + export interface PersistentFormProps extends Omit, 'action'> { action: (formData: FormData) => void | Promise; + /** + * À passer quand l'envoi a **réussi** — typiquement l'état retourné par + * l'action, ou `null` en cas de refus. + * + * Le formulaire revient alors à ses valeurs par défaut, c'est-à-dire à ce que + * le serveur vient de rendre : vide pour un formulaire d'ajout, et la valeur + * enregistrée pour un champ d'édition. C'est ce qui rend visible une + * normalisation faite côté serveur, qu'un rétablissement de la frappe + * masquerait. + * + * Passer l'état lui-même plutôt qu'un booléen n'est pas un détail : + * `useActionState` en produit un nouveau à chaque envoi, si bien que deux + * succès consécutifs sont distingués — ce qu'un `true` répété ne permettrait + * pas. + * + * Omis, le formulaire garde la saisie : c'est le bon défaut pour un écran + * dont le succès mène ailleurs. + */ + resetAfter?: unknown; children: ReactNode; } +function restorable(element: unknown): element is Restorable { + if ( + !(element instanceof HTMLInputElement) && + !(element instanceof HTMLSelectElement) && + !(element instanceof HTMLTextAreaElement) + ) { + return false; + } + // Les champs internes des actions serveur portent un nom en `$ACTION_` : les + // toucher défairait le câblage de l'action elle-même. + if (!element.name || element.name.startsWith('$')) return false; + if (element instanceof HTMLInputElement) { + // Un mot de passe ne se réécrit pas dans le document. + if (element.type === 'password') return false; + // Un champ fichier n'est pas assignable par script, et c'est heureux. + if (element.type === 'file') return false; + // Un champ caché ne porte jamais une saisie : il porte l'état de + // l'application — identifiants, jetons, numéros de version. Le rétablir + // écraserait ce que le serveur vient de renvoyer. + // + // Le cas s'est produit : le formulaire de publication d'une semaine porte + // un `expectedVersion`, verrou optimiste relu à chaque rendu. Rétabli + // depuis la photo, il renvoyait la version d'avant la publication, et la + // dépublication qui suivait était refusée pour conflit. + if (element.type === 'hidden') return false; + } + return true; +} + /** * Le rétablissement est **inconditionnel**, et ce n'est pas un raccourci. * - * Le distinguer selon l'issue supposerait de lire l'état de l'action au moment - * de la remise à zéro. Or React remet le formulaire à zéro pendant la - * validation du rendu, tandis qu'un `useEffect` s'exécute après : le drapeau lu - * serait celui de l'envoi précédent, et le premier refus perdrait la saisie — - * exactement le défaut qu'on corrige. Mieux vaut ne pas dépendre de cet ordre. - * - * Un formulaire qui doit se vider après un envoi réussi le fait donc lui-même. - * La règle par défaut est la bonne : ne jamais perdre ce que l'utilisateur a - * tapé. + * Le conditionner à l'issue supposerait de lire l'état de l'action au moment de + * la remise à zéro. Or React remet le formulaire à zéro pendant la validation + * du rendu, tandis qu'un `useEffect` s'exécute après : le drapeau lu serait + * celui de l'envoi précédent, et le premier refus perdrait la saisie — + * exactement le défaut qu'on corrige. Le vidage après succès passe donc par un + * effet, qui s'exécute *après* le rétablissement et le défait proprement. */ export function PersistentForm({ action, + resetAfter, children, ...rest }: PersistentFormProps) { const formRef = useRef(null); - const snapshot = useRef | null>(null); + const snapshot = useRef(null); useEffect(() => { const form = formRef.current; if (!form) return; - const restorable = (element: Element): element is HTMLInputElement => - element instanceof HTMLInputElement && - Boolean(element.name) && - // Un mot de passe ne se réécrit pas dans le document. - element.type !== 'password' && - // Cases et boutons radio : leur état vit dans `checked`, pas dans - // `value`, et le rétablir demanderait de photographier autre chose. - element.type !== 'checkbox' && - element.type !== 'radio' && - // Les champs internes des actions serveur portent un nom en `$ACTION_`. - // Les toucher défairait le câblage de l'action elle-même. - !element.name.startsWith('$'); - const capture = () => { - snapshot.current = [...form.elements] - .filter(restorable) - .map((field) => [field.name, field.value]); + snapshot.current = [...form.elements].filter(restorable).map((field) => ({ + name: field.name, + value: field.value, + checked: field instanceof HTMLInputElement ? field.checked : false, + ...(field instanceof HTMLSelectElement && field.multiple + ? { selected: [...field.selectedOptions].map((o) => o.value) } + : {}), + })); }; const rewrite = () => { @@ -78,10 +129,21 @@ export function PersistentForm({ // Après la remise à zéro, pas pendant : l'événement `reset` précède // l'effacement, et écrire ici serait aussitôt défait. queueMicrotask(() => { - for (const [name, value] of captured) { - const field = form.elements.namedItem(name); - if (field instanceof Element && restorable(field)) { - field.value = value; + for (const entry of captured) { + const field = form.elements.namedItem(entry.name); + if (!restorable(field)) continue; + + if ( + field instanceof HTMLInputElement && + (field.type === 'checkbox' || field.type === 'radio') + ) { + field.checked = entry.checked; + } else if (field instanceof HTMLSelectElement && entry.selected) { + for (const option of field.options) { + option.selected = entry.selected.includes(option.value); + } + } else { + field.value = entry.value; } } }); @@ -95,6 +157,17 @@ export function PersistentForm({ }; }, []); + useEffect(() => { + if (!resetAfter) return; + const form = formRef.current; + if (!form) return; + + // La photo est jetée d'abord : sans cela, la remise à zéro déclencherait le + // rétablissement de ce qu'on cherche justement à écarter. + snapshot.current = null; + form.reset(); + }, [resetAfter]); + return (
{children} diff --git a/src/domain/planning/publication.ts b/src/domain/planning/publication.ts new file mode 100644 index 0000000..e82adca --- /dev/null +++ b/src/domain/planning/publication.ts @@ -0,0 +1,20 @@ +/** + * Intention portée par le bouton qui publie ou dépublie une semaine. + * + * Dans le domaine, et non auprès de l'action : un module `'use server'` ne peut + * exporter que des fonctions asynchrones, et y placer une constante vide le + * module de tous ses exports — l'erreur se lit alors « The module has no + * exports at all », loin de sa cause. + * + * Pourquoi une intention plutôt que deux actions : passer tantôt l'une tantôt + * l'autre à `useActionState` selon l'état paraît naturel et ne fonctionne pas. + * Le formulaire cesse de suivre le changement d'action, et un bouton + * « Dépublier » finit par republier — sans message d'erreur, puisque l'action + * réellement exécutée réussit. Voir `setWeekPublicationAction`. + */ + +export const PUBLISH_INTENT = 'publier'; +export const UNPUBLISH_INTENT = 'depublier'; + +/** Nom du champ qui porte l'intention dans le formulaire. */ +export const PUBLICATION_INTENT_FIELD = 'intention'; diff --git a/src/server/planning/actions.ts b/src/server/planning/actions.ts index dc4ebae..02eaddb 100644 --- a/src/server/planning/actions.ts +++ b/src/server/planning/actions.ts @@ -3,6 +3,10 @@ import { revalidatePath } from 'next/cache'; import { z } from 'zod'; +import { + PUBLICATION_INTENT_FIELD, + PUBLISH_INTENT, +} from '@/domain/planning/publication'; import { AuthorizationError, can, type Actor } from '@/domain/access/authorize'; import { shiftMinutes } from '@/domain/counters/week'; import { @@ -374,18 +378,33 @@ const publishInput = z.object({ acknowledgement: z.string().trim().max(500).optional(), }); -export async function publishWeekAction( +/** + * Publier et dépublier par une **seule** action, l'intention venant du + * formulaire. + * + * Passer tantôt l'une tantôt l'autre à `useActionState` selon l'état paraît + * naturel et ne fonctionne pas : le formulaire cesse de suivre le changement + * d'action, et un bouton « Dépublier » finit par republier — sans message + * d'erreur, puisque l'action exécutée réussit. + * + * Mesuré sur trois envois consécutifs, en lisant l'en-tête `Next-Action` : + * + * 1er envoi next-action: 60b8097e (dépublier) → brouillon + * 2e envoi next-action: 6012d40f (publier) → publiée + * 3e envoi next-action: 6012d40f (publier) → publiée + * + * L'intention voyage donc avec le bouton, `name`/`value` sur le déclencheur : + * un champ caché serait remis à sa valeur d'origine par la réinitialisation + * que React applique après chaque action, un bouton ne l'est jamais. + */ +export async function setWeekPublicationAction( _previous: PlanningActionState, formData: FormData, ): Promise { - return setPublication(formData, true); -} - -export async function unpublishWeekAction( - _previous: PlanningActionState, - formData: FormData, -): Promise { - return setPublication(formData, false); + return setPublication( + formData, + formData.get(PUBLICATION_INTENT_FIELD) === PUBLISH_INTENT, + ); } const duplicateInput = z.object({ diff --git a/tests/e2e/planning.spec.ts b/tests/e2e/planning.spec.ts index d0587d1..180f1d6 100644 --- a/tests/e2e/planning.spec.ts +++ b/tests/e2e/planning.spec.ts @@ -109,6 +109,28 @@ test('un manager pose un créneau, publie, puis dépublie', async ({ page }) => await section.getByRole('button', { name: 'Dépublier' }).click(); await expect(section.getByText('Brouillon')).toBeVisible(); + + // Un second aller-retour, sans recharger la page. Régression : le formulaire + // recevait tantôt l'action de publication, tantôt celle de dépublication + // selon l'état, et cessait de suivre le changement. Le bouton affichait + // « Dépublier » et republiait — sans message d'erreur, puisque l'action + // réellement exécutée réussissait. + // + // Le vérifier deux fois, et non une : le premier envoi d'un chargement + // emploie toujours la bonne action, si bien qu'un test qui s'arrêterait là + // laisserait passer le défaut selon l'état où la semaine a été trouvée. + // Le motif n'est plus demandé : l'alerte a été acquittée au passage + // précédent, et l'acquittement survit à la dépublication — c'est justement ce + // que garantit le report des acquittements d'une réévaluation à l'autre. + const motif = section.getByPlaceholder(/^Motif —/); + if (await motif.isVisible()) { + await motif.fill('Second passage, même chargement'); + } + await section.getByRole('button', { name: 'Publier' }).click(); + await expect(section.getByText('Publiée')).toBeVisible(); + + await section.getByRole('button', { name: 'Dépublier' }).click(); + await expect(section.getByText('Brouillon')).toBeVisible(); }); test('la navigation de semaine change la grille', async ({ page }) => {