Refactor validation handling in PersonEdit.vue: Replace hasValidationError and validationError with hasViolation and violationTitles. Introduce hasViolationWithParameter and violationTitlesWithParameter for enhanced field validation. Update RequiredIdentifierConstraint messages, improve API error mapping, and refine ValidationException structure with violationsList. Add tests and translations for identifier validation.

This commit is contained in:
2025-09-18 16:12:05 +02:00
parent 52404956d2
commit a5b06de92a
9 changed files with 92 additions and 44 deletions

View File

@@ -25,14 +25,13 @@ export interface TransportExceptionInterface {
name: string;
}
// Strict : uniquement les clés déclarées dans M[K]
export type ViolationFromMap<
M extends Record<string, Record<string, unknown>>,
> = {
[K in Extract<keyof M, string>]: {
propertyPath: K;
title: string;
parameters?: M[K]; // ← uniquement ces clés (pas dextras)
parameters?: M[K];
type?: string;
};
}[Extract<keyof M, string>];
@@ -53,18 +52,19 @@ export interface ValidationExceptionInterface<
>,
> extends Error {
name: "ValidationException";
/** Copie du payload serveur (utile pour logs/diagnostic) */
problem: ValidationProblemFromMap<M>;
/** Liste compacte "Titre: chemin" */
/** Full server payload copy */
problems: ValidationProblemFromMap<M>;
/** A list of all violations, with property key */
violationsList: ViolationFromMap<M>[];
/** Compact list "Title: path" */
violations: string[];
/** Uniquement les titres */
/** Only titles */
titles: string[];
/** Uniquement les chemins de propriété */
/** Only property paths */
propertyPaths: Extract<keyof M, string>[];
/** Indexation par propriété (utile pour afficher par champ) */
/** Indexing by property (useful for display by field) */
byProperty: Record<Extract<keyof M, string>, string[]>;
}
export class ValidationException<
M extends Record<string, Record<string, unknown>> = Record<
string,
@@ -75,8 +75,9 @@ export class ValidationException<
implements ValidationExceptionInterface<M>
{
public readonly name = "ValidationException" as const;
public readonly problem: ValidationProblemFromMap<M>;
public readonly problems: ValidationProblemFromMap<M>;
public readonly violations: string[];
public readonly violationsList: ViolationFromMap<M>[];
public readonly titles: string[];
public readonly propertyPaths: Extract<keyof M, string>[];
public readonly byProperty: Record<Extract<keyof M, string>, string[]>;
@@ -86,8 +87,9 @@ export class ValidationException<
super(message);
Object.setPrototypeOf(this, new.target.prototype);
this.problem = problem;
this.problems = problem;
this.violationsList = problem.violations;
this.violations = problem.violations.map(
(v) => `${v.title}: ${v.propertyPath}`,
);

View File

@@ -35,6 +35,7 @@ use Chill\PersonBundle\Entity\Person\PersonCenterCurrent;
use Chill\PersonBundle\Entity\Person\PersonCenterHistory;
use Chill\PersonBundle\Entity\Person\PersonCurrentAddress;
use Chill\PersonBundle\Entity\Person\PersonResource;
use Chill\PersonBundle\PersonIdentifier\Validator\RequiredIdentifierConstraint;
use Chill\PersonBundle\Validator\Constraints\Household\HouseholdMembershipSequential;
use Chill\PersonBundle\Validator\Constraints\Person\Birthdate;
use Chill\PersonBundle\Validator\Constraints\Person\PersonHasCenter;
@@ -273,6 +274,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
private ?int $id = null;
#[ORM\OneToMany(mappedBy: 'person', targetEntity: PersonIdentifier::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[RequiredIdentifierConstraint]
private Collection $identifiers;
/**

View File

@@ -16,9 +16,10 @@ use Symfony\Component\Validator\Constraint;
/**
* Test that the required constraints are present.
*/
#[\Attribute]
class RequiredIdentifierConstraint extends Constraint
{
public string $message = 'This identifier must be set';
public string $message = 'person_identifier.This identifier must be set';
public function getTargets(): string
{

View File

@@ -45,7 +45,6 @@ final class RequiredIdentifierConstraintValidator extends ConstraintValidator
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $worker->renderAsString($identifier))
->setParameter('definition_id', (string) $worker->getDefinition()->getId())
->atPath('identifiers')
->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05')
->addViolation();
}

View File

@@ -73,6 +73,10 @@ export interface WritePersonViolationMap
"{{ value }}": string | null;
};
birthdate: {};
identifiers: {
"{{ value }}": string | null;
"definition_id": string;
};
}
export const createPerson = async (person: PersonWrite): Promise<Person> => {
return makeFetch<PersonWrite, Person, WritePersonViolationMap>(

View File

@@ -5,7 +5,7 @@
<div class="form-floating">
<input
class="form-control form-control-lg"
:class="{ 'is-invalid': hasValidationError('lastName') }"
:class="{ 'is-invalid': hasViolation('lastName') }"
id="lastname"
v-model="lastName"
:placeholder="trans(PERSON_MESSAGES_PERSON_LASTNAME)"
@@ -16,7 +16,7 @@
</div>
</div>
<div
v-for="err in validationError('lastName')"
v-for="err in violationTitles('lastName')"
class="invalid-feedback was-validated-force"
>
{{ err }}
@@ -40,7 +40,7 @@
<div class="form-floating">
<input
class="form-control form-control-lg"
:class="{ 'is-invalid': hasValidationError('firstName') }"
:class="{ 'is-invalid': hasViolation('firstName') }"
id="firstname"
v-model="firstName"
:placeholder="trans(PERSON_MESSAGES_PERSON_FIRSTNAME)"
@@ -51,7 +51,7 @@
</div>
</div>
<div
v-for="err in validationError('firstName')"
v-for="err in violationTitles('firstName')"
class="invalid-feedback was-validated-force"
>
{{ err }}
@@ -94,6 +94,7 @@
<div class="form-floating">
<input
class="form-control form-control-lg"
:class="{'is-invalid': hasViolationWithParameter('identifiers', 'definition_id', worker.definition_id.toString())}"
type="text"
:name="'worker_' + worker.definition_id"
:placeholder="localizeString(worker.label)"
@@ -103,6 +104,12 @@
localizeString(worker.label)
}}</label>
</div>
<div
v-for="err in violationTitlesWithParameter('identifiers', 'definition_id', worker.definition_id.toString())"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
</div>
@@ -111,7 +118,7 @@
<div class="form-floating">
<select
class="form-select form-select-lg"
:class="{ 'is-invalid': hasValidationError('gender') }"
:class="{ 'is-invalid': hasViolation('gender') }"
id="gender"
v-model="gender"
>
@@ -127,7 +134,7 @@
}}</label>
</div>
<div
v-for="err in validationError('gender')"
v-for="err in violationTitles('gender')"
class="invalid-feedback was-validated-force"
>
{{ err }}
@@ -140,7 +147,7 @@
<div class="form-floating">
<select
class="form-select form-select-lg"
:class="{ 'is-invalid': hasValidationError('center') }"
:class="{ 'is-invalid': hasViolation('center') }"
id="center"
v-model="center"
>
@@ -154,7 +161,7 @@
<label for="center">{{ trans(PERSON_MESSAGES_PERSON_CENTER_TITLE) }}</label>
</div>
<div
v-for="err in validationError('center')"
v-for="err in violationTitles('center')"
class="invalid-feedback was-validated-force"
>
{{ err }}
@@ -167,7 +174,7 @@
<div class="form-floating">
<select
class="form-select form-select-lg"
:class="{ 'is-invalid': hasValidationError('civility') }"
:class="{ 'is-invalid': hasViolation('civility') }"
id="civility"
v-model="civility"
>
@@ -181,7 +188,7 @@
<label for="civility">{{ trans(PERSON_MESSAGES_PERSON_CIVILITY_TITLE) }}</label>
</div>
<div
v-for="err in validationError('civility')"
v-for="err in violationTitles('civility')"
class="invalid-feedback was-validated-force"
>
{{ err }}
@@ -197,7 +204,7 @@
<div class="form-floating">
<input
class="form-control form-control-lg"
:class="{ 'is-invalid': hasValidationError('birthdate') }"
:class="{ 'is-invalid': hasViolation('birthdate') }"
name="birthdate"
type="date"
v-model="birthDate"
@@ -207,7 +214,7 @@
<label for="birthdate">{{ trans(BIRTHDATE) }}</label>
</div>
<div
v-for="err in validationError('birthdate')"
v-for="err in violationTitles('birthdate')"
class="invalid-feedback was-validated-force"
>
{{ err }}
@@ -223,7 +230,7 @@
<div class="form-floating">
<input
class="form-control form-control-lg"
:class="{ 'is-invalid': hasValidationError('phonenumber') }"
:class="{ 'is-invalid': hasViolation('phonenumber') }"
v-model="phonenumber"
:placeholder="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)"
:aria-label="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)"
@@ -232,7 +239,7 @@
<label for="phonenumber">{{ trans(PERSON_MESSAGES_PERSON_PHONENUMBER) }}</label>
</div>
<div
v-for="err in validationError('phonenumber')"
v-for="err in violationTitles('phonenumber')"
class="invalid-feedback was-validated-force"
>
{{ err }}
@@ -248,7 +255,7 @@
<div class="form-floating">
<input
class="form-control form-control-lg"
:class="{ 'is-invalid': hasValidationError('mobilenumber') }"
:class="{ 'is-invalid': hasViolation('mobilenumber') }"
v-model="mobilenumber"
:placeholder="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)"
:aria-label="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)"
@@ -257,7 +264,7 @@
<label for="mobilenumber">{{ trans(PERSON_MESSAGES_PERSON_MOBILENUMBER) }}</label>
</div>
<div
v-for="err in validationError('mobilenumber')"
v-for="err in violationTitles('mobilenumber')"
class="invalid-feedback was-validated-force"
>
{{ err }}
@@ -273,7 +280,7 @@
<div class="form-floating">
<input
class="form-control form-control-lg"
:class="{ 'is-invalid': hasValidationError('email') }"
:class="{ 'is-invalid': hasViolation('email') }"
v-model="email"
:placeholder="trans(PERSON_MESSAGES_PERSON_EMAIL)"
:aria-label="trans(PERSON_MESSAGES_PERSON_EMAIL)"
@@ -282,7 +289,7 @@
<label for="email">{{ trans(PERSON_MESSAGES_PERSON_EMAIL) }}</label>
</div>
<div
v-for="err in validationError('email')"
v-for="err in violationTitles('email')"
class="invalid-feedback was-validated-force"
>
{{ err }}
@@ -603,19 +610,49 @@ function addQueryItem(field: "lastName" | "firstName", queryItem: string) {
}
type WritePersonViolationKey = Extract<keyof WritePersonViolationMap, string>;
const validationErrors = ref<Partial<Record<WritePersonViolationKey, string[]>>>({});
const violationsList = ref<ValidationExceptionInterface<WritePersonViolationMap>["violationsList"]>([]);
function violationTitles<P extends WritePersonViolationKey>(property: P): string[] {
return violationsList.value.filter((v) => v.propertyPath === property).map((v) => v.title);
function validationError(
property: WritePersonViolationKey,
}
function violationTitlesWithParameter<
P extends WritePersonViolationKey,
Param extends Extract<keyof WritePersonViolationMap[P], string>
>(
property: P,
with_parameter: Param,
with_parameter_value: WritePersonViolationMap[P][Param],
): string[] {
return validationErrors.value[property] ?? [];
const list = violationsList.value.filter((v) => v.propertyPath === property);
const filtered = list.filter(
(v): boolean =>
!!v.parameters &&
// `with_parameter in v.parameters` check indexing
with_parameter in v.parameters &&
// the cast is safe, because we have overloading that bind the types
(v.parameters as WritePersonViolationMap[P])[with_parameter] === with_parameter_value
);
return filtered.map((v) => v.title);
// Sinon, retourner simplement les titres de la propriété
return list.map((v) => v.title);
}
function hasValidationError(
property: WritePersonViolationKey,
function hasViolation<P extends WritePersonViolationKey>(property: P): boolean {
return violationTitles(property).length > 0;
}
function hasViolationWithParameter<
P extends WritePersonViolationKey,
Param extends Extract<keyof WritePersonViolationMap[P], string>
>(
property: P,
with_parameter: Param,
with_parameter_value: WritePersonViolationMap[P][Param],
): boolean {
return validationError(property).length > 0;
return violationTitlesWithParameter(property, with_parameter, with_parameter_value).length > 0;
}
function submitNewAddress(payload: { addressId: number }) {
@@ -630,8 +667,7 @@ async function postPerson(): Promise<void> {
emit("onPersonCreated", { person: createdPerson });
} catch (e: unknown) {
if (isValidationException<WritePersonViolationMap>(e)) {
console.log(e.byProperty);
validationErrors.value = e.byProperty;
violationsList.value = e.violationsList;
} else {
toast.error(trans(PERSON_EDIT_ERROR_WHILE_SAVING));
}

View File

@@ -106,8 +106,7 @@ final class RequiredIdentifierConstraintValidatorTest extends ConstraintValidato
$this->validator->validate($collection, new RequiredIdentifierConstraint());
$this->buildViolation('This identifier must be set')
->atPath('property.path.identifiers')
$this->buildViolation('person_identifier.This identifier must be set')
->setParameter('{{ value }}', '')
->setParameter('definition_id', '1')
->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05')
@@ -123,8 +122,7 @@ final class RequiredIdentifierConstraintValidatorTest extends ConstraintValidato
$this->validator->validate($collection, new RequiredIdentifierConstraint());
$this->buildViolation('This identifier must be set')
->atPath('property.path.identifiers')
$this->buildViolation('person_identifier.This identifier must be set')
->setParameter('{{ value }}', ' ')
->setParameter('definition_id', '1')
->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05')

View File

@@ -111,3 +111,6 @@ services:
Chill\PersonBundle\PersonIdentifier\Normalizer\:
resource: '../PersonIdentifier/Normalizer'
Chill\PersonBundle\PersonIdentifier\Validator\:
resource: '../PersonIdentifier/Validator'

View File

@@ -73,5 +73,8 @@ relationship:
person_creation:
If you want to create an household, an address is required: Pour la création d'un ménage, une adresse est requise
person_identifier:
This identifier must be set: Cet identifiant doit être présent.
accompanying_course_work:
The endDate should be greater or equal than the start date: La date de fin doit être égale ou supérieure à la date de début