mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-15 16:32:03 +00:00
Merge remote-tracking branch 'origin/main' into feat/barre-c
# Conflicts: # frontend/src/components/navbar/ContextualNavbar.css
This commit is contained in:
commit
dfc73858af
9 files changed
+182
-31
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
|
||||
@@ -436,6 +466,8 @@ const PersonCard = ({
|
||||
unions: personUnions.map((union) => ({
|
||||
...union,
|
||||
partner: persons.find((p) => p.id === (union.partner1Id === person.id ? union.partner2Id : union.partner1Id)) || null,
|
||||
// Enfants du couple : placent dans la frise une union dont la date manque
|
||||
childIds: filiations.filter((f) => f.unionId && String(f.unionId) === String(union.id)).map((f) => f.childId),
|
||||
})),
|
||||
}
|
||||
|
||||
@@ -998,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',
|
||||
|
||||
@@ -107,7 +107,7 @@ const siblingWord = (p) => {
|
||||
|
||||
/**
|
||||
* @param person personne de la fiche (birthDate, deathDate, birthPlace, birthYear, deathYear)
|
||||
* @param family { parents, children, siblings, unions: [{ id, partner, startYear, endYear, unionType }] }
|
||||
* @param family { parents, children, siblings, unions: [{ id, partner, startYear, endYear, unionType, childIds }] }
|
||||
* @param life réponse de GET /trees/:id/persons/:personId/life, ou null tant qu'elle n'est pas là
|
||||
* @returns entrées triées { key, year, kind: 'mile' | 'minor' | 'fam', title, meta, note, sources, mediaIds,
|
||||
* event (moment modifiable), milestone ({ type, unionId } d'une naissance, d'un mariage, d'un décès) }
|
||||
@@ -144,10 +144,18 @@ export function buildLifeTimeline(person, family = {}, life = null) {
|
||||
const partnerName = union.partner ? fullName(union.partner) : t('Conjoint inconnu')
|
||||
const place = formatPlace(detail.startPlace || '')
|
||||
const extra = sourcesOf('marriage', union.id)
|
||||
// Union sans date : à sa place logique, juste avant le premier enfant du couple, sinon avant la fin de
|
||||
// l'union ou le décès du conjoint. Aucune année n'est inventée, la colonne reste vide.
|
||||
const childIds = new Set((union.childIds || []).map(String))
|
||||
const childYears = (family.children || []).filter((c) => childIds.has(String(c.id))).map((c) => yearOf(c.birthDate) ?? c.birthYear).filter(within)
|
||||
const partnerDeath = union.partner ? yearOf(union.partner.deathDate) ?? union.partner.deathYear : null
|
||||
const anchor = year ? null : (childYears.length ? Math.min(...childYears) : null) ?? union.endYear ?? (within(partnerDeath) ? partnerDeath : null)
|
||||
out.push({
|
||||
key: `union-${union.id}`, year, order: 2, kind: 'mile',
|
||||
key: `union-${union.id}`, year, sortYear: anchor, order: year ? 2 : 1, kind: 'mile',
|
||||
title: union.unionType === 'union' ? t('Union avec {name}', { name: partnerName }) : t('Épouse {name}', { name: partnerName }),
|
||||
meta: joinMeta(place, detail.startDatePrecision ? describeDate(detail.startDate, null, detail.startDatePrecision) : '', ageAt(birthYear, year)),
|
||||
meta: year
|
||||
? joinMeta(place, detail.startDatePrecision ? describeDate(detail.startDate, null, detail.startDatePrecision) : '', ageAt(birthYear, year))
|
||||
: joinMeta(place, t('date inconnue')),
|
||||
note: extra?.notes || '', sources: extra?.sources || '',
|
||||
...milestoneFields(extra, { type: 'marriage', unionId: union.id }),
|
||||
})
|
||||
@@ -171,7 +179,7 @@ export function buildLifeTimeline(person, family = {}, life = null) {
|
||||
}
|
||||
out.push({
|
||||
key: `event-${event.id}`, year, order: 1, kind: 'minor', title,
|
||||
meta: joinMeta(formatPlace(event.place || ''), describeDate(event.date, event.dateEnd, event.datePrecision), ageAt(birthYear, year)),
|
||||
meta: joinMeta(formatPlace(event.place || ''), year ? describeDate(event.date, event.dateEnd, event.datePrecision) : t('date inconnue'), ageAt(birthYear, year)),
|
||||
note: event.notes || '', sources: event.sources || '',
|
||||
mediaIds: event.mediaIds || [],
|
||||
// Seuls les moments propres à la personne se modifient depuis l'onglet (pas ceux d'une union)
|
||||
@@ -206,9 +214,9 @@ export function buildLifeTimeline(person, family = {}, life = null) {
|
||||
})
|
||||
}
|
||||
|
||||
// Les entrées sans date restent en bas, dans l'ordre où elles sont arrivées
|
||||
// Une union sans date se range à sa place logique (sortYear) ; le reste sans date va en bas, dans l'ordre d'arrivée
|
||||
return out
|
||||
.map((entry, index) => ({ ...entry, index }))
|
||||
.sort((a, b) => (a.year ?? Infinity) - (b.year ?? Infinity) || a.order - b.order || a.index - b.index)
|
||||
.map(({ order, index, ...entry }) => entry) // eslint-disable-line no-unused-vars
|
||||
.sort((a, b) => (a.year ?? a.sortYear ?? Infinity) - (b.year ?? b.sortYear ?? Infinity) || a.order - b.order || a.index - b.index)
|
||||
.map(({ order, index, sortYear, ...entry }) => entry) // eslint-disable-line no-unused-vars
|
||||
}
|
||||
@@ -66,6 +66,30 @@ describe('frise de vie', () => {
|
||||
expect(entries.map((e) => [e.title, e.year, e.meta])).toEqual([['Naissance', 1900, ''], ['Décès', null, 'date inconnue']])
|
||||
})
|
||||
|
||||
it('union sans date : avant le premier enfant du couple, sinon avant le décès du conjoint, sinon en bas', () => {
|
||||
const lucie = { id: 1, firstName: 'Lucie', birthYear: 1940 }
|
||||
const entries = buildLifeTimeline(lucie, {
|
||||
children: [{ id: 7, firstName: 'Hugo', birthYear: 1966 }, { id: 8, firstName: 'Léa', birthYear: 1981 }],
|
||||
unions: [
|
||||
{ id: 'u1', partner: { id: 2, firstName: 'Marc', lastName: 'Roy', deathYear: 2013 }, unionType: 'mariage', childIds: [7] },
|
||||
{ id: 'u2', partner: { id: 3, firstName: 'Paul', lastName: 'Vidal', deathYear: 2016 }, unionType: 'mariage', childIds: [] },
|
||||
{ id: 'u3', partner: { id: 4, firstName: 'Yves', lastName: 'Mallet' }, unionType: 'mariage' },
|
||||
],
|
||||
}, null)
|
||||
expect(entries.map((e) => e.title)).toEqual([
|
||||
'Naissance', 'Épouse Marc Roy', 'Naissance de Hugo', 'Naissance de Léa', 'Décès de Marc', 'Épouse Paul Vidal', 'Décès de Paul', 'Épouse Yves Mallet',
|
||||
])
|
||||
// Aucune année inventée : colonne vide, « date inconnue » dit pourquoi
|
||||
for (const key of ['union-u1', 'union-u2', 'union-u3']) {
|
||||
expect(entries.find((e) => e.key === key)).toMatchObject({ year: undefined, meta: 'date inconnue' })
|
||||
}
|
||||
expect(entries.find((e) => e.key === 'union-u1')).not.toHaveProperty('sortYear')
|
||||
})
|
||||
|
||||
it('un moment sans date dit « date inconnue »', () => {
|
||||
expect(entries.find((e) => e.title === 'Résidence').meta).toBe('Rue des Écoles · date inconnue')
|
||||
})
|
||||
|
||||
it('dit les dates comme on les dit', () => {
|
||||
expect(describeDate('1879-01-01', null, 'about')).toBe('vers 1879')
|
||||
expect(describeDate('1879-01-01', null, 'before')).toBe('avant 1879')
|
||||
|
||||
@@ -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