mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #101 from IliesAllali/feat/enregistrement-auto
Fiche : enregistrement automatique en quittant
This commit is contained in:
7 files changed
+141
-24
No files matched your search
+57
-18
@@ -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}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user