Étendre la préservation des formulaires, et corriger la publication
Suite de PR #31. `PersistentForm` couvre désormais les formulaires où la saisie compte : ajout d'un salarié, d'un établissement, d'une équipe, d'une entrée de registre, demande d'absence, heures réelles, correspondances de paie, réglages d'envoi, conservation, dépôt de pièce. Le composant gère `select`, `textarea`, cases et boutons radio, et reçoit `resetAfter` : passé l'état retourné par l'action en cas de succès, le formulaire revient aux valeurs rendues par le serveur — vide pour un ajout, la valeur enregistrée pour un champ d'édition. C'est ce qui rend visible une normalisation faite côté serveur. Les champs cachés sont exclus. Ils ne portent jamais une saisie mais l'état de l'application, et les rétablir écrase ce que le serveur vient de renvoyer. Le cas s'est produit sur `expectedVersion`, le verrou optimiste d'une semaine de planning. En éprouvant cela, un défaut plus grave est apparu, antérieur et sans rapport avec la préservation : le formulaire de publication recevait tantôt l'action de publication, tantôt celle de dépublication selon l'état, et cessait de suivre le changement. Lecture de l'en-tête `Next-Action` sur trois envois consécutifs : 1er envoi 60b8097e (dépublier) → brouillon 2e envoi 6012d40f (publier) → publiée 3e envoi 6012d40f (publier) → publiée Le bouton affichait « Dépublier » et republiait, sans message d'erreur puisque l'action réellement exécutée réussissait. Une seule action reçoit maintenant l'intention, portée par le bouton 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. Le test du planning fait désormais deux allers-retours dans le même chargement : le premier envoi d'une page emploie toujours la bonne action, si bien qu'un seul aller-retour laissait passer le défaut selon l'état où la semaine avait été trouvée. Vérifié : le test échoue sur l'ancien câblage, passe sur le nouveau. 450 tests unitaires et d'intégration, 77 de bout en bout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
15 files changed
+254
-69
No files matched your search
@@ -3,6 +3,7 @@
|
|||||||
import { useActionState } from 'react';
|
import { useActionState } from 'react';
|
||||||
|
|
||||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||||
|
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||||
import {
|
import {
|
||||||
createEmployeeAction,
|
createEmployeeAction,
|
||||||
type ActionState,
|
type ActionState,
|
||||||
@@ -15,7 +16,11 @@ export function AddEmployeeForm() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={formAction} className="flex flex-col gap-3">
|
<PersistentForm
|
||||||
|
action={formAction}
|
||||||
|
resetAfter={state.ok ? state : null}
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Field label="Prénom" name="firstName" required />
|
<Field label="Prénom" name="firstName" required />
|
||||||
<Field label="Nom" name="lastName" required />
|
<Field label="Nom" name="lastName" required />
|
||||||
@@ -37,6 +42,6 @@ export function AddEmployeeForm() {
|
|||||||
<span className="text-xs text-ok-soft-ink">Salarié ajouté.</span>
|
<span className="text-xs text-ok-soft-ink">Salarié ajouté.</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</PersistentForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useActionState } from 'react';
|
|||||||
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||||
import {
|
import {
|
||||||
ACCEPTED_MIME_TYPES,
|
ACCEPTED_MIME_TYPES,
|
||||||
CATEGORY_LABELS,
|
CATEGORY_LABELS,
|
||||||
@@ -114,8 +115,9 @@ export function DocumentsPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<form
|
<PersistentForm
|
||||||
action={upload}
|
action={upload}
|
||||||
|
resetAfter={uploadState.ok ? uploadState : null}
|
||||||
className="flex flex-wrap items-end gap-3 border-t border-line-1 pt-4"
|
className="flex flex-wrap items-end gap-3 border-t border-line-1 pt-4"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="membershipId" value={membershipId} />
|
<input type="hidden" name="membershipId" value={membershipId} />
|
||||||
@@ -157,7 +159,7 @@ export function DocumentsPanel({
|
|||||||
<Button type="submit" variant="primary" disabled={uploading}>
|
<Button type="submit" variant="primary" disabled={uploading}>
|
||||||
Déposer
|
Déposer
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</PersistentForm>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{uploadState.error ? (
|
{uploadState.error ? (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useActionState } from 'react';
|
import { useActionState } from 'react';
|
||||||
|
|
||||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||||
|
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||||
import {
|
import {
|
||||||
createLocationAction,
|
createLocationAction,
|
||||||
type ActionState,
|
type ActionState,
|
||||||
@@ -15,7 +16,11 @@ export function AddLocationForm() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={formAction} className="flex flex-col gap-3">
|
<PersistentForm
|
||||||
|
action={formAction}
|
||||||
|
resetAfter={state.ok ? state : null}
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Field label="Nom" name="name" required placeholder="Nantes Atlantis" />
|
<Field label="Nom" name="name" required placeholder="Nantes Atlantis" />
|
||||||
<Field
|
<Field
|
||||||
@@ -52,6 +57,6 @@ export function AddLocationForm() {
|
|||||||
<span className="text-xs text-ok-soft-ink">Établissement créé.</span>
|
<span className="text-xs text-ok-soft-ink">Établissement créé.</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</PersistentForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useActionState } from 'react';
|
import { useActionState } from 'react';
|
||||||
|
|
||||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||||
|
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||||
import {
|
import {
|
||||||
createTeamAction,
|
createTeamAction,
|
||||||
type ActionState,
|
type ActionState,
|
||||||
@@ -15,13 +16,17 @@ export function AddTeamForm({ locationId }: { locationId: string }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={formAction} className="flex flex-wrap items-end gap-2">
|
<PersistentForm
|
||||||
|
action={formAction}
|
||||||
|
resetAfter={state.ok ? state : null}
|
||||||
|
className="flex flex-wrap items-end gap-2"
|
||||||
|
>
|
||||||
<input type="hidden" name="locationId" value={locationId} />
|
<input type="hidden" name="locationId" value={locationId} />
|
||||||
<Field label="Nouvelle équipe" name="name" placeholder="Caisse" required />
|
<Field label="Nouvelle équipe" name="name" placeholder="Caisse" required />
|
||||||
<SubmitButton size="md">Ajouter</SubmitButton>
|
<SubmitButton size="md">Ajouter</SubmitButton>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<FormError>{state.error}</FormError>
|
<FormError>{state.error}</FormError>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</PersistentForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useActionState } from 'react';
|
import { useActionState } from 'react';
|
||||||
|
|
||||||
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
|
||||||
|
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||||
import { LEGAL_DOMAINS } from '@/domain/legal/domains';
|
import { LEGAL_DOMAINS } from '@/domain/legal/domains';
|
||||||
import {
|
import {
|
||||||
addLegalEntryAction,
|
addLegalEntryAction,
|
||||||
@@ -16,7 +17,11 @@ export function AddEntryForm() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={formAction} className="flex flex-col gap-3">
|
<PersistentForm
|
||||||
|
action={formAction}
|
||||||
|
resetAfter={state.ok ? state : null}
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
<span className="text-sm font-medium">Domaine</span>
|
<span className="text-sm font-medium">Domaine</span>
|
||||||
@@ -78,6 +83,6 @@ export function AddEntryForm() {
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</PersistentForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useActionState } from 'react';
|
import { useActionState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||||
import {
|
import {
|
||||||
requestTimeOffAction,
|
requestTimeOffAction,
|
||||||
type AbsenceActionState,
|
type AbsenceActionState,
|
||||||
@@ -33,7 +34,8 @@ export function RequestForm({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<PersistentForm
|
||||||
|
resetAfter={state.ok ? state : null}
|
||||||
action={formAction}
|
action={formAction}
|
||||||
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3"
|
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3"
|
||||||
>
|
>
|
||||||
@@ -126,7 +128,7 @@ export function RequestForm({
|
|||||||
{state.ok && !state.warning ? (
|
{state.ok && !state.warning ? (
|
||||||
<p className="w-full text-xs text-ok-soft-ink">Demande enregistrée.</p>
|
<p className="w-full text-xs text-ok-soft-ink">Demande enregistrée.</p>
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</PersistentForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useActionState } from 'react';
|
import { useActionState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||||
import {
|
import {
|
||||||
saveActualHoursAction,
|
saveActualHoursAction,
|
||||||
validateHoursAction,
|
validateHoursAction,
|
||||||
@@ -37,7 +38,11 @@ export function ActualHoursForm({ shift }: { shift: HoursShiftRow }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={formAction} className="flex flex-wrap items-center gap-1.5">
|
<PersistentForm
|
||||||
|
action={formAction}
|
||||||
|
resetAfter={state.ok ? state : null}
|
||||||
|
className="flex flex-wrap items-center gap-1.5"
|
||||||
|
>
|
||||||
<input type="hidden" name="shiftId" value={shift.id} />
|
<input type="hidden" name="shiftId" value={shift.id} />
|
||||||
|
|
||||||
<label className="sr-only" htmlFor={`start-${shift.id}`}>
|
<label className="sr-only" htmlFor={`start-${shift.id}`}>
|
||||||
@@ -90,7 +95,7 @@ export function ActualHoursForm({ shift }: { shift: HoursShiftRow }) {
|
|||||||
{state.error}
|
{state.error}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</PersistentForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useActionState } from 'react';
|
import { useActionState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||||
import {
|
import {
|
||||||
saveMappingAction,
|
saveMappingAction,
|
||||||
type PayrollActionState,
|
type PayrollActionState,
|
||||||
@@ -31,7 +32,8 @@ export function MappingForm({
|
|||||||
const listId = `codes-${row.key}`;
|
const listId = `codes-${row.key}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<PersistentForm
|
||||||
|
resetAfter={state.ok ? state : null}
|
||||||
action={formAction}
|
action={formAction}
|
||||||
className="flex flex-wrap items-center gap-3 border-b border-line-1 px-4 py-3 last:border-b-0"
|
className="flex flex-wrap items-center gap-3 border-b border-line-1 px-4 py-3 last:border-b-0"
|
||||||
>
|
>
|
||||||
@@ -85,6 +87,6 @@ export function MappingForm({
|
|||||||
<Button type="submit" size="sm" disabled={pending}>
|
<Button type="submit" size="sm" disabled={pending}>
|
||||||
Enregistrer
|
Enregistrer
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</PersistentForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import { AlertPanel } from '@/components/planning/AlertPanel';
|
|||||||
import { WeekGrid } from '@/components/planning/WeekGrid';
|
import { WeekGrid } from '@/components/planning/WeekGrid';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||||
import { formatMinutes } from '@/domain/counters/week';
|
import { formatMinutes } from '@/domain/counters/week';
|
||||||
import {
|
import {
|
||||||
displayName,
|
displayName,
|
||||||
@@ -16,11 +17,15 @@ import {
|
|||||||
createShiftAction,
|
createShiftAction,
|
||||||
deleteShiftAction,
|
deleteShiftAction,
|
||||||
duplicateWeekAction,
|
duplicateWeekAction,
|
||||||
publishWeekAction,
|
setWeekPublicationAction,
|
||||||
unpublishWeekAction,
|
|
||||||
updateShiftAction,
|
updateShiftAction,
|
||||||
type PlanningActionState,
|
type PlanningActionState,
|
||||||
} from '@/server/planning/actions';
|
} from '@/server/planning/actions';
|
||||||
|
import {
|
||||||
|
PUBLICATION_INTENT_FIELD,
|
||||||
|
PUBLISH_INTENT,
|
||||||
|
UNPUBLISH_INTENT,
|
||||||
|
} from '@/domain/planning/publication';
|
||||||
import type { BoardLabel, BoardSection } from '@/server/planning/queries';
|
import type { BoardLabel, BoardSection } from '@/server/planning/queries';
|
||||||
|
|
||||||
export interface TeamSectionProps {
|
export interface TeamSectionProps {
|
||||||
@@ -222,12 +227,15 @@ function PublishControl({
|
|||||||
pendingWarnings: number;
|
pendingWarnings: number;
|
||||||
}) {
|
}) {
|
||||||
const [state, formAction, pending] = useActionState(
|
const [state, formAction, pending] = useActionState(
|
||||||
published ? unpublishWeekAction : publishWeekAction,
|
setWeekPublicationAction,
|
||||||
empty,
|
empty,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={formAction} className="flex flex-wrap items-center gap-2">
|
<PersistentForm
|
||||||
|
action={formAction}
|
||||||
|
className="flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
<input type="hidden" name="teamId" value={teamId} />
|
<input type="hidden" name="teamId" value={teamId} />
|
||||||
<input type="hidden" name="week" value={weekParam} />
|
<input type="hidden" name="week" value={weekParam} />
|
||||||
{/* Verrou optimiste : la version lue au rendu est renvoyée telle quelle. */}
|
{/* Verrou optimiste : la version lue au rendu est renvoyée telle quelle. */}
|
||||||
@@ -254,15 +262,20 @@ function PublishControl({
|
|||||||
{state.error}
|
{state.error}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : 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. */}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
name={PUBLICATION_INTENT_FIELD}
|
||||||
|
value={published ? UNPUBLISH_INTENT : PUBLISH_INTENT}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={published ? 'secondary' : 'primary'}
|
variant={published ? 'secondary' : 'primary'}
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
>
|
>
|
||||||
{published ? 'Dépublier' : 'Publier'}
|
{published ? 'Dépublier' : 'Publier'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</PersistentForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useActionState } from 'react';
|
import { useActionState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||||
import {
|
import {
|
||||||
saveEmailSettingsAction,
|
saveEmailSettingsAction,
|
||||||
sendTestEmailAction,
|
sendTestEmailAction,
|
||||||
@@ -42,8 +43,9 @@ export function EmailSettingsForm({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<PersistentForm
|
||||||
action={formAction}
|
action={formAction}
|
||||||
|
resetAfter={state.message ? state : null}
|
||||||
className="grid gap-4 rounded-3 border border-line-1 bg-surface p-4 sm:grid-cols-2"
|
className="grid gap-4 rounded-3 border border-line-1 bg-surface p-4 sm:grid-cols-2"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
@@ -143,7 +145,7 @@ export function EmailSettingsForm({
|
|||||||
<span className="text-xs text-ok-soft-ink">{state.message}</span>
|
<span className="text-xs text-ok-soft-ink">{state.message}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</PersistentForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +162,7 @@ export function TestEmailForm({ defaultTo }: { defaultTo: string }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<PersistentForm
|
||||||
action={formAction}
|
action={formAction}
|
||||||
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3"
|
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3"
|
||||||
>
|
>
|
||||||
@@ -189,7 +191,7 @@ export function TestEmailForm({ defaultTo }: { defaultTo: string }) {
|
|||||||
{state.message ? (
|
{state.message ? (
|
||||||
<p className="w-full text-xs text-ok-soft-ink">{state.message}</p>
|
<p className="w-full text-xs text-ok-soft-ink">{state.message}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</PersistentForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useActionState } from 'react';
|
import { useActionState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||||||
import {
|
import {
|
||||||
START_POINT_LABELS,
|
START_POINT_LABELS,
|
||||||
START_POINTS,
|
START_POINTS,
|
||||||
@@ -23,7 +24,11 @@ export function RetentionPolicyForm() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={action} className="grid gap-3 sm:grid-cols-2">
|
<PersistentForm
|
||||||
|
action={action}
|
||||||
|
resetAfter={state.ok ? state : null}
|
||||||
|
className="grid gap-3 sm:grid-cols-2"
|
||||||
|
>
|
||||||
<Field
|
<Field
|
||||||
label="Objet"
|
label="Objet"
|
||||||
name="objectType"
|
name="objectType"
|
||||||
@@ -88,7 +93,7 @@ export function RetentionPolicyForm() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Messages state={state} />
|
<Messages state={state} />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</PersistentForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* 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
|
* 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
|
* formulaire vierge et doit tout ressaisir.
|
||||||
* installation, huit champs pour un mot de passe trop court.
|
|
||||||
*
|
*
|
||||||
* Pire que l'agacement : les champs vidés portent `required`, si bien que le
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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.
|
* 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
|
export interface PersistentFormProps
|
||||||
extends Omit<FormHTMLAttributes<HTMLFormElement>, 'action'> {
|
extends Omit<FormHTMLAttributes<HTMLFormElement>, 'action'> {
|
||||||
action: (formData: FormData) => void | Promise<void>;
|
action: (formData: FormData) => void | Promise<void>;
|
||||||
|
/**
|
||||||
|
* À 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;
|
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 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
|
* Le conditionner à l'issue supposerait de lire l'état de l'action au moment de
|
||||||
* de la remise à zéro. Or React remet le formulaire à zéro pendant la
|
* la remise à zéro. Or React remet le formulaire à zéro pendant la validation
|
||||||
* validation du rendu, tandis qu'un `useEffect` s'exécute après : le drapeau lu
|
* du rendu, tandis qu'un `useEffect` s'exécute après : le drapeau lu serait
|
||||||
* serait celui de l'envoi précédent, et le premier refus perdrait la saisie —
|
* 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.
|
* 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.
|
||||||
* 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é.
|
|
||||||
*/
|
*/
|
||||||
export function PersistentForm({
|
export function PersistentForm({
|
||||||
action,
|
action,
|
||||||
|
resetAfter,
|
||||||
children,
|
children,
|
||||||
...rest
|
...rest
|
||||||
}: PersistentFormProps) {
|
}: PersistentFormProps) {
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const snapshot = useRef<Array<[string, string]> | null>(null);
|
const snapshot = useRef<Captured[] | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const form = formRef.current;
|
const form = formRef.current;
|
||||||
if (!form) return;
|
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 = () => {
|
const capture = () => {
|
||||||
snapshot.current = [...form.elements]
|
snapshot.current = [...form.elements].filter(restorable).map((field) => ({
|
||||||
.filter(restorable)
|
name: field.name,
|
||||||
.map((field) => [field.name, field.value]);
|
value: field.value,
|
||||||
|
checked: field instanceof HTMLInputElement ? field.checked : false,
|
||||||
|
...(field instanceof HTMLSelectElement && field.multiple
|
||||||
|
? { selected: [...field.selectedOptions].map((o) => o.value) }
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const rewrite = () => {
|
const rewrite = () => {
|
||||||
@@ -78,10 +129,21 @@ export function PersistentForm({
|
|||||||
// Après la remise à zéro, pas pendant : l'événement `reset` précède
|
// 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.
|
// l'effacement, et écrire ici serait aussitôt défait.
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
for (const [name, value] of captured) {
|
for (const entry of captured) {
|
||||||
const field = form.elements.namedItem(name);
|
const field = form.elements.namedItem(entry.name);
|
||||||
if (field instanceof Element && restorable(field)) {
|
if (!restorable(field)) continue;
|
||||||
field.value = value;
|
|
||||||
|
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 (
|
return (
|
||||||
<form ref={formRef} action={action} {...rest}>
|
<form ref={formRef} action={action} {...rest}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PUBLICATION_INTENT_FIELD,
|
||||||
|
PUBLISH_INTENT,
|
||||||
|
} from '@/domain/planning/publication';
|
||||||
import { AuthorizationError, can, type Actor } from '@/domain/access/authorize';
|
import { AuthorizationError, can, type Actor } from '@/domain/access/authorize';
|
||||||
import { shiftMinutes } from '@/domain/counters/week';
|
import { shiftMinutes } from '@/domain/counters/week';
|
||||||
import {
|
import {
|
||||||
@@ -374,18 +378,33 @@ const publishInput = z.object({
|
|||||||
acknowledgement: z.string().trim().max(500).optional(),
|
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,
|
_previous: PlanningActionState,
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<PlanningActionState> {
|
): Promise<PlanningActionState> {
|
||||||
return setPublication(formData, true);
|
return setPublication(
|
||||||
}
|
formData,
|
||||||
|
formData.get(PUBLICATION_INTENT_FIELD) === PUBLISH_INTENT,
|
||||||
export async function unpublishWeekAction(
|
);
|
||||||
_previous: PlanningActionState,
|
|
||||||
formData: FormData,
|
|
||||||
): Promise<PlanningActionState> {
|
|
||||||
return setPublication(formData, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const duplicateInput = z.object({
|
const duplicateInput = z.object({
|
||||||
|
|||||||
@@ -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 section.getByRole('button', { name: 'Dépublier' }).click();
|
||||||
await expect(section.getByText('Brouillon')).toBeVisible();
|
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 }) => {
|
test('la navigation de semaine change la grille', async ({ page }) => {
|
||||||
|
|||||||
Reference in new issue
Block a user