mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-13 00:16:09 +00:00
Merge origin/fix/nom-facultatif into fix/fiche-entete-partage
This commit is contained in:
commit
0f6ce774e7
10 files changed
+120
-36
No files matched your search
@@ -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())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 //')
|
||||
})
|
||||
})
|
||||
@@ -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; }
|
||||
@@ -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]) => (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
}
|
||||
Reference in new issue
Block a user