Merge pull request #32 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre

Étendre la préservation des formulaires, et corriger la publication
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-10 07:57:59 +02:00
commit dbb0d68634
15 files changed
+254 -69

No files matched your search

+7 -2
View File
@@ -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 (
<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">
<Field label="Prénom" name="firstName" 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>
) : null}
</div>
</form>
</PersistentForm>
);
}
+4 -2
View File
@@ -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 ? (
<form
<PersistentForm
action={upload}
resetAfter={uploadState.ok ? uploadState : null}
className="flex flex-wrap items-end gap-3 border-t border-line-1 pt-4"
>
<input type="hidden" name="membershipId" value={membershipId} />
@@ -157,7 +159,7 @@ export function DocumentsPanel({
<Button type="submit" variant="primary" disabled={uploading}>
Déposer
</Button>
</form>
</PersistentForm>
) : null}
{uploadState.error ? (
@@ -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 (
<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">
<Field label="Nom" name="name" required placeholder="Nantes Atlantis" />
<Field
@@ -52,6 +57,6 @@ export function AddLocationForm() {
<span className="text-xs text-ok-soft-ink">Établissement créé.</span>
) : null}
</div>
</form>
</PersistentForm>
);
}
@@ -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 (
<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} />
<Field label="Nouvelle équipe" name="name" placeholder="Caisse" required />
<SubmitButton size="md">Ajouter</SubmitButton>
<div className="w-full">
<FormError>{state.error}</FormError>
</div>
</form>
</PersistentForm>
);
}
@@ -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 (
<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">
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">Domaine</span>
@@ -78,6 +83,6 @@ export function AddEntryForm() {
</span>
) : null}
</div>
</form>
</PersistentForm>
);
}
+4 -2
View File
@@ -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 (
<form
<PersistentForm
resetAfter={state.ok ? state : null}
action={formAction}
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 ? (
<p className="w-full text-xs text-ok-soft-ink">Demande enregistrée.</p>
) : null}
</form>
</PersistentForm>
);
}
+7 -2
View File
@@ -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 (
<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} />
<label className="sr-only" htmlFor={`start-${shift.id}`}>
@@ -90,7 +95,7 @@ export function ActualHoursForm({ shift }: { shift: HoursShiftRow }) {
{state.error}
</span>
) : null}
</form>
</PersistentForm>
);
}
+4 -2
View File
@@ -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 (
<form
<PersistentForm
resetAfter={state.ok ? state : null}
action={formAction}
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}>
Enregistrer
</Button>
</form>
</PersistentForm>
);
}
+18 -5
View File
@@ -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 (
<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="week" value={weekParam} />
{/* Verrou optimiste : la version lue au rendu est renvoyée telle quelle. */}
@@ -254,15 +262,20 @@ function PublishControl({
{state.error}
</span>
) : 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
type="submit"
name={PUBLICATION_INTENT_FIELD}
value={published ? UNPUBLISH_INTENT : PUBLISH_INTENT}
size="sm"
variant={published ? 'secondary' : 'primary'}
disabled={pending}
>
{published ? 'Dépublier' : 'Publier'}
</Button>
</form>
</PersistentForm>
);
}
@@ -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 (
<form
<PersistentForm
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"
>
<Field
@@ -143,7 +145,7 @@ export function EmailSettingsForm({
<span className="text-xs text-ok-soft-ink">{state.message}</span>
) : null}
</div>
</form>
</PersistentForm>
);
}
@@ -160,7 +162,7 @@ export function TestEmailForm({ defaultTo }: { defaultTo: string }) {
);
return (
<form
<PersistentForm
action={formAction}
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 ? (
<p className="w-full text-xs text-ok-soft-ink">{state.message}</p>
) : null}
</form>
</PersistentForm>
);
}
+7 -2
View File
@@ -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 (
<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
label="Objet"
name="objectType"
@@ -88,7 +93,7 @@ export function RetentionPolicyForm() {
</Button>
<Messages state={state} />
</div>
</form>
</PersistentForm>
);
}
+106 -33
View File
@@ -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<FormHTMLAttributes<HTMLFormElement>, 'action'> {
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;
}
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<HTMLFormElement>(null);
const snapshot = useRef<Array<[string, string]> | null>(null);
const snapshot = useRef<Captured[] | null>(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 (
<form ref={formRef} action={action} {...rest}>
{children}
+20
View File
@@ -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';
+28 -9
View File
@@ -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<PlanningActionState> {
return setPublication(formData, true);
}
export async function unpublishWeekAction(
_previous: PlanningActionState,
formData: FormData,
): Promise<PlanningActionState> {
return setPublication(formData, false);
return setPublication(
formData,
formData.get(PUBLICATION_INTENT_FIELD) === PUBLISH_INTENT,
);
}
const duplicateInput = z.object({
+22
View File
@@ -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 }) => {