Merge pull request #101 from IliesAllali/feat/enregistrement-auto

Fiche : enregistrement automatique en quittant
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-19 10:23:06 +02:00
commit 86e1d959a7
7 files changed
+141 -24

No files matched your search

+57 -18
View File
@@ -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 && (
<PersonCard
person={tree.selectedPerson}
onClose={() => tree.setSelectedPerson(null)}
onClose={() => { chooseSelectedPerson(null) }}
onMediaSelect={handleMediaSelectFresh}
saveOnLeaveRef={personSaveOnLeaveRef}
editMode={editMode.isActive}
canManageMedia={tree.canSubmitContribution || tree.canEditCurrentTree}
onManageMedia={media.handleOpenMediaManager}
+1
View File
@@ -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; }
+37 -6
View File
@@ -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
@@ -1000,6 +1030,7 @@ const PersonCard = ({
</div>
{/* ── Actions d'édition ── */}
{editMode && saveError && <p className="pc-save-error" role="alert">{saveError}</p>}
{editMode && (
<footer className="pc-foot">
{showDeleteConfirm ? (
+2
View File
@@ -101,4 +101,6 @@ export default {
'Conflit détecté (email ou slug déjà utilisé).': 'Conflict detected (email or slug already in use).',
"Erreur backend. Vérifiez que l'API et la base sont lancées.": 'Server error. Check that the API and database are running.',
'Le serveur ne répond pas. Vérifiez la connexion et réessayez.': 'The server is not responding. Check your connection and try again.',
// App.jsx : enregistrement automatique en quittant une fiche
'Modifications de {name} enregistrées': 'Changes to {name} saved',
}
+1
View File
@@ -176,6 +176,7 @@ export default {
'Marie': 'Mary',
'Dupont': 'Smith',
'Il faut au moins un prénom.': 'A first name is required.',
"L'enregistrement a échoué. Vos modifications sont gardées, réessayez.": 'Saving failed. Your changes are kept, try again.',
'si différent': 'if different',
'Martin': 'Johnson',
'Place dans la famille': 'Place in the family',
+8
View File
@@ -0,0 +1,8 @@
// Enregistrement d'une fiche en modification, partagé par le bouton « Enregistrer » et le départ de la fiche.
// Un appel pendant un enregistrement reçoit la même promesse : pas de double envoi.
export function runOnce(inFlightRef, run) {
if (inFlightRef.current) return inFlightRef.current
const promise = Promise.resolve().then(run).finally(() => { inFlightRef.current = null })
inFlightRef.current = promise
return promise
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from 'vitest'
import { runOnce } from './saveOnLeave.js'
const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
describe('runOnce (enregistrement de fiche)', () => {
it('un second appel pendant l’envoi reçoit le même résultat, sans second envoi', async () => {
const ref = { current: null }
const send = vi.fn(async () => { await tick(); return 'saved' })
const first = runOnce(ref, send)
const second = runOnce(ref, send)
expect(second).toBe(first)
expect(await first).toBe('saved')
expect(send).toHaveBeenCalledTimes(1)
})
it('une fois l’envoi terminé, un nouvel appel enregistre à nouveau (nouvelle modification)', async () => {
const ref = { current: null }
const send = vi.fn(async () => 'saved')
await runOnce(ref, send)
expect(ref.current).toBeNull()
await runOnce(ref, send)
expect(send).toHaveBeenCalledTimes(2)
})
it('un échec se propage au résultat et libère le verrou pour une nouvelle tentative', async () => {
const ref = { current: null }
const send = vi.fn()
.mockImplementationOnce(async () => { throw new Error('réseau') })
.mockImplementationOnce(async () => 'saved')
await expect(runOnce(ref, send)).rejects.toThrow('réseau')
expect(ref.current).toBeNull()
expect(await runOnce(ref, send)).toBe('saved')
})
})