Merge pull request #93 from IliesAllali/feat/fiche-pastilles-adoption

Fiche : pastilles, lieu de décès, adoption
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-18 23:50:10 +02:00
commit 6a9034cf98
15 files changed
+316 -63

No files matched your search

+5
View File
@@ -77,6 +77,7 @@ const personCreateSchema = z.object({
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
birthPlace: z.string().max(200).optional().nullable(),
deathPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
nationality: z.string().max(120).optional().nullable(),
@@ -94,6 +95,7 @@ const personUpdateSchema = z
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
birthPlace: z.string().max(200).optional().nullable(),
deathPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
nationality: z.string().max(120).optional().nullable(),
@@ -386,6 +388,7 @@ async function applyContributionChange(
deathDate: toDateOrNull(parsed.data.deathDate),
isDeceased: !parsed.data.deathDate && Boolean(parsed.data.isDeceased),
birthPlace: toTrimmedStringOrNull(parsed.data.birthPlace),
deathPlace: toTrimmedStringOrNull(parsed.data.deathPlace),
profession: toTrimmedStringOrNull(parsed.data.profession),
region: toTrimmedStringOrNull(parsed.data.region),
nationality: toTrimmedStringOrNull(parsed.data.nationality),
@@ -454,6 +457,7 @@ async function applyContributionChange(
deathDate: nextDates.deathDate,
isDeceased: nextDates.deathDate ? false : parsed.data.isDeceased,
birthPlace: parsed.data.birthPlace !== undefined ? toTrimmedStringOrNull(parsed.data.birthPlace) : undefined,
deathPlace: parsed.data.deathPlace !== undefined ? toTrimmedStringOrNull(parsed.data.deathPlace) : undefined,
profession: parsed.data.profession !== undefined ? toTrimmedStringOrNull(parsed.data.profession) : undefined,
region: parsed.data.region !== undefined ? toTrimmedStringOrNull(parsed.data.region) : undefined,
nationality: parsed.data.nationality !== undefined ? toTrimmedStringOrNull(parsed.data.nationality) : undefined,
@@ -852,6 +856,7 @@ async function snapshotEntity(
deathDate: true,
isDeceased: true,
birthPlace: true,
deathPlace: true,
profession: true,
region: true,
nationality: true,
+8
View File
@@ -93,6 +93,7 @@ const createPersonSchema = z.object({
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
birthPlace: z.string().max(200).optional().nullable(),
deathPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
nationality: z.string().max(120).optional().nullable(),
@@ -110,6 +111,7 @@ const patchPersonSchema = z
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
birthPlace: z.string().max(200).optional().nullable(),
deathPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
region: z.string().max(120).optional().nullable(),
nationality: z.string().max(120).optional().nullable(),
@@ -1319,6 +1321,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
// Une date de décès dit déjà que la personne est décédée : le drapeau ne sert que sans date
isDeceased: !payload.data.deathDate && Boolean(payload.data.isDeceased),
birthPlace: toTrimmedStringOrNull(payload.data.birthPlace),
deathPlace: toTrimmedStringOrNull(payload.data.deathPlace),
profession: toTrimmedStringOrNull(payload.data.profession),
region: toTrimmedStringOrNull(payload.data.region),
nationality: toTrimmedStringOrNull(payload.data.nationality),
@@ -1388,6 +1391,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
deathDate?: Date | null
isDeceased?: boolean
birthPlace?: string | null
deathPlace?: string | null
profession?: string | null
region?: string | null
nationality?: string | null
@@ -1432,6 +1436,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
data.birthPlace = toTrimmedStringOrNull(payload.data.birthPlace)
}
if (payload.data.deathPlace !== undefined) {
data.deathPlace = toTrimmedStringOrNull(payload.data.deathPlace)
}
if (payload.data.profession !== undefined) {
data.profession = toTrimmedStringOrNull(payload.data.profession)
}
+2
View File
@@ -14,6 +14,7 @@ export type GraphPayload = {
deathDate: string | null
isDeceased: boolean
birthPlace: string | null
deathPlace: string | null
profession: string | null
region: string | null
nationality: string | null
@@ -90,6 +91,7 @@ export function buildGraphPayload(
deathDate: formatDate(person.deathDate),
isDeceased: Boolean(person.isDeceased) && !person.deathDate,
birthPlace: person.birthPlace,
deathPlace: person.deathPlace,
profession: person.profession,
region: person.region,
nationality: person.nationality,
+26
View File
@@ -1008,3 +1008,29 @@ describe('Surnom (nickname) en écriture directe', () => {
await app.close()
})
})
describe('Lieu de décès et filiation adoptive (écriture)', () => {
it('PATCH accepte le lieu de décès, le coupe, et vide = null', async () => {
const app = createApp()
const token = await createUserToken(app)
const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload })
expect((await patch({ deathPlace: ' Nantes, France ' })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ deathPlace: 'Nantes, France' })
expect((await patch({ deathPlace: ' ' })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ deathPlace: null })
await app.close()
})
it('PATCH d’un lien accepte le type de filiation (naissance ou adoption)', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.parentChildLink.findFirst.mockResolvedValue({ id: 'link-1', parentPersonId: 'person-2', childPersonId: 'person-1', viaUnionId: null, parentageType: 'biologique' })
prismaMock.parentChildLink.updateMany.mockResolvedValueOnce({ count: 1 })
const res = await app.inject({ method: 'PATCH', url: '/trees/tree-1/links/link-1', headers: { authorization: `Bearer ${token}` }, payload: { parentageType: 'adoption' } })
expect(res.statusCode).toBe(200)
expect(prismaMock.parentChildLink.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ parentageType: 'adoption' })
await app.close()
})
})
+66 -7
View File
@@ -26,6 +26,7 @@ import { ExpandableSearch } from './components/search'
import { t, dateLocale } from './i18n/index.js'
import {
createParentChildLink,
updateParentChildLink,
deleteParentChildLink,
deletePerson,
deleteUnion,
@@ -257,9 +258,9 @@ function buildContributionChangesFromDraft(draft = {}) {
const relatedId = String(rel.personId)
if (rel.type === 'child') {
linkedParentIds.add(relatedId)
changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: relatedId, childPersonId: ref, parentageType: 'biologique' } })
changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: relatedId, childPersonId: ref, parentageType: rel.adopted ? 'adoption' : 'biologique' } })
} else if (rel.type === 'parent') {
changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: ref, childPersonId: relatedId, parentageType: 'biologique' } })
changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: ref, childPersonId: relatedId, parentageType: rel.adopted ? 'adoption' : 'biologique' } })
} else if (rel.type === 'spouse') {
changes.push({ entityType: 'union', action: 'create', after: { partner1PersonId: relatedId, partner2PersonId: ref, unionType: 'mariage' } })
}
@@ -276,6 +277,16 @@ function buildContributionChangesFromDraft(draft = {}) {
}
}
// Lien avec ses parents basculé (naissance / adoption) sur un lien existant
const parentageChanges = draft.parentageChanges && typeof draft.parentageChanges === 'object' ? draft.parentageChanges : {}
for (const [childId, parentageType] of Object.entries(parentageChanges)) {
for (const filiation of filiations) {
if (String(filiation.childId) !== String(childId) || String(filiation.id).startsWith('local-')) continue
if ((filiation.parentageType || 'biologique') === parentageType) continue
changes.push({ entityType: 'parent_child_link', action: 'update', entityId: String(filiation.id), after: { parentageType } })
}
}
const modifiedPersons = draft.modifiedPersons && typeof draft.modifiedPersons === 'object'
? draft.modifiedPersons
: {}
@@ -291,6 +302,7 @@ function buildContributionChangesFromDraft(draft = {}) {
if (Object.prototype.hasOwnProperty.call(source, 'birthYear')) after.birthDate = yearToIsoDate(source.birthYear)
if (Object.prototype.hasOwnProperty.call(source, 'deathYear')) after.deathDate = yearToIsoDate(source.deathYear)
if (Object.prototype.hasOwnProperty.call(source, 'birthPlace')) after.birthPlace = (source.birthPlace || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'deathPlace')) after.deathPlace = (source.deathPlace || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'profession')) after.profession = (source.profession || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'region')) after.region = (source.region || '').trim() || null
if (Object.prototype.hasOwnProperty.call(source, 'nationality')) after.nationality = (source.nationality || '').trim() || null
@@ -1040,7 +1052,7 @@ function App() {
childId: rel.personId,
parentId: personId,
unionId: null,
parentageType: 'biologique',
parentageType: rel.adopted ? 'adoption' : 'biologique',
displayOrder: filiations.length + 1,
})
} else if (rel.type === 'child') {
@@ -1049,7 +1061,7 @@ function App() {
childId: personId,
parentId: rel.personId,
unionId: null,
parentageType: 'biologique',
parentageType: rel.adopted ? 'adoption' : 'biologique',
displayOrder: filiations.length + 1,
})
}
@@ -1089,7 +1101,7 @@ function App() {
birthDate: formData.birthDate || null,
deathDate: formData.deathDate || null,
isDeceased: Boolean(formData.isDeceased),
relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId })),
relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId, adopted: Boolean(rel.adopted) })),
siblingParentLinks,
},
]
@@ -1127,13 +1139,13 @@ function App() {
linkedParentIds.add(String(rel.personId))
await createParentChildLink(
tree.treeContext.treeId,
{ parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: 'biologique', displayOrder: 1 },
{ parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 },
auth.userAuth.token
)
} else if (rel.type === 'parent') {
await createParentChildLink(
tree.treeContext.treeId,
{ parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: 'biologique', displayOrder: 1 },
{ parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 },
auth.userAuth.token
)
} else if (rel.type === 'spouse') {
@@ -1246,6 +1258,7 @@ function App() {
if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthYear')) payload.birthDate = yearToIsoDate(rawChanges.birthYear)
if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathYear')) payload.deathDate = yearToIsoDate(rawChanges.deathYear)
if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthPlace')) payload.birthPlace = (rawChanges.birthPlace || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathPlace')) payload.deathPlace = (rawChanges.deathPlace || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'profession')) payload.profession = (rawChanges.profession || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'region')) payload.region = (rawChanges.region || '').trim() || null
if (Object.prototype.hasOwnProperty.call(rawChanges, 'nationality')) payload.nationality = (rawChanges.nationality || '').trim() || null
@@ -1529,6 +1542,7 @@ function App() {
localPerson.deathYear = iso ? Number(iso.slice(0, 4)) : null
}
if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) localPerson.birthPlace = changes.birthPlace || null
if (Object.prototype.hasOwnProperty.call(changes, 'deathPlace')) localPerson.deathPlace = changes.deathPlace || null
if (Object.prototype.hasOwnProperty.call(changes, 'profession')) localPerson.profession = changes.profession || null
if (Object.prototype.hasOwnProperty.call(changes, 'region')) localPerson.region = changes.region || null
if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) localPerson.nationality = changes.nationality || null
@@ -1558,6 +1572,7 @@ function App() {
if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) payload.birthDate = yearToIsoDate(changes.birthYear)
if (Object.prototype.hasOwnProperty.call(changes, 'deathYear')) payload.deathDate = yearToIsoDate(changes.deathYear)
if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) payload.birthPlace = (changes.birthPlace || '').trim() || null
if (Object.prototype.hasOwnProperty.call(changes, 'deathPlace')) payload.deathPlace = (changes.deathPlace || '').trim() || null
if (Object.prototype.hasOwnProperty.call(changes, 'profession')) payload.profession = (changes.profession || '').trim() || null
if (Object.prototype.hasOwnProperty.call(changes, 'region')) payload.region = (changes.region || '').trim() || null
if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) payload.nationality = (changes.nationality || '').trim() || null
@@ -1807,6 +1822,48 @@ function App() {
}
}, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode, makeLocalId]) // eslint-disable-line react-hooks/exhaustive-deps
// Choix en attente d'un contributeur (brouillon), affiché sur la fiche avant l'envoi
const [pendingParentage, setPendingParentage] = useState({})
useEffect(() => { if (!editMode.isActive) setPendingParentage({}) }, [editMode.isActive]) // eslint-disable-line react-hooks/exhaustive-deps
// Lien avec ses parents : naissance ou adoption, pour tous les liens parent de la personne
const handleSetParentage = useCallback(async (childPersonId, parentageType) => {
const childLinks = filiations.filter((candidate) => String(candidate.childId) === String(childPersonId))
if (childLinks.length === 0) return
if (isDemoMode) {
for (const link of childLinks) link.parentageType = parentageType
tree.bumpGraphRevision()
setRelationFeedback({ loading: false, success: t('Lien modifié (démo locale)'), error: '' })
return
}
if (!tree.canEditCurrentTree) {
const draft = editMode.restoreDraft()
const parentageChanges = { ...(draft.parentageChanges || {}), [String(childPersonId)]: parentageType }
editMode.updateDraft({ parentageChanges })
setPendingParentage((prev) => ({ ...prev, [String(childPersonId)]: parentageType }))
setRelationFeedback({ loading: false, success: t('Modification ajoutée à la contribution'), error: '' })
return
}
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
if (!accessToken || !tree.treeContext.treeId) return
setRelationFeedback({ loading: true, success: '', error: '' })
try {
for (const link of childLinks) {
if (String(link.id).startsWith('local-') || (link.parentageType || 'biologique') === parentageType) continue
await updateParentChildLink(tree.treeContext.treeId, link.id, { parentageType }, accessToken)
}
await refreshTreeAndKeepSelection(childPersonId, { relayout: true })
setRelationFeedback({ loading: false, success: t('Lien modifié'), error: '' })
} catch (err) {
setRelationFeedback({ loading: false, success: '', error: getAccountErrorMessage(err) })
console.error('Failed to update parentage:', err)
}
}, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps
// Suppression d'une relation (mode édition)
const handleRemoveRelation = useCallback(async (sourcePersonId, targetPersonId, relationType) => {
if (isDemoMode) {
@@ -3038,6 +3095,8 @@ function App() {
onDelete={handlePersonDelete}
onAddRelation={handleAddRelation}
onRemoveRelation={handleRemoveRelation}
onSetParentage={handleSetParentage}
pendingParentage={pendingParentage[String(tree.selectedPerson.id)] ?? null}
relationActionLoading={relationFeedback.loading}
relationActionSuccess={relationFeedback.success}
relationActionError={relationFeedback.error}
+13
View File
@@ -235,6 +235,19 @@ export async function reorderSiblings(treeId, order, token) {
})
}
// Lien parent-enfant modifié (type de filiation : naissance ou adoption)
export async function updateParentChildLink(treeId, linkId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links/${encodeURIComponent(linkId)}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data.link
}
export async function deleteParentChildLink(treeId, linkId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links/${encodeURIComponent(linkId)}`, {
method: 'DELETE',
+11 -1
View File
@@ -42,6 +42,8 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
const [deathDate, setDeathDate] = useState('')
// Décédé(e) sans date connue (issue #77)
const [isDeceased, setIsDeceased] = useState(false)
// Lien d'enfant ou de parent : la personne qui est l'enfant du lien a été adoptée
const [adopted, setAdopted] = useState(false)
// Les champs de date gardent leur texte : on les recrée après chaque ajout
const [dateFieldsKey, setDateFieldsKey] = useState(0)
const [relations, setRelations] = useState([])
@@ -157,7 +159,8 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
birthDate: birthDate || null,
deathDate: deathDate || null,
isDeceased: !deathDate && isDeceased,
relations,
// Adopté(e) : s'applique aux liens d'enfant ou de parent de cette fiche
relations: relations.map((rel) => ({ ...rel, adopted: adopted && (rel.type === 'child' || rel.type === 'parent') })),
})
}
@@ -341,6 +344,13 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
)}
</div>
{(selectedRelationType === 'child' || selectedRelationType === 'parent') && (
<label className="apn-deceased">
<input type="checkbox" checked={adopted} onChange={(e) => setAdopted(e.target.checked)} />
{t('Adopté(e)')}
</label>
)}
{relations.length > 0 && (
<div className="apn-links">
{relations.map((rel) => (
@@ -76,6 +76,7 @@ const PERSON_FIELD_LABELS = {
nickname: t('Surnom'),
birthDate: t('Date de naissance'),
deathDate: t('Date de décès'),
deathPlace: t('Lieu de décès'),
birthPlace: t('Lieu de naissance'),
profession: t('Métier'),
nationality: t('Nationalité'),
+16
View File
@@ -256,3 +256,19 @@
.pc-unknown-first { display: inline-flex; align-items: center; gap: 7px; margin-top: 6px; font: 400 13px var(--pz-sans); color: var(--pz-ink-2); cursor: pointer; }
.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); }
/* 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; }
.pc-fill-chip { height: 30px; padding: 0 12px; border-radius: 999px; border: 1px dashed var(--pz-a300); background: var(--pz-a50); color: var(--pz-a700); font: 500 13px var(--pz-sans); cursor: pointer; }
@media (hover: hover) and (pointer: fine) { .pc-fill-chip:hover { background: var(--pz-a100); border-color: var(--pz-a500); } }
.pc-fill-chip:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
/* Liens : parents adoptifs, réglage de la filiation */
.pc-section-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 6px; }
.pc-adopt-tag { font: 500 12px var(--pz-sans); color: var(--pz-a700); background: var(--pz-a50); border: 1px solid var(--pz-a300); border-radius: 999px; padding: 1px 9px; }
.pc-parentage { display: grid; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--pz-line-soft); }
.pc-seg { width: 100%; }
.pc-seg .pz-tab { flex: 1; }
/* Titres de section de l'onglet Infos et Liens : casse normale, Newsreader 15 px, comme la maquette (.sec) */
.pc-infos .pz-eyebrow { text-transform: none; letter-spacing: 0; font: 400 15px/1.3 var(--pz-serif); color: var(--pz-ink); }
@@ -0,0 +1,55 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import PersonCard from './PersonCard.jsx'
// Onglet Infos : en modification, seuls les champs remplis s'affichent ; les vides sont des pastilles
const sparse = {
id: 'p-sparse',
firstName: 'Marie',
lastName: 'Martin',
nickname: 'Mamie Mimi',
isAlive: false,
birthDate: null,
deathDate: null,
}
const render = (person, editMode) => renderToStaticMarkup(
<PersonCard person={person} editMode={editMode} onSave={() => {}} />,
)
describe('PersonCard, onglet Infos', () => {
it('en modification, une pastille par champ vide et le champ rempli reste affiché', () => {
const html = render(sparse, true)
expect(html).toContain('+ Date de naissance')
expect(html).toContain('+ Lieu de naissance')
expect(html).toContain('+ Nom de naissance')
expect(html).toContain('+ Date de décès')
expect(html).toContain('+ Lieu de décès')
expect(html).toContain('+ Métier')
expect(html).toContain('+ Région')
expect(html).toContain('+ Nationalité')
expect(html).toContain('+ Notes')
// Le surnom est rempli : il n'a pas de pastille, il a son champ
expect(html).not.toContain('+ Surnom')
expect(html).toContain('Mamie Mimi')
})
it('une personne vivante n’a ni pastille de décès ni champ de décès', () => {
const html = render({ ...sparse, isAlive: true, nickname: null }, true)
expect(html).not.toContain('+ Date de décès')
expect(html).not.toContain('+ Lieu de décès')
expect(html).toContain('+ Surnom')
})
it('en lecture, pas de pastille, et le lieu de décès s’affiche quand il existe', () => {
const html = render({ ...sparse, deathPlace: 'Nantes, France', deathDate: '1970-03-02' }, false)
expect(html).not.toContain('pc-fill-chip')
expect(html).toContain('Lieu de décès')
expect(html).toContain('Nantes')
})
it('en lecture, le lieu de décès absent n’a pas de ligne', () => {
const html = render(sparse, false)
expect(html).not.toContain('Lieu de décès')
})
})
+101 -51
View File
@@ -58,6 +58,8 @@ const PersonCard = ({
onDelete,
onAddRelation,
onRemoveRelation,
onSetParentage,
pendingParentage = null,
relationActionLoading = false,
relationActionSuccess = '',
relationActionError = '',
@@ -124,6 +126,10 @@ const PersonCard = ({
const [relationSearchQuery, setRelationSearchQuery] = useState('')
const relationSearchRef = useRef(null)
const avatarInputRef = useRef(null)
// Infos en modification : champs ouverts depuis une pastille (et leur focus)
const [openedFields, setOpenedFields] = useState([])
const [focusField, setFocusField] = useState(null)
const bodyRef = useRef(null)
// Reset edits quand la personne change
useEffect(() => {
@@ -138,8 +144,18 @@ const PersonCard = ({
setShowSourceMenu(false)
setMomentSheet(null)
setLifeNotice('')
setOpenedFields([])
setFocusField(null)
}, [person.id])
// Pastille cliquée : le champ s'ouvre à sa place et prend le focus
useEffect(() => {
if (!focusField) return
const target = bodyRef.current?.querySelector(`[data-pc-field="${focusField}"] input, [data-pc-field="${focusField}"] textarea`)
target?.focus()
setFocusField(null)
}, [focusField])
// Connexion Immich de l'arbre : métadonnées sans clé. Une erreur (rôle périmé, réseau) se
// traduit par « non configuré » : la fiche reste utilisable, simplement sans choix de source.
// Seul un administrateur de l'arbre voit l'option Immich, comme côté serveur.
@@ -215,6 +231,42 @@ const PersonCard = ({
}))
}
// Onglet Infos en modification : seuls les champs remplis (ou ouverts) s'affichent, le reste est en pastilles
const isFilled = (value) => value !== null && value !== undefined && String(value).trim() !== ''
const openedHas = (field) => openedFields.includes(field)
const openField = (field) => {
setOpenedFields((prev) => (prev.includes(field) ? prev : [...prev, field]))
setFocusField(field)
}
const birthFilled = isFilled(getEditValue('birthYear', person.birthDate || person.birthYear))
const deathFilled = isFilled(getEditValue('deathYear', person.deathDate || person.deathYear))
const deceasedNow = editMode ? !getEditValue('isAlive', person.isAlive) : !person.isAlive
const notesShown = editMode && (isFilled(getEditValue('note', person.note)) || openedHas('note'))
// En lecture : toujours visible si demandé (naissance, décès d'une personne décédée), sinon si rempli
const infoRow = (field, label, filled, content, always = false) => {
const shown = editMode ? (filled || openedHas(field)) : (always || filled)
if (!shown) return null
return (
<div className="pc-row" data-pc-field={field}>
<span className="pc-label">{label}</span>
{content}
</div>
)
}
const chipCandidates = [
{ field: 'nickname', label: t('Surnom'), filled: isFilled(getEditValue('nickname', person.nickname)), relevant: true },
{ field: 'birthYear', label: t('Date de naissance'), filled: birthFilled, relevant: true },
{ field: 'birthPlace', label: t('Lieu de naissance'), filled: isFilled(getEditValue('birthPlace', person.birthPlace)), relevant: true },
{ field: 'deathYear', label: t('Date de décès'), filled: deathFilled, relevant: deceasedNow },
{ field: 'deathPlace', label: t('Lieu de décès'), filled: isFilled(getEditValue('deathPlace', person.deathPlace)), relevant: deceasedNow },
{ field: 'birthName', label: t('Nom de naissance'), filled: isFilled(getEditValue('birthName', person.birthName)), relevant: true },
{ field: 'profession', label: t('Métier'), filled: isFilled(getEditValue('profession', person.profession)), relevant: true },
{ field: 'region', label: t('Région'), filled: isFilled(getEditValue('region', person.region)), relevant: true },
{ field: 'nationality', label: t('Nationalité'), filled: isFilled(getEditValue('nationality', person.nationality)), relevant: true },
{ field: 'note', label: t('Notes'), filled: isFilled(getEditValue('note', person.note)), relevant: true },
]
const emptyChips = editMode ? chipCandidates.filter((c) => c.relevant && !c.filled && !openedHas(c.field)) : []
const handleSave = async () => {
if (!hasChanges || !onSave) return
if (!unknownFirstName && 'firstName' in edits && !String(edits.firstName).trim()) {
@@ -231,6 +283,7 @@ const PersonCard = ({
const savedFor = person.id
await onSave(savedFor, edits)
setEditState(prev => (prev.personId === savedFor ? { ...prev, changed: false } : prev))
setOpenedFields([])
setSaveSuccess(true)
setTimeout(() => setSaveSuccess(false), 2000)
} finally {
@@ -302,6 +355,18 @@ const PersonCard = ({
return childFiliations[0]
})()
const personMedias = getPersonMedias(person.id)
// Filiation d'adoption entre un enfant et un parent (lien direct ou via une union)
const parentageAdopted = (childId, parentId) => filiations.some((f) => (
String(f.childId) === String(childId)
&& f.parentageType === 'adoption'
&& (String(f.parentId) === String(parentId) || (f.unionId && personUnions.some((u) => String(u.id) === String(f.unionId))))
))
// Un contributeur voit son choix en attente avant l'envoi ; sinon le lien enregistré
const parentsAdopted = pendingParentage ? pendingParentage === 'adoption' : filiations.some((f) => String(f.childId) === String(person.id) && f.parentageType === 'adoption')
const adoptedChildLabel = (child) => {
if (!parentageAdopted(child.id, person.id)) return null
return child.sex === 'female' ? t('adoptée') : t('adopté')
}
// Frise de vie : la famille vient de l'arbre déjà chargé, les événements de la route /life,
// demandée seulement à l'ouverture de l'onglet (la démo a ses propres données)
@@ -733,32 +798,14 @@ const PersonCard = ({
{/* ── Infos ── */}
{tab === 'infos' && (
<div className="pc-infos">
<div className="pc-infos" ref={bodyRef}>
<section className="pc-section">
<h3 className="pz-eyebrow">{t('État civil')}</h3>
<div className="pc-rows">
<div className="pc-row">
<span className="pc-label">{t('Naissance')}</span>
{renderEditableValue({ field: 'birthYear', value: person.birthDate || person.birthYear, placeholder: t('Date de naissance'), type: 'date' })}
</div>
{(person.birthPlace || editMode) && (
<div className="pc-row">
<span className="pc-label">{t('Lieu')}</span>
{renderEditableValue({ field: 'birthPlace', value: person.birthPlace || '', placeholder: t('Lieu de naissance'), format: formatPlace })}
</div>
)}
{(person.birthName || editMode) && (
<div className="pc-row">
<span className="pc-label">{t('Nom de naissance')}</span>
{renderEditableValue({ field: 'birthName', value: person.birthName || '', placeholder: t('Nom de naissance') })}
</div>
)}
{(person.nickname || editMode) && (
<div className="pc-row">
<span className="pc-label">{t('Surnom')}</span>
{renderEditableValue({ field: 'nickname', value: person.nickname || '', placeholder: t('Surnom') })}
</div>
)}
{infoRow('birthYear', t('Naissance'), birthFilled, renderEditableValue({ field: 'birthYear', value: person.birthDate || person.birthYear, placeholder: t('Date de naissance'), type: 'date' }), true)}
{infoRow('birthPlace', t('Lieu'), isFilled(getEditValue('birthPlace', person.birthPlace)), renderEditableValue({ field: 'birthPlace', value: person.birthPlace || '', placeholder: t('Lieu de naissance'), format: formatPlace }))}
{infoRow('birthName', t('Nom de naissance'), isFilled(getEditValue('birthName', person.birthName)), renderEditableValue({ field: 'birthName', value: person.birthName || '', placeholder: t('Nom de naissance') }))}
{infoRow('nickname', t('Surnom'), isFilled(getEditValue('nickname', person.nickname)), renderEditableValue({ field: 'nickname', value: person.nickname || '', placeholder: t('Surnom') }))}
{editMode && (
<div className="pc-row">
<span className="pc-label">{t('Statut')}</span>
@@ -772,37 +819,28 @@ const PersonCard = ({
</button>
</div>
)}
{(!getEditValue('isAlive', person.isAlive) || (!editMode && !person.isAlive)) && (
<div className="pc-row">
<span className="pc-label">{t('Décès')}</span>
{renderEditableValue({ field: 'deathYear', value: person.deathDate || person.deathYear || '', placeholder: t('Date de décès'), type: 'date', emptyLabel: t('Date inconnue') })}
</div>
)}
{infoRow('deathYear', t('Décès'), deathFilled, renderEditableValue({ field: 'deathYear', value: person.deathDate || person.deathYear || '', placeholder: t('Date de décès'), type: 'date', emptyLabel: t('Date inconnue') }), deceasedNow)}
{infoRow('deathPlace', t('Lieu de décès'), isFilled(getEditValue('deathPlace', person.deathPlace)), renderEditableValue({ field: 'deathPlace', value: person.deathPlace || '', placeholder: t('Lieu de décès'), format: formatPlace }))}
{!editMode && (
<div className="pc-row">
<span className="pc-label">{t('Âge')}</span>
<span className="value">{ageLabel || '-'}</span>
</div>
)}
{(person.region || editMode) && (
<div className="pc-row">
<span className="pc-label">{t('Région')}</span>
{renderEditableValue({ field: 'region', value: person.region || '', placeholder: t('Région') })}
</div>
)}
{(person.nationality || editMode) && (
<div className="pc-row">
<span className="pc-label">{t('Nationalité')}</span>
{renderEditableValue({ field: 'nationality', value: person.nationality || '', placeholder: t('Nationalité') })}
</div>
)}
{(person.profession || editMode) && (
<div className="pc-row">
<span className="pc-label">{t('Métier')}</span>
{renderEditableValue({ field: 'profession', value: person.profession || '', placeholder: t('Métier') })}
</div>
)}
{infoRow('region', t('Région'), isFilled(getEditValue('region', person.region)), renderEditableValue({ field: 'region', value: person.region || '', placeholder: t('Région') }))}
{infoRow('nationality', t('Nationalité'), isFilled(getEditValue('nationality', person.nationality)), renderEditableValue({ field: 'nationality', value: person.nationality || '', placeholder: t('Nationalité') }))}
{infoRow('profession', t('Métier'), isFilled(getEditValue('profession', person.profession)), renderEditableValue({ field: 'profession', value: person.profession || '', placeholder: t('Métier') }))}
</div>
{/* Modification : une pastille par champ vide, qui ouvre le champ à sa place */}
{editMode && emptyChips.length > 0 && (
<div className="pc-fill-chips">
{emptyChips.map((chip) => (
<button key={chip.field} type="button" className="pc-fill-chip" onClick={() => openField(chip.field)}>
+ {chip.label}
</button>
))}
</div>
)}
</section>
{!editMode && (parentsList.length > 0 || personUnions.length > 0 || childrenList.length > 0) && (
@@ -844,8 +882,8 @@ const PersonCard = ({
</section>
)}
{(person.note || editMode) && (
<section className="pc-section">
{(editMode ? notesShown : person.note) && (
<section className="pc-section" data-pc-field="note">
<h3 className="pz-eyebrow">{t('Notes')}</h3>
{editMode ? (
<textarea
@@ -873,9 +911,21 @@ const PersonCard = ({
)}
<section className="pc-section">
<h3 className="pz-eyebrow">{t('Parents')}</h3>
<div className="pc-section-head">
<h3 className="pz-eyebrow">{t('Parents')}</h3>
{parentsAdopted && parentsList.length > 0 && <span className="pc-adopt-tag">{t('Parents adoptifs')}</span>}
</div>
{parentsList.length > 0 ? parentsList.map(p => renderRelationRow(p, 'parent')) : <p className="pc-rel-empty">{t("Aucun parent pour l'instant")}</p>}
{renderRelationAdd('parent', t('Ajouter un parent'), t('Rechercher un parent'))}
{parentsList.length > 0 && (
<div className="pc-parentage">
<span className="pc-label">{t('Lien avec ses parents')}</span>
<div className="pz-tabs pc-seg" role="radiogroup" aria-label={t('Lien avec ses parents')}>
<button type="button" role="radio" aria-checked={!parentsAdopted} className={`pz-tab ${!parentsAdopted ? 'is-active' : ''}`} onClick={() => onSetParentage?.(person.id, 'biologique')}>{t('Naissance')}</button>
<button type="button" role="radio" aria-checked={parentsAdopted} className={`pz-tab ${parentsAdopted ? 'is-active' : ''}`} onClick={() => onSetParentage?.(person.id, 'adoption')}>{t('Adoption')}</button>
</div>
</div>
)}
</section>
<section className="pc-section">
@@ -890,7 +940,7 @@ const PersonCard = ({
<section className="pc-section">
<h3 className="pz-eyebrow">{t('Enfants')}</h3>
{childrenList.length > 0 ? childrenList.map(c => renderRelationRow(c, 'child')) : <p className="pc-rel-empty">{t('Aucun enfant')}</p>}
{childrenList.length > 0 ? childrenList.map(c => renderRelationRow(c, 'child', adoptedChildLabel(c))) : <p className="pc-rel-empty">{t('Aucun enfant')}</p>}
{renderRelationAdd('child', t('Ajouter un enfant'), t('Rechercher un enfant'))}
</section>
</div>
+2 -4
View File
@@ -297,10 +297,8 @@ export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entr
})
for (const { sourceId, sourcePos, pType, items, progress } of sibships.values()) {
if (pType === 'adoption') {
ctx.setLineDash([8, 5])
ctx.strokeStyle = FIL.color
} else if (pType === 'inconnu') {
// Adoption : trait plein comme une filiation de naissance (le mode d'adoption se dit dans la fiche)
if (pType === 'inconnu') {
ctx.setLineDash([3, 3])
ctx.strokeStyle = COLORS.linkUnknown
} else {
+1
View File
@@ -115,6 +115,7 @@ function mapRemotePersonToLocal(person, current = null) {
frameType: current?.frameType || pickFrameType(person?.id),
note: person?.notes || null,
birthPlace: person?.birthPlace || null,
deathPlace: person?.deathPlace || null,
region: person?.region || null,
profession: person?.profession || null,
nationality: person?.nationality || null,
+3
View File
@@ -36,6 +36,9 @@ export default {
'Lien familial ajouté (démo locale)': 'Family link added (local demo)',
'Lien ajouté à la contribution': 'Link added to your contribution',
'Lien familial ajouté': 'Family link added',
'Lien modifié (démo locale)': 'Link changed (local demo)',
'Lien modifié': 'Link changed',
'Modification ajoutée à la contribution': 'Change added to the contribution',
'Relation introuvable pour suppression': 'Could not find this relationship to delete',
'Lien familial supprimé (démo locale)': 'Family link deleted (local demo)',
'Suppression de lien ajoutée à la contribution': 'Link deletion added to your contribution',
+6
View File
@@ -38,6 +38,12 @@ export default {
'Date de naissance': 'Date of birth',
'Lieu': 'Place',
'Lieu de naissance': 'Birthplace',
'Lieu de décès': 'Place of death',
'Parents adoptifs': 'Adoptive parents',
'Lien avec ses parents': 'Link with parents',
'adopté': 'adopted',
'adoptée': 'adopted',
'Adopté(e)': 'Adopted',
'Nom de naissance': 'Birth name',
'Surnom': 'Nickname',
'Loulou': 'Lulu',