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:
15 files changed
+254
-69
No files matched your search
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 { 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({
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in new issue
Block a user