mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-09 23:50:58 +00:00
Ajouter un proche depuis la fiche : créer depuis la recherche d'un lien, frère ou sœur, « + » en lecture, panneau prérempli, alerte de doublon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6e42a1f12d
commit
5ebfa8337d
8 files changed
+216
-6
No files matched your search
@@ -1104,6 +1104,22 @@ function App() {
|
||||
const [addPersonLoading, setAddPersonLoading] = useState(false)
|
||||
const [addPersonError, setAddPersonError] = useState('')
|
||||
|
||||
// Ajouter un proche depuis une fiche (1.5.1) : le panneau s'ouvre avec le lien déjà posé, le nom tapé dans la
|
||||
// recherche, et le nom de famille proposé pour un enfant ou un frère ou une sœur
|
||||
const [addPersonPrefill, setAddPersonPrefill] = useState(null)
|
||||
const handleCreateRelative = useCallback(async (type, query = '') => {
|
||||
const person = tree.selectedPerson
|
||||
if (!person) return
|
||||
const [first = '', ...rest] = String(query).trim().split(/\s+/)
|
||||
const inheritsName = type === 'child' || type === 'sibling'
|
||||
const lastName = rest.join(' ') || (inheritsName ? person.lastName || '' : '')
|
||||
const personName = [person.firstName, person.lastName].filter(Boolean).join(' ')
|
||||
if (!(await leaveSelectedPerson())) return
|
||||
await chooseSelectedPerson(null)
|
||||
setAddPersonPrefill({ nonce: Date.now(), firstName: first, lastName, relation: { type, personId: person.id, personName } })
|
||||
handleOpenAddPerson()
|
||||
}, [tree.selectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleOpenAddPerson = useCallback(() => {
|
||||
setAddPersonError('')
|
||||
// Le panneau d'ajout vit dans le mode édition : la famille y entre sans passer par le crayon
|
||||
@@ -3257,6 +3273,8 @@ function App() {
|
||||
error={addPersonError}
|
||||
onSubmit={handleAddPersonSubmit}
|
||||
onClose={handleCloseAddPerson}
|
||||
prefill={addPersonPrefill}
|
||||
onOpenPerson={(person) => { setAddPersonPanelVisible(false); handlePersonSelect(person) }}
|
||||
/>
|
||||
{/* Modal Session Contribution (contributeurs) */}
|
||||
<ContributionSessionModal
|
||||
@@ -3390,6 +3408,7 @@ function App() {
|
||||
onRemoveRelation={handleRemoveRelation}
|
||||
onSetParentage={handleSetParentage}
|
||||
pendingParentage={pendingParentage[String(tree.selectedPerson.id)] ?? null}
|
||||
onCreateRelative={tree.canEditCurrentTree || tree.canSubmitContribution || isDemoMode ? handleCreateRelative : null}
|
||||
proposal={ghosts?.patches.get(String(tree.selectedPerson.id)) || null}
|
||||
proposalReview={userRole.isAdmin && ghosts?.patches.get(String(tree.selectedPerson.id))?.ghost?.source === 'pending'
|
||||
? reviewFromTree(ghosts.patches.get(String(tree.selectedPerson.id)).ghost.sessionId)
|
||||
|
||||
@@ -120,3 +120,8 @@
|
||||
|
||||
/* Titre de section en casse normale (pas de sur-titre en capitales dans le panneau d'ajout) */
|
||||
.apn-section-title { font: 500 14px/1.3 var(--pz-sans); color: var(--pz-ink); margin: 0 0 10px; }
|
||||
|
||||
/* Doublon probable (1.5.1) : une personne du même nom est déjà dans l'arbre */
|
||||
.apn-duplicate { display: grid; gap: 6px; padding: 10px 12px; border: 1px solid var(--pz-a200); border-radius: 10px; background: var(--pz-a50); }
|
||||
.apn-duplicate-line { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 0; font: 500 13.5px var(--pz-sans); color: var(--pz-ink); }
|
||||
.apn-duplicate-hint { margin: 0; font: 400 12.5px var(--pz-sans); color: var(--pz-muted); }
|
||||
@@ -7,6 +7,7 @@ import { t } from '../i18n/index.js'
|
||||
import { firstNameLabel } from '../utils/personName.js'
|
||||
import { SEX_CHOICES } from '../utils/personSex.js'
|
||||
import { initialOf } from '../utils/initial.js'
|
||||
import { findLikelyDuplicates } from '../utils/duplicates.js'
|
||||
|
||||
// Titre traduit d'un bloc : le passage entre <em> et </em> garde son italique
|
||||
function withEmphasis(text) {
|
||||
@@ -33,7 +34,11 @@ function withEmphasis(text) {
|
||||
* @param {Function} props.onSubmit - Handler soumission (formData)
|
||||
* @param {Function} props.onClose - Handler fermeture
|
||||
*/
|
||||
function AddPersonPanel({ visible, persons = [], loading = false, error = '', onSubmit, onClose }) {
|
||||
/**
|
||||
* @param {Object} props.prefill - ouverture depuis une fiche (1.5.1) : { nonce, firstName, lastName, relation: { type, personId, personName } }
|
||||
* @param {Function} props.onOpenPerson - « Ouvrir sa fiche » sous l'alerte de doublon
|
||||
*/
|
||||
function AddPersonPanel({ visible, persons = [], loading = false, error = '', onSubmit, onClose, prefill = null, onOpenPerson = null }) {
|
||||
const [firstName, setFirstName] = useState('')
|
||||
// Prénom inconnu : enregistré vide, affiché « Prénom inconnu » dans l'arbre et la fiche
|
||||
const [unknownFirstName, setUnknownFirstName] = useState(false)
|
||||
@@ -61,6 +66,23 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
|
||||
const firstNameRef = useRef(null)
|
||||
const panelRef = useRef(null)
|
||||
|
||||
// Ouvert depuis une fiche : le lien avec la personne est déjà là, et le nom de famille proposé pour un enfant
|
||||
// ou un frère ou une sœur (modifiable). Avant, il fallait rechercher à nouveau la personne qu'on venait de quitter.
|
||||
useEffect(() => {
|
||||
if (!prefill?.nonce) return
|
||||
setFirstName(prefill.firstName || '')
|
||||
setUnknownFirstName(false)
|
||||
setLastName(prefill.lastName || '')
|
||||
setTouched({})
|
||||
if (prefill.relation?.personId != null) {
|
||||
setSelectedRelationType(prefill.relation.type)
|
||||
setRelations([{ personId: prefill.relation.personId, personName: prefill.relation.personName || '', type: prefill.relation.type }])
|
||||
}
|
||||
}, [prefill?.nonce]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Doublon probable : même prénom et même nom qu'une personne de l'arbre
|
||||
const duplicates = unknownFirstName ? [] : findLikelyDuplicates(persons, firstName, lastName)
|
||||
|
||||
// Focus sur Prénom à l'ouverture
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -251,6 +273,21 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
|
||||
<p className="apn-error-text">{t('Il faut au moins un prénom.')}</p>
|
||||
)}
|
||||
{lastNameMissing && <p className="apn-error-text">{t('Il faut au moins un nom si le prénom est inconnu.')}</p>}
|
||||
{duplicates.length > 0 && (
|
||||
<div className="apn-duplicate" role="status">
|
||||
{duplicates.map((p) => (
|
||||
<p key={p.id} className="apn-duplicate-line">
|
||||
<span>{p.birthYear
|
||||
? t('{name} ({year}) est déjà dans l’arbre.', { name: `${firstNameLabel(p)} ${p.lastName || ''}`.trim(), year: p.birthYear })
|
||||
: t('{name} est déjà dans l’arbre.', { name: `${firstNameLabel(p)} ${p.lastName || ''}`.trim() })}</span>
|
||||
{onOpenPerson && (
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onOpenPerson(p)}>{t('Ouvrir sa fiche')}</button>
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
<p className="apn-duplicate-hint">{t('Si c’est une autre personne, ajoutez-la quand même.')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pz-field">
|
||||
<label htmlFor="ap-birthName">{t('Nom de naissance')} <span className="apn-optional">{t('si différent')}</span></label>
|
||||
|
||||
@@ -304,3 +304,10 @@
|
||||
}
|
||||
.pc-quick-edit:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
|
||||
@media (hover: hover) and (pointer: fine) { .pc-quick-edit:hover { background: var(--pz-line-soft); } }
|
||||
|
||||
/* Ajouter un proche depuis la fiche en lecture (1.5.1) */
|
||||
.pc-quick-add { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.pc-quick-add .pz-btn { gap: 4px; }
|
||||
/* « Créer « Jeanne » comme parent » sous la recherche d'un proche */
|
||||
.pc-search-create .pc-rel-avatar { color: var(--pz-a700); background: var(--pz-a50); }
|
||||
.pc-search-create em { color: var(--pz-muted); font-style: normal; }
|
||||
@@ -70,6 +70,8 @@ const PersonCard = ({
|
||||
// Modification proposée et pas encore relue (fantôme, 1.5.1) : { fields, deleted, ghost }, et sa relecture
|
||||
proposal = null,
|
||||
proposalReview = null,
|
||||
// Créer un proche qui n'est pas encore dans l'arbre (1.5.1) : (type, nom tapé) ouvre l'ajout déjà rempli
|
||||
onCreateRelative = null,
|
||||
relationActionLoading = false,
|
||||
relationActionSuccess = '',
|
||||
relationActionError = '',
|
||||
@@ -617,14 +619,15 @@ const PersonCard = ({
|
||||
ageLabel && { key: 'age', node: ageLabel, nowrap: true },
|
||||
].filter(Boolean)
|
||||
|
||||
const renderRelationRow = (target, relationType, meta) => (
|
||||
// removable : un frère ou une sœur passe par ses parents, il ne se retire pas d'ici
|
||||
const renderRelationRow = (target, relationType, meta, removable = true) => (
|
||||
<div key={`${relationType}-${target?.id}`} className="pc-rel-row">
|
||||
<span className="pc-rel-avatar" aria-hidden="true">
|
||||
{target?.photo ? <img src={target.photo} alt="" /> : <span>{target?.firstName?.[0] || '?'}</span>}
|
||||
</span>
|
||||
<span className="pc-rel-name">{target ? fullNameLabel(target) : ''}</span>
|
||||
{meta && <span className="pc-rel-meta">{meta}</span>}
|
||||
<button
|
||||
{removable && <button
|
||||
type="button"
|
||||
className="pz-btn pz-btn--ghost pz-btn--icon pc-rel-remove"
|
||||
aria-label={t('Retirer {name}', { name: target ? fullNameLabel(target) : '' })}
|
||||
@@ -632,7 +635,7 @@ const PersonCard = ({
|
||||
disabled={relationActionLoading}
|
||||
>
|
||||
<X size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</button>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -646,6 +649,9 @@ const PersonCard = ({
|
||||
onSelect={(p) => handleRelationAdd(p, relationType)}
|
||||
onClose={() => { setActiveRelationSearch(null); setRelationSearchQuery('') }}
|
||||
placeholder={placeholder}
|
||||
// La recherche ne trouvait que les personnes déjà dans l'arbre : sans résultat, on s'arrêtait là
|
||||
onCreate={onCreateRelative ? (query) => { setActiveRelationSearch(null); setRelationSearchQuery(''); onCreateRelative(relationType, query) } : null}
|
||||
createLabel={relationType === 'parent' ? t('comme parent') : relationType === 'child' ? t('comme enfant') : t('comme conjoint·e')}
|
||||
/>
|
||||
) : (
|
||||
<button type="button" className="pc-rel-add" onClick={() => setActiveRelationSearch(relationType)} disabled={relationActionLoading}>
|
||||
@@ -1046,6 +1052,18 @@ const PersonCard = ({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Ajouter un proche sans passer par « Modifier » puis l'onglet Relations (1.5.1) */}
|
||||
{!editMode && onCreateRelative && (
|
||||
<div className="pc-quick-add" role="group" aria-label={t('Ajouter un proche')}>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onCreateRelative('parent', '')}><Plus size={14} strokeWidth={2} />{t('Parent')}</button>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onCreateRelative('spouse', '')}><Plus size={14} strokeWidth={2} />{t('Conjoint·e')}</button>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onCreateRelative('child', '')}><Plus size={14} strokeWidth={2} />{t('Enfant')}</button>
|
||||
{parentsList.length > 0 && (
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onCreateRelative('sibling', '')}><Plus size={14} strokeWidth={2} />{t('Frère ou sœur')}</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(editMode ? notesShown : person.note) && (
|
||||
<section className="pc-section" data-pc-field="note">
|
||||
<h3 className="pz-eyebrow">{t('Notes')}</h3>
|
||||
@@ -1107,6 +1125,24 @@ const PersonCard = ({
|
||||
{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>
|
||||
|
||||
{onCreateRelative && (
|
||||
<section className="pc-section">
|
||||
<h3 className="pz-eyebrow">{t('Frères et sœurs')}</h3>
|
||||
{siblingsList.length > 0
|
||||
? siblingsList.map(c => renderRelationRow(c, 'sibling', null, false))
|
||||
: <p className="pc-rel-empty">{t('Aucun frère ni sœur')}</p>}
|
||||
{/* Un frère ou une sœur reçoit les mêmes parents : sans parent, on le dit avant d'ouvrir l'ajout */}
|
||||
{parentsList.length > 0 ? (
|
||||
<button type="button" className="pc-rel-add" onClick={() => onCreateRelative('sibling', '')}>
|
||||
<Plus size={15} strokeWidth={2} />
|
||||
{t('Ajouter un frère ou une sœur')}
|
||||
</button>
|
||||
) : (
|
||||
<p className="pc-rel-empty">{t("Ajoutez d'abord un parent pour lui rattacher un frère ou une sœur.")}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1244,7 +1280,7 @@ const PersonCard = ({
|
||||
* Recherche en ligne pour ajouter un lien (parent, conjoint·e, enfant)
|
||||
*/
|
||||
const RelationSearchInline = forwardRef(function RelationSearchInline(
|
||||
{ query, onQueryChange, results, onSelect, onClose, placeholder },
|
||||
{ query, onQueryChange, results, onSelect, onClose, placeholder, onCreate = null, createLabel = '' },
|
||||
ref
|
||||
) {
|
||||
const filtered = results.slice(0, 6)
|
||||
@@ -1286,9 +1322,15 @@ const RelationSearchInline = forwardRef(function RelationSearchInline(
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : query ? (
|
||||
) : query && !onCreate ? (
|
||||
<p className="pc-rel-empty">{t('Aucune personne ne correspond')}</p>
|
||||
) : null}
|
||||
{onCreate && query.trim() && (
|
||||
<button type="button" className="pc-search-item pc-search-create" onClick={() => onCreate(query.trim())}>
|
||||
<span className="pc-rel-avatar" aria-hidden="true"><Plus size={14} strokeWidth={2.2} /></span>
|
||||
<span className="pc-rel-name">{t('Créer « {name} »', { name: query.trim() })} <em>{createLabel}</em></span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import PersonCard from './PersonCard.jsx'
|
||||
import ProposalCard, { ProposalBanner } from './ProposalCard.jsx'
|
||||
import { findLikelyDuplicates } from '../utils/duplicates.js'
|
||||
import { summarizeChanges } from '../utils/proposalSummary.js'
|
||||
|
||||
// 1.5.1 : propositions sur l'arbre (fantômes) et ajout d'un proche depuis la fiche. Les droits de relecture sont
|
||||
// tenus par le serveur (backend/test/propositions-fantomes.db.test.ts) ; ici, ce que chacun voit.
|
||||
const noop = () => {}
|
||||
const ghostPerson = (source) => ({ id: 'ghost:s1:tmp:a', firstName: 'Lucie', lastName: 'Martin', birthYear: 1990, ghost: { source, sessionId: 's1', label: 'Cousine', createdAt: '2026-10-25T10:00:00Z' } })
|
||||
const review = { busy: false, error: '', onAccept: noop, onReject: noop, onOpenDetails: noop }
|
||||
|
||||
describe('carte d’une proposition', () => {
|
||||
it('qui gère l’arbre peut accepter, refuser ou voir le détail', () => {
|
||||
const html = renderToStaticMarkup(<ProposalCard person={ghostPerson('pending')} summary="1 personne, 1 lien" review={review} onClose={noop} />)
|
||||
expect(html).toContain('Proposé par Cousine')
|
||||
expect(html).toContain('Accepter')
|
||||
expect(html).toContain('Refuser')
|
||||
// t() pose une espace fine insécable devant les deux-points
|
||||
expect(html).toMatch(/Dans cette proposition\s:\s1 personne, 1 lien\./)
|
||||
})
|
||||
|
||||
it('la famille lit seulement qu’elle attend, sans bouton de relecture', () => {
|
||||
const html = renderToStaticMarkup(<ProposalCard person={ghostPerson('sent')} review={review} onClose={noop} />)
|
||||
expect(html).toContain('en attente de relecture')
|
||||
expect(html).not.toContain('Accepter')
|
||||
expect(renderToStaticMarkup(<ProposalCard person={ghostPerson('draft')} onClose={noop} />)).toContain('Pas encore envoyé')
|
||||
})
|
||||
|
||||
it('le bandeau d’une fiche existante liste les champs proposés', () => {
|
||||
const html = renderToStaticMarkup(<ProposalBanner patch={{ fields: { birthPlace: 'Lille', deathDate: '1950-01-01' }, ghost: { source: 'pending', label: 'Cousine' } }} review={review} />)
|
||||
expect(html).toContain('Lieu de naissance')
|
||||
expect(html).toContain('Lille')
|
||||
expect(html).toContain('1950')
|
||||
expect(html).toContain('Accepter')
|
||||
})
|
||||
|
||||
it('le résumé compte ce que contient la proposition', () => {
|
||||
expect(summarizeChanges([
|
||||
{ entityType: 'person', action: 'create' },
|
||||
{ entityType: 'parent_child_link', action: 'create' },
|
||||
{ entityType: 'union', action: 'create' },
|
||||
{ entityType: 'annotation', action: 'create' },
|
||||
])).toBe("1 personne, 2 liens, 1 ajout sur l'arbre")
|
||||
})
|
||||
})
|
||||
|
||||
describe('ajouter un proche depuis la fiche', () => {
|
||||
const person = { id: 'p1', firstName: 'Marie', lastName: 'Martin', isAlive: true }
|
||||
it('la fiche en lecture propose parent, conjoint·e et enfant quand App le permet ; sinon rien', () => {
|
||||
const html = renderToStaticMarkup(<PersonCard person={person} onSave={noop} onCreateRelative={noop} />)
|
||||
expect(html).toContain('pc-quick-add')
|
||||
expect(html).toContain('Conjoint·e')
|
||||
// Sans parent connu, pas de frère ou sœur à rattacher
|
||||
expect(html).not.toContain('Frère ou sœur')
|
||||
expect(renderToStaticMarkup(<PersonCard person={person} onSave={noop} />)).not.toContain('pc-quick-add')
|
||||
})
|
||||
|
||||
it('le doublon probable ignore accents, casse et espaces, et regarde aussi le nom de naissance', () => {
|
||||
const persons = [
|
||||
{ id: 'a', firstName: 'Hélène', lastName: 'Dupont' },
|
||||
{ id: 'b', firstName: 'Helene', lastName: 'Martin', birthName: 'Dupont' },
|
||||
{ id: 'c', firstName: 'Hélène', lastName: 'Durand' },
|
||||
{ id: 'g', firstName: 'Hélène', lastName: 'Dupont', ghost: { source: 'pending' } },
|
||||
]
|
||||
expect(findLikelyDuplicates(persons, ' helene ', 'DUPONT').map((p) => p.id)).toEqual(['a', 'b'])
|
||||
expect(findLikelyDuplicates(persons, 'Hélène', '')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -495,4 +495,20 @@ export default {
|
||||
'Chargement de la photo': 'Loading photo',
|
||||
'Impossible d’afficher cette photo.': 'Unable to display this photo.',
|
||||
'Les photos venues d’Immich ne se recadrent pas pour l’instant.': 'Photos from Immich cannot be cropped for now.',
|
||||
|
||||
// ─── Ajouter un proche depuis la fiche (1.5.1) ───
|
||||
'{name} ({year}) est déjà dans l’arbre.': '{name} ({year}) is already in the tree.',
|
||||
'{name} est déjà dans l’arbre.': '{name} is already in the tree.',
|
||||
'Ouvrir sa fiche': 'Open their profile',
|
||||
'Si c’est une autre personne, ajoutez-la quand même.': "If it's someone else, add them anyway.",
|
||||
'comme parent': 'as a parent',
|
||||
'comme enfant': 'as a child',
|
||||
'comme conjoint·e': 'as a partner',
|
||||
'Ajouter un proche': 'Add a relative',
|
||||
'Frère ou sœur': 'Sibling',
|
||||
'Frères et sœurs': 'Siblings',
|
||||
'Aucun frère ni sœur': 'No siblings',
|
||||
'Ajouter un frère ou une sœur': 'Add a sibling',
|
||||
"Ajoutez d'abord un parent pour lui rattacher un frère ou une sœur.": 'Add a parent first to link a sibling.',
|
||||
'Créer « {name} »': 'Create “{name}”',
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Doublon probable à l'ajout d'une personne (1.5.1) : même prénom, et même nom ou nom de naissance, sans tenir
|
||||
// compte des accents, de la casse ni des espaces en trop. Trois au plus.
|
||||
function norm(value) {
|
||||
return String(value || '').normalize('NFD').replace(/\p{M}/gu, '').trim().toLowerCase().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
export function findLikelyDuplicates(persons = [], firstName = '', lastName = '') {
|
||||
const first = norm(firstName)
|
||||
const last = norm(lastName)
|
||||
if (!first || !last) return []
|
||||
return persons
|
||||
.filter((p) => !p.ghost && norm(p.firstName) === first && (norm(p.lastName) === last || norm(p.birthName) === last))
|
||||
.slice(0, 3)
|
||||
}
|
||||
Reference in new issue
Block a user