From a25f263451f6d6b6d4fa3ae990e432d7932cce21 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 01:08:26 +0200 Subject: [PATCH] fiche: enregistrer automatiquement en quittant la fiche MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les modifications en cours d'une fiche ne sont plus perdues quand on change de personne, ferme la fiche, sort du mode modification, ouvre un souvenir, ajoute une personne ou envoie les contributions. - La fiche enregistre par le même chemin que « Enregistrer » (onSave), pour la personne quittée. Un échec ou une validation la garde ouverte avec le message d'erreur. - Invité en contribution : les changements vont dans le brouillon, puis partent avec « Envoyer ». - Un petit message confirme l'enregistrement. Rien n'est enregistré quand rien n'a changé, ni en démo. - Un second enregistrement pendant l'envoi reçoit la même promesse (utils/saveOnLeave.js), sans double appel. Co-Authored-By: Claude Haiku 5.5 --- frontend/src/App.jsx | 75 +++++++++++++++++++------- frontend/src/components/PersonCard.css | 1 + frontend/src/components/PersonCard.jsx | 43 ++++++++++++--- frontend/src/i18n/en/app.js | 2 + frontend/src/i18n/en/fiche.js | 1 + frontend/src/utils/saveOnLeave.js | 8 +++ frontend/src/utils/saveOnLeave.test.js | 35 ++++++++++++ 7 files changed, 141 insertions(+), 24 deletions(-) create mode 100644 frontend/src/utils/saveOnLeave.js create mode 100644 frontend/src/utils/saveOnLeave.test.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e6ba106..df28b4a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,6 +4,7 @@ import Galaxy from './components/Galaxy' import PersonCard from './components/PersonCard.jsx' import { FamilyWelcome, SouvenirPicker } from './components/FamilyOnboarding.jsx' import { HELP_URL } from './utils/siteLinks.js' +import { firstNameLabel } from './utils/personName.js' import { hasSeenFamilyWelcome, markFamilyWelcomeSeen } from './utils/familyWelcome' import MediaViewer from './components/MediaViewer' import MediaManagerPanel from './components/MediaManagerPanel' @@ -563,6 +564,10 @@ function App() { const [familyWelcomeVisible, setFamilyWelcomeVisible] = useState(false) const [souvenirPickerVisible, setSouvenirPickerVisible] = useState(false) const [pendingMediaPersonId, setPendingMediaPersonId] = useState(null) + // Message bref (toast) : enregistrement automatique d'une fiche, erreurs d'ordre des fratries + const [draftNotice, setDraftNotice] = useState('') + // Fiche en modification : elle enregistre ses changements avant qu'on la quitte (PersonCard, saveOnLeaveRef) + const personSaveOnLeaveRef = useRef(null) useEffect(() => { if (isFamilyVisitor && tree.bootState === 'ready' && !hasSeenFamilyWelcome(tree.treeContext.treeId)) { @@ -811,29 +816,55 @@ function App() { updateDraft: editMode.updateDraft, }) - const handlePersonSelect = useCallback((personLike) => { + // Quitter la fiche (changer de personne, la fermer, sortir du mode modification, envoyer, ouvrir un + // souvenir) : ses changements sont enregistrés d'abord. Faux si ça échoue : la fiche reste ouverte + // avec le message d'erreur, la saisie n'est pas jetée. En démo rien n'est persisté : on ne sauvegarde pas. + const leaveSelectedPerson = useCallback(async () => { + const current = tree.selectedPerson + const saveOnLeave = personSaveOnLeaveRef.current + if (!current || !saveOnLeave || isDemoMode) return true + const outcome = await saveOnLeave() + if (outcome === 'saved') setDraftNotice(t('Modifications de {name} enregistrées', { name: firstNameLabel(current) })) + return outcome !== 'blocked' + }, [tree.selectedPerson, isDemoMode]) + + const chooseSelectedPerson = useCallback(async (next) => { + if (!(await leaveSelectedPerson())) return false + tree.setSelectedPerson(next) + return true + }, [leaveSelectedPerson, tree.setSelectedPerson]) + + const deactivateEditMode = useCallback(async () => { + if (!(await leaveSelectedPerson())) return + editMode.deactivate() + }, [leaveSelectedPerson, editMode.deactivate]) + + const handlePersonSelect = useCallback(async (personLike) => { const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike if (personId === null || personId === undefined) { - tree.setSelectedPerson(null) + await chooseSelectedPerson(null) return } + const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null + if (!(await leaveSelectedPerson())) return + // Cliquer une personne pendant un ajout ferme le panneau d'ajout (il passe au-dessus de la fiche) ; // sa saisie reste en mémoire et reprend quand on rouvre « Ajouter » setAddPersonPanelVisible(false) - const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null tree.setSelectedPerson(freshPerson) trackTreeInteraction('text') trackDemoInteraction('person') - }, [tree.setSelectedPerson, trackTreeInteraction, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps + }, [chooseSelectedPerson, leaveSelectedPerson, tree.setSelectedPerson, trackTreeInteraction, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps - const handleMediaSelectFresh = useCallback((media, personLike) => { + const handleMediaSelectFresh = useCallback(async (media, personLike) => { const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike const freshPerson = personId !== null && personId !== undefined ? persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null : personLike || null + if (!(await leaveSelectedPerson())) return tree.handleMediaSelect(media, freshPerson) trackTreeInteraction(toInteractionMediaType(media?.type)) trackDemoInteraction('media') @@ -895,22 +926,26 @@ function App() { const handleEditToggle = useCallback(() => { if (editMode.isActive && editMode.hasDraft) { setShowConfirmModal(true) + } else if (editMode.isActive) { + deactivateEditMode() } else { - if (!editMode.isActive) trackDemoInteraction('edit') + trackDemoInteraction('edit') editMode.toggle() } - }, [editMode.isActive, editMode.hasDraft, editMode.toggle, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps + }, [editMode.isActive, editMode.hasDraft, editMode.toggle, deactivateEditMode, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps // Actions de la modal de confirmation const handleConfirmContinue = useCallback(() => { setShowConfirmModal(false) }, []) - const handleConfirmSaveAndQuit = useCallback(() => { + const handleConfirmSaveAndQuit = useCallback(async () => { + const left = await leaveSelectedPerson() + setShowConfirmModal(false) + if (!left) return editMode.saveDraft() editMode.deactivate() - setShowConfirmModal(false) - }, [editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps + }, [leaveSelectedPerson, editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps const handleConfirmDiscard = useCallback(() => { editMode.clearDraft() @@ -966,11 +1001,11 @@ function App() { setSouvenirPickerVisible(true) }, [tree.treeContext.treeId]) - const handleSouvenirPick = useCallback((person) => { + const handleSouvenirPick = useCallback(async (person) => { + if (!(await chooseSelectedPerson(person))) return setSouvenirPickerVisible(false) - tree.setSelectedPerson(person) setPendingMediaPersonId(person.id) - }, [tree.setSelectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps + }, [chooseSelectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps const handleSouvenirPickerAddPerson = useCallback(() => { setSouvenirPickerVisible(false) @@ -984,6 +1019,8 @@ function App() { const handleAddPersonSubmit = useCallback(async (formData) => { if (!tree.treeContext.treeId && !isDemoMode) return + // La fiche ouverte est enregistrée avant que la nouvelle personne ne la remplace + if (!(await leaveSelectedPerson())) return setAddPersonLoading(true) setAddPersonError('') @@ -1193,7 +1230,6 @@ function App() { const [contribSubmitLoading, setContribSubmitLoading] = useState(false) const [contribSubmitError, setContribSubmitError] = useState('') const [contribSuccess, setContribSuccess] = useState(false) - const [draftNotice, setDraftNotice] = useState('') // Ordre d'une fratrie (glisser dans l'arbre ou flèches de la fiche) : appliqué tout de suite, enregistré ensuite. // L'arbre a déjà glissé à la nouvelle place ; en cas d'échec il revient en arrière et on le dit. @@ -1233,6 +1269,8 @@ function App() { // Soumission des modifications const handleEditSubmit = useCallback(async () => { + // Les changements de la fiche ouverte doivent être dans le brouillon avant l'envoi + if (!(await leaveSelectedPerson())) return if (isDemoMode) { // Démo: édition locale uniquement, jamais de persistance serveur. editMode.saveDraft() @@ -2967,11 +3005,11 @@ function App() { hasResults={search.hasResults} onSearch={search.handleSearch} onClear={search.clearSearch} - onResultClick={(person) => { + onResultClick={async (person) => { const fullPerson = persons.find(p => p.id === person.id) if (!fullPerson) return trackDemoInteraction('search') - tree.setSelectedPerson(fullPerson) + if (!(await chooseSelectedPerson(fullPerson))) return galaxyZoomApiRef.current?.focusPerson?.(fullPerson.id) }} /> @@ -2988,7 +3026,7 @@ function App() { hasDraft={editMode.hasDraft} draftTimestamp={editMode.draftTimestamp} role={userRole.isAdmin ? 'admin' : 'contributor'} - onDeactivate={editMode.deactivate} + onDeactivate={deactivateEditMode} onSubmit={handleEditSubmit} /> {/* Confirmation Modal (quitter mode édition avec draft) */} @@ -3104,8 +3142,9 @@ function App() { {tree.selectedPerson && ( tree.setSelectedPerson(null)} + onClose={() => { chooseSelectedPerson(null) }} onMediaSelect={handleMediaSelectFresh} + saveOnLeaveRef={personSaveOnLeaveRef} editMode={editMode.isActive} canManageMedia={tree.canSubmitContribution || tree.canEditCurrentTree} onManageMedia={media.handleOpenMediaManager} diff --git a/frontend/src/components/PersonCard.css b/frontend/src/components/PersonCard.css index d1754b6..547b951 100644 --- a/frontend/src/components/PersonCard.css +++ b/frontend/src/components/PersonCard.css @@ -270,6 +270,7 @@ .pc-unknown-first { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; margin: -4px 0 0; font: 400 12.5px var(--pz-sans); color: var(--pz-ink-2); cursor: pointer; white-space: nowrap; } .pc-unknown-first input { width: 15px; height: 15px; accent-color: var(--pz-a500); } .pc-name-error { margin: 6px 0 0; font: 500 12.5px var(--pz-sans); color: var(--pz-danger); } +.pc-save-error { margin: 0; font: 500 12.5px/1.4 var(--pz-sans); color: var(--pz-danger); } /* Modification de l'onglet Infos : pastilles des champs vides (DA terracotta, sans nouvelle couleur) */ .pc-fill-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index b4cd7c0..b7ce816 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -17,6 +17,7 @@ import { formatPlace } from '../utils/place.js' import { firstNameLabel, fullNameLabel, isFirstNameUnknown } from '../utils/personName.js' import { isYearOnly } from '../utils/dateInput.js' import DateField from './DateField.jsx' +import { runOnce } from '../utils/saveOnLeave.js' // Aucune modification en cours (référence stable) const NO_EDITS = Object.freeze({}) @@ -70,6 +71,7 @@ const PersonCard = ({ onMoveSibling, onUploadMomentFile, onMomentSaved, + saveOnLeaveRef = null, }) => { // Souvenirs d'abord quand il y en a, ou quand on peut en ajouter : c'est là qu'est le bouton const [tab, setTab] = useState(() => (getPersonMedias(person.id).length > 0 || canManageMedia ? 'medias' : 'infos')) @@ -114,6 +116,9 @@ const PersonCard = ({ const [lastNameMissing, setLastNameMissing] = useState(false) const [saving, setSaving] = useState(false) const [saveSuccess, setSaveSuccess] = useState(false) + const [saveError, setSaveError] = useState('') + // Enregistrement en cours : un second appel (bouton, puis changement de fiche) reçoit la même promesse + const saveInFlight = useRef(null) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showImmichPanel, setShowImmichPanel] = useState(false) // null : connexion Immich de l'arbre inconnue, puis booléen. Le menu de source ne se propose @@ -149,6 +154,7 @@ const PersonCard = ({ setActiveRelationSearch(null) setRelationSearchQuery('') setSaveSuccess(false) + setSaveError('') setShowSourceMenu(false) setMomentSheet(null) setLifeNotice('') @@ -313,29 +319,53 @@ const PersonCard = ({ ] const emptyChips = editMode ? chipCandidates.filter((c) => c.relevant && !c.filled && !openedHas(c.field)) : [] - const handleSave = async () => { - if (!hasChanges || !onSave) return + // Enregistre les modifications de cette fiche (bouton « Enregistrer » et départ de la fiche). + // Résultat : 'saved', 'unchanged' (rien à enregistrer) ou 'blocked' (validation, ou échec réseau : + // le message reste sur la fiche, la saisie n'est pas jetée). + const persistEdits = () => runOnce(saveInFlight, async () => { + if (!hasChanges || !onSave) return 'unchanged' if (!unknownFirstName && 'firstName' in edits && !String(edits.firstName).trim()) { setFirstNameMissing(true) - return + return 'blocked' } // Prénom inconnu : il faut alors un nom de famille if (unknownFirstName && !String(getEditValue('lastName', person.lastName) ?? '').trim()) { setLastNameMissing(true) - return + return 'blocked' } + const savedFor = person.id setSaving(true) + setSaveError('') try { - const savedFor = person.id await onSave(savedFor, edits) setEditState(prev => (prev.personId === savedFor ? { ...prev, changed: false } : prev)) setOpenedFields([]) setSaveSuccess(true) setTimeout(() => setSaveSuccess(false), 2000) + return 'saved' + } catch (error) { + // Serveur injoignable (status 0, message traduit par le client) ; sinon un texte clair, jamais un message technique + setSaveError(error?.status === 0 ? error.message : t("L'enregistrement a échoué. Vos modifications sont gardées, réessayez.")) + return 'blocked' } finally { setSaving(false) } - } + }) + + // App enregistre la fiche avant de la quitter (changement de personne, fermeture, sortie du mode + // modification) : la référence suit toujours la dernière version de persistEdits + useEffect(() => { + if (!saveOnLeaveRef) return undefined + saveOnLeaveRef.current = persistEdits + return () => { if (saveOnLeaveRef.current === persistEdits) saveOnLeaveRef.current = null } + }) + + // Sortie du mode modification : les modifications restantes (abandonnées) ne reviennent pas à la prochaine entrée + useEffect(() => { + if (!editMode) setEditState({ personId: person.id, values: {}, changed: false }) + }, [editMode]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleSave = () => { persistEdits() } const handleDelete = () => { if (!onDelete) return @@ -998,6 +1028,7 @@ const PersonCard = ({ {/* ── Actions d'édition ── */} + {editMode && saveError &&

{saveError}

} {editMode && (