Merge origin/fix/nom-facultatif into fix/fiche-entete-partage

This commit is contained in:
IliesAllali committed 2026-10-18 22:26:48 +02:00
commit 0f6ce774e7
10 files changed
+120 -36

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
// Nom de famille facultatif, prénom facultatif (prénom inconnu = vide) : une personne garde au moins un des deux
export function hasAnyName(firstName: string, lastName: string): boolean {
return Boolean(firstName.trim() || lastName.trim())
}
+14 -3
View File
@@ -13,6 +13,7 @@ import type { Actor, MembershipRole } from '../types/auth.js'
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
import { nextSiblingOrder } from '../lib/sibling-order.js'
import { personPrecisionResets, unionPrecisionResets } from '../lib/date-precision.js'
import { hasAnyName } from '../lib/person-names.js'
const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/
@@ -69,7 +70,7 @@ const reviewSessionSchema = z.object({
const personCreateSchema = z.object({
firstName: z.string().trim().max(120),
lastName: z.string().min(1).max(120),
lastName: z.string().trim().max(120),
birthName: z.string().max(120).optional().nullable(),
nickname: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
@@ -81,12 +82,12 @@ const personCreateSchema = z.object({
nationality: z.string().max(120).optional().nullable(),
sex: z.string().max(20).optional().nullable(),
notes: z.string().max(2000).optional().nullable(),
})
}).refine((value) => hasAnyName(value.firstName, value.lastName), { message: 'name_required', path: ['lastName'] })
const personUpdateSchema = z
.object({
firstName: z.string().trim().max(120).optional(),
lastName: z.string().min(1).max(120).optional(),
lastName: z.string().trim().max(120).optional(),
birthName: z.string().max(120).optional().nullable(),
nickname: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
@@ -427,6 +428,16 @@ async function applyContributionChange(
})
: null
if (parsed.data.firstName !== undefined || parsed.data.lastName !== undefined) {
const before = await tx.person.findFirst({
where: { id: change.entityId, treeId, deletedAt: null },
select: { firstName: true, lastName: true },
})
if (before && !hasAnyName(parsed.data.firstName ?? before.firstName, parsed.data.lastName ?? before.lastName)) {
throw new ContributionRouteError(400, 'invalid_change_after')
}
}
const result = await tx.person.updateMany({
where: {
id: change.entityId,
+15 -3
View File
@@ -16,6 +16,7 @@ import { reserveTreeUnlockAttempt, serializePasswordCheck } from '../lib/unlock-
import type { MembershipRole } from '../types/auth.js'
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
import { personPrecisionResets, unionPrecisionResets } from '../lib/date-precision.js'
import { hasAnyName } from '../lib/person-names.js'
import { ContributionRouteError, resolveSubmissionContext } from './contributions.js'
const MAX_TREES_PER_ACCOUNT = 10
@@ -85,7 +86,7 @@ const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/
const createPersonSchema = z.object({
firstName: z.string().trim().max(120),
lastName: z.string().min(1).max(120),
lastName: z.string().trim().max(120),
birthName: z.string().max(120).optional().nullable(),
nickname: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
@@ -97,12 +98,12 @@ const createPersonSchema = z.object({
nationality: z.string().max(120).optional().nullable(),
sex: z.string().max(20).optional().nullable(),
notes: z.string().max(2000).optional().nullable(),
})
}).refine((value) => hasAnyName(value.firstName, value.lastName), { message: 'name_required', path: ['lastName'] })
const patchPersonSchema = z
.object({
firstName: z.string().trim().max(120).optional(),
lastName: z.string().min(1).max(120).optional(),
lastName: z.string().trim().max(120).optional(),
birthName: z.string().max(120).optional().nullable(),
nickname: z.string().max(120).optional().nullable(),
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
@@ -1367,6 +1368,17 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
return reply.code(403).send({ error: 'forbidden' })
}
// Au moins un nom : on regarde le résultat de la modification, pas seulement ce qu'elle envoie
if (payload.data.firstName !== undefined || payload.data.lastName !== undefined) {
const before = await app.prisma.person.findFirst({
where: { id: params.data.personId, treeId: params.data.id, deletedAt: null },
select: { firstName: true, lastName: true },
})
if (before && !hasAnyName(payload.data.firstName ?? before.firstName, payload.data.lastName ?? before.lastName)) {
return reply.code(400).send({ error: 'invalid_payload', details: 'name_required' })
}
}
const data: {
firstName?: string
lastName?: string
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { hasAnyName } from '../src/lib/person-names'
import { gedcomNameValue } from '../src/routes/gedcom'
// Nom de famille facultatif : une personne garde au moins un prénom ou un nom
describe('au moins un nom', () => {
it('un prénom ou un nom suffit', () => {
expect(hasAnyName('Léa', '')).toBe(true)
expect(hasAnyName('', 'Lambert')).toBe(true)
expect(hasAnyName(' ', ' Lambert ')).toBe(true)
})
it('ni prénom ni nom : refusé', () => {
expect(hasAnyName('', '')).toBe(false)
expect(hasAnyName(' ', ' ')).toBe(false)
})
it('export GEDCOM sans nom de famille : « Léa // », valide', () => {
expect(gedcomNameValue('Léa', '')).toBe('Léa //')
})
})
+2 -1
View File
@@ -228,7 +228,8 @@ function buildContributionChangesFromDraft(draft = {}) {
for (const person of addedPersons) {
if (!person || typeof person !== 'object') continue
// Prénom inconnu (chaîne vide) : la personne part quand même, nom de famille obligatoire comme avant
if (!person.lastName) continue
// Nom de famille facultatif : seule une personne sans aucun nom est ignorée
if (!person.firstName && !person.lastName) continue
// Identifiant provisoire : le serveur le remplace par le vrai id pour créer les liens
const ref = typeof person.ref === 'string' && person.ref.startsWith('tmp:') ? person.ref : null
@@ -111,3 +111,6 @@
/* Colonnes alignées en haut : la case « décédé(e) » sous le décès ne décale pas la naissance */
.apn-row { align-items: start; }
/* 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; }
+28 -14
View File
@@ -49,6 +49,8 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
const [relationDropdownOpen, setRelationDropdownOpen] = useState(false)
const [selectedRelationType, setSelectedRelationType] = useState('parent')
const [touched, setTouched] = useState({})
// Prénom inconnu sans nom de famille : la personne n'aurait aucun nom
const [lastNameMissing, setLastNameMissing] = useState(false)
const [justAdded, setJustAdded] = useState(false)
const firstNameRef = useRef(null)
@@ -103,6 +105,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
setDateFieldsKey((k) => k + 1)
setRelations([])
setTouched({})
setLastNameMissing(false)
setJustAdded(false)
firstNameRef.current?.focus()
}
@@ -139,6 +142,11 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
return
}
if (unknownFirstName && !lastName.trim()) {
setLastNameMissing(true)
return
}
setJustAdded(true)
onSubmit({
@@ -176,7 +184,6 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
>
<div className="apn-head">
<div className="pz-modal-head">
<p className="pz-eyebrow">{t('Nouvelle fiche')}</p>
<h2 id="addPersonTitle" className="pz-title">{withEmphasis(t("Ajouter <em>quelqu'un</em>"))}</h2>
</div>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon apn-close" onClick={onClose} aria-label={t('Fermer')}>
@@ -201,6 +208,20 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
disabled={unknownFirstName}
aria-invalid={touched.firstName && !unknownFirstName && !firstName.trim()}
/>
{/* Visible tant que le prénom est vide, et tant qu'elle est cochée (pour pouvoir la décocher) */}
{(!firstName.trim() || unknownFirstName) && (
<label className="apn-deceased">
<input
type="checkbox"
checked={unknownFirstName}
onChange={(e) => {
setUnknownFirstName(e.target.checked)
setTouched({})
}}
/>
{t('Je ne connais pas son prénom')}
</label>
)}
</div>
<div className="pz-field">
<label htmlFor="ap-lastName">{t('Nom')}</label>
@@ -208,26 +229,19 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
id="ap-lastName"
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
onChange={(e) => {
setLastName(e.target.value)
setLastNameMissing(false)
}}
placeholder={t('Dupont')}
autoComplete="off"
/>
</div>
</div>
<label className="apn-deceased">
<input
type="checkbox"
checked={unknownFirstName}
onChange={(e) => {
setUnknownFirstName(e.target.checked)
setTouched({})
}}
/>
{t('Je ne connais pas son prénom')}
</label>
{touched.firstName && !unknownFirstName && !firstName.trim() && (
<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>}
<div className="pz-field">
<label htmlFor="ap-birthName">{t('Nom de naissance')} <span className="apn-optional">{t('si différent')}</span></label>
@@ -272,7 +286,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
</div>
<section className="apn-section">
<h3 className="pz-eyebrow">{t('Place dans la famille')}</h3>
<h3 className="apn-section-title">{t('Place dans la famille')}</h3>
<div className="pz-tabs apn-types" role="radiogroup" aria-label={t('Type de lien')}>
{Object.entries(relationTypeTabs).map(([type, label]) => (
+30 -14
View File
@@ -98,6 +98,7 @@ const PersonCard = ({
// Prénom inconnu (case cochée) et saisie de prénom vide sans case : le prénom est demandé avant l'enregistrement
const [unknownFirstName, setUnknownFirstName] = useState(() => isFirstNameUnknown(person))
const [firstNameMissing, setFirstNameMissing] = useState(false)
const [lastNameMissing, setLastNameMissing] = useState(false)
const [hasChanges, setHasChanges] = useState(false)
const [saving, setSaving] = useState(false)
const [saveSuccess, setSaveSuccess] = useState(false)
@@ -122,6 +123,7 @@ const PersonCard = ({
setEdits({})
setUnknownFirstName(isFirstNameUnknown(person))
setFirstNameMissing(false)
setLastNameMissing(false)
setHasChanges(false)
setShowDeleteConfirm(false)
setActiveRelationSearch(null)
@@ -209,6 +211,11 @@ const PersonCard = ({
setFirstNameMissing(true)
return
}
// Prénom inconnu : il faut alors un nom de famille
if (unknownFirstName && !String(getEditValue('lastName', person.lastName) ?? '').trim()) {
setLastNameMissing(true)
return
}
setSaving(true)
try {
await onSave(person.id, edits)
@@ -556,27 +563,36 @@ const PersonCard = ({
type="text"
className="pc-input pc-input--name pc-input--last"
value={getEditValue('lastName', person.lastName)}
onChange={(e) => handleFieldChange('lastName', e.target.value)}
onChange={(e) => {
setLastNameMissing(false)
handleFieldChange('lastName', e.target.value)
}}
placeholder={t('Nom')}
aria-label={t('Nom')}
/>
<label className="pc-unknown-first">
<input
type="checkbox"
checked={unknownFirstName}
onChange={(e) => {
setUnknownFirstName(e.target.checked)
setFirstNameMissing(false)
handleFieldChange('firstName', '')
}}
/>
{t('Je ne connais pas son prénom')}
</label>
{/* Visible tant que le prénom est vide, et tant qu'elle est cochée (pour pouvoir la décocher) */}
{(unknownFirstName || !String(getEditValue('firstName', person.firstName) ?? '').trim()) && (
<label className="pc-unknown-first">
<input
type="checkbox"
checked={unknownFirstName}
onChange={(e) => {
setUnknownFirstName(e.target.checked)
setFirstNameMissing(false)
setLastNameMissing(false)
handleFieldChange('firstName', '')
}}
/>
{t('Je ne connais pas son prénom')}
</label>
)}
{firstNameMissing && <p className="pc-name-error">{t('Il faut au moins un prénom.')}</p>}
{lastNameMissing && <p className="pc-name-error">{t('Il faut au moins un nom si le prénom est inconnu.')}</p>}
</div>
) : (
<h2 className="pc-name">
{isFirstNameUnknown(person) ? <span className="pc-name-unknown">{firstNameLabel(person)}</span> : person.firstName} <em>{person.lastName}</em>
{isFirstNameUnknown(person) ? <span className="pc-name-unknown">{firstNameLabel(person)}</span> : person.firstName}
{person.lastName && <>{' '}<em>{person.lastName}</em></>}
</h2>
)}
{!editMode && metaItems.length > 0 && (
+1 -1
View File
@@ -102,7 +102,7 @@ function mapRemotePersonToLocal(person, current = null) {
id: person?.id ?? current?.id,
// Prénom inconnu = chaîne vide renvoyée par l'API (pas de repli sur l'ancien nom lors d'un rafraîchissement)
firstName: person ? (person.firstName ?? '') : (current?.firstName ?? ''),
lastName: person?.lastName || current?.lastName || '',
lastName: person ? (person.lastName ?? '') : (current?.lastName ?? ''),
birthName: person?.birthName ?? current?.birthName ?? null,
nickname: person?.nickname ?? null,
birthYear,
+1
View File
@@ -407,4 +407,5 @@ export default {
'Décédé(e), date inconnue': 'Deceased, date unknown',
'Prénom inconnu': 'Unknown first name',
'Je ne connais pas son prénom': "I don't know their first name",
'Il faut au moins un nom si le prénom est inconnu.': 'A last name is required when the first name is unknown.',
}