Refactor and enhance ValidationException handling across types and components

- Simplify and extend type definitions in `types.ts` for dynamic and normalized keys.
- Update `ValidationExceptionInterface` to include new methods for filtering violations.
- Refactor `apiMethods.ts` to leverage updated exception types and key parsing.
- Adjust `WritePersonViolationMap` for stricter type definitions.
- Enhance `PersonEdit.vue` to use refined violation methods, improving validation error handling.
This commit is contained in:
2025-09-23 21:26:12 +02:00
parent a1fd395868
commit 34af53130b
5 changed files with 115 additions and 81 deletions

View File

@@ -1,4 +1,10 @@
import { Scope } from "../../types"; import {
DynamicKeys,
Scope,
ValidationExceptionInterface,
ValidationProblemFromMap,
ViolationFromMap
} from "../../types";
export type body = Record<string, boolean | string | number | null>; export type body = Record<string, boolean | string | number | null>;
export type fetchOption = Record<string, boolean | string | number | null>; export type fetchOption = Record<string, boolean | string | number | null>;
@@ -25,50 +31,10 @@ export interface TransportExceptionInterface {
name: string; name: string;
} }
export type ViolationFromMap<
M extends Record<string, Record<string, unknown>>,
> = {
[K in Extract<keyof M, string>]: {
propertyPath: K;
title: string;
parameters?: M[K];
type?: string;
};
}[Extract<keyof M, string>];
export type ValidationProblemFromMap<
M extends Record<string, Record<string, unknown>>,
> = {
type: string;
title: string;
detail?: string;
violations: ViolationFromMap<M>[];
} & Record<string, unknown>;
export interface ValidationExceptionInterface<
M extends Record<string, Record<string, unknown>> = Record<
string,
Record<string, unknown>
>,
> extends Error {
name: "ValidationException";
/** Full server payload copy */
problems: ValidationProblemFromMap<M>;
/** A list of all violations, with property key */
violationsList: ViolationFromMap<M>[];
/** Compact list "Title: path" */
violations: string[];
/** Only titles */
titles: string[];
/** Only property paths */
propertyPaths: Extract<keyof M, string>[];
/** Indexing by property (useful for display by field) */
byProperty: Record<Extract<keyof M, string>, string[]>;
}
export class ValidationException< export class ValidationException<
M extends Record<string, Record<string, unknown>> = Record< M extends Record<string, Record<string, string|number>> = Record<
string, string,
Record<string, unknown> Record<string, string|number>
>, >,
> >
extends Error extends Error
@@ -79,7 +45,7 @@ export class ValidationException<
public readonly violations: string[]; public readonly violations: string[];
public readonly violationsList: ViolationFromMap<M>[]; public readonly violationsList: ViolationFromMap<M>[];
public readonly titles: string[]; public readonly titles: string[];
public readonly propertyPaths: Extract<keyof M, string>[]; public readonly propertyPaths: DynamicKeys<M> & string[];
public readonly byProperty: Record<Extract<keyof M, string>, string[]>; public readonly byProperty: Record<Extract<keyof M, string>, string[]>;
constructor(problem: ValidationProblemFromMap<M>) { constructor(problem: ValidationProblemFromMap<M>) {
@@ -98,11 +64,11 @@ export class ValidationException<
this.propertyPaths = problem.violations.map( this.propertyPaths = problem.violations.map(
(v) => v.propertyPath, (v) => v.propertyPath,
) as Extract<keyof M, string>[]; ) as DynamicKeys<M> & string[];
this.byProperty = problem.violations.reduce( this.byProperty = problem.violations.reduce(
(acc, v) => { (acc, v) => {
const key = v.propertyPath as Extract<keyof M, string>; const key = v.propertyPath.replace('/\[\d+\]$/', "") as Extract<keyof M, string>;
(acc[key] ||= []).push(v.title); (acc[key] ||= []).push(v.title);
return acc; return acc;
}, },
@@ -113,13 +79,38 @@ export class ValidationException<
Error.captureStackTrace(this, ValidationException); Error.captureStackTrace(this, ValidationException);
} }
} }
violationsByNormalizedProperty(property: Extract<keyof M, string>): ViolationFromMap<M>[] {
return this.violationsList.filter((v) => v.propertyPath.replace(/\[\d+\]$/, "") === property);
}
violationsByNormalizedPropertyAndParams<
P extends Extract<keyof M, string>,
K extends Extract<keyof M[P], string>
>(
property: P,
param: K,
param_value: M[P][K]
): ViolationFromMap<M>[]
{
const list = this.violationsByNormalizedProperty(property);
return list.filter(
(v): boolean =>
!!v.parameters &&
// `with_parameter in v.parameters` check indexing
param in v.parameters &&
// the cast is safe, because we have overloading that bind the types
(v.parameters as M[P])[param] === param_value
);
}
} }
/** /**
* Check that the exception is a ValidationExceptionInterface * Check that the exception is a ValidationExceptionInterface
* @param x * @param x
*/ */
export function isValidationException<M extends Record<string, Record<string, unknown>>>( export function isValidationException<M extends Record<string, Record<string, string|number>>>(
x: unknown, x: unknown,
): x is ValidationExceptionInterface<M> { ): x is ValidationExceptionInterface<M> {
return ( return (
@@ -315,9 +306,9 @@ export interface ConflictHttpExceptionInterface
export const makeFetch = async < export const makeFetch = async <
Input, Input,
Output, Output,
M extends Record<string, Record<string, unknown>> = Record< M extends Record<string, Record<string, string|number>> = Record<
string, string,
Record<string, Primitive> Record<string, string|number>
>, >,
>( >(
method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE", method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE",

View File

@@ -275,13 +275,63 @@ export interface TransportExceptionInterface {
name: string; name: string;
} }
export interface ValidationExceptionInterface type IndexedKey<Base extends string> = `${Base}[${number}]`;
extends TransportExceptionInterface { type BaseKeys<M> = Extract<keyof M, string>;
export type DynamicKeys<M extends Record<string, Record<string, unknown>>> =
| BaseKeys<M>
| { [K in BaseKeys<M> as IndexedKey<K>]: K }[IndexedKey<BaseKeys<M>>];
type NormalizeKey<K extends string> = K extends `${infer B}[${number}]` ? B : K;
export type ViolationFromMap<M extends Record<string, Record<string, unknown>>> = {
[K in DynamicKeys<M> & string]: { // <- note le "& string" ici
propertyPath: K;
title: string;
parameters?: M[NormalizeKey<K>];
type?: string;
}
}[DynamicKeys<M> & string];
export type ValidationProblemFromMap<
M extends Record<string, Record<string, string|number>>,
> = {
type: string;
title: string;
detail?: string;
violations: ViolationFromMap<M>[];
} & Record<string, unknown>;
export interface ValidationExceptionInterface<
M extends Record<string, Record<string, string|number>> = Record<
string,
Record<string, string|number>
>,
> extends Error {
name: "ValidationException"; name: "ValidationException";
error: object; /** Full server payload copy */
problems: ValidationProblemFromMap<M>;
/** A list of all violations, with property key */
violationsList: ViolationFromMap<M>[];
/** Compact list "Title: path" */
violations: string[]; violations: string[];
/** Only titles */
titles: string[]; titles: string[];
propertyPaths: string[]; /** Only property paths */
propertyPaths: DynamicKeys<M> & string[];
/** Indexing by property (useful for display by field) */
byProperty: Record<Extract<keyof M, string>, string[]>;
violationsByNormalizedProperty(property: Extract<keyof M, string>): ViolationFromMap<M>[];
violationsByNormalizedPropertyAndParams<
P extends Extract<keyof M, string>,
K extends Extract<keyof M[P], string>
>(
property: P,
param: K,
param_value: M[P][K]
): ViolationFromMap<M>[];
} }
export interface AccessExceptionInterface extends TransportExceptionInterface { export interface AccessExceptionInterface extends TransportExceptionInterface {

View File

@@ -45,15 +45,15 @@ export const getPersonIdentifiers = async (): Promise<
> => fetchResults("/api/1.0/person/identifiers/workers"); > => fetchResults("/api/1.0/person/identifiers/workers");
export interface WritePersonViolationMap export interface WritePersonViolationMap
extends Record<string, Record<string, unknown>> { extends Record<string, Record<string, string>> {
firstName: { firstName: {
"{{ value }}": string | null; "{{ value }}": string
}; };
lastName: { lastName: {
"{{ value }}": string | null; "{{ value }}": string;
}; };
gender: { gender: {
"{{ value }}": string | null; "{{ value }}": string;
}; };
mobilenumber: { mobilenumber: {
"{{ types }}": string; // ex: "mobile number" "{{ types }}": string; // ex: "mobile number"
@@ -64,17 +64,17 @@ export interface WritePersonViolationMap
"{{ value }}": string; // ex: "+33 1 02 03 04 05" "{{ value }}": string; // ex: "+33 1 02 03 04 05"
}; };
email: { email: {
"{{ value }}": string | null; "{{ value }}": string;
}; };
center: { center: {
"{{ value }}": string | null; "{{ value }}": string;
}; };
civility: { civility: {
"{{ value }}": string | null; "{{ value }}": string;
}; };
birthdate: {}; birthdate: {};
identifiers: { identifiers: {
"{{ value }}": string | null; "{{ value }}": string;
"definition_id": string; "definition_id": string;
}; };
} }

View File

@@ -366,7 +366,7 @@ import {
Center, Center,
Civility, Civility,
Gender, Gender,
DateTimeWrite, DateTimeWrite, ValidationExceptionInterface,
} from "ChillMainAssets/types"; } from "ChillMainAssets/types";
import { import {
AltName, AltName,
@@ -378,7 +378,6 @@ import {
} from "ChillPersonAssets/types"; } from "ChillPersonAssets/types";
import { import {
isValidationException, isValidationException,
ValidationExceptionInterface,
} from "ChillMainAssets/lib/api/apiMethods"; } from "ChillMainAssets/lib/api/apiMethods";
import {useToast} from "vue-toast-notification"; import {useToast} from "vue-toast-notification";
import {getTimezoneOffsetString, ISOToDate} from "ChillMainAssets/chill/js/date"; import {getTimezoneOffsetString, ISOToDate} from "ChillMainAssets/chill/js/date";
@@ -610,10 +609,13 @@ function addQueryItem(field: "lastName" | "firstName", queryItem: string) {
} }
type WritePersonViolationKey = Extract<keyof WritePersonViolationMap, string>; type WritePersonViolationKey = Extract<keyof WritePersonViolationMap, string>;
const violationsList = ref<ValidationExceptionInterface<WritePersonViolationMap>["violationsList"]>([]); const violationsList = ref<ValidationExceptionInterface<WritePersonViolationMap>|null>(null);
function violationTitles<P extends WritePersonViolationKey>(property: P): string[] { function violationTitles<P extends WritePersonViolationKey>(property: P): string[] {
return violationsList.value.filter((v) => v.propertyPath === property).map((v) => v.title); if (null === violationsList.value) {
return [];
}
return violationsList.value.violationsByNormalizedProperty(property).map((v) => v.title);
} }
function violationTitlesWithParameter< function violationTitlesWithParameter<
@@ -624,20 +626,11 @@ function violationTitlesWithParameter<
with_parameter: Param, with_parameter: Param,
with_parameter_value: WritePersonViolationMap[P][Param], with_parameter_value: WritePersonViolationMap[P][Param],
): string[] { ): string[] {
const list = violationsList.value.filter((v) => v.propertyPath === property); if (violationsList.value === null) {
return [];
const filtered = list.filter( }
(v): boolean => return violationsList.value.violationsByNormalizedPropertyAndParams(property, with_parameter, with_parameter_value)
!!v.parameters && .map((v) => v.title);
// `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);
} }
@@ -660,14 +653,13 @@ function submitNewAddress(payload: { addressId: number }) {
} }
async function postPerson(): Promise<void> { async function postPerson(): Promise<void> {
console.log("postPerson");
try { try {
const createdPerson = await createPerson(person); const createdPerson = await createPerson(person);
emit("onPersonCreated", { person: createdPerson }); emit("onPersonCreated", { person: createdPerson });
} catch (e: unknown) { } catch (e: unknown) {
if (isValidationException<WritePersonViolationMap>(e)) { if (isValidationException<WritePersonViolationMap>(e)) {
violationsList.value = e.violationsList; violationsList.value = e;
} else { } else {
toast.error(trans(PERSON_EDIT_ERROR_WHILE_SAVING)); toast.error(trans(PERSON_EDIT_ERROR_WHILE_SAVING));
} }

View File

@@ -75,6 +75,7 @@ person_creation:
person_identifier: person_identifier:
This identifier must be set: Cet identifiant doit être présent. This identifier must be set: Cet identifiant doit être présent.
Identifier must be unique. The same identifier already exists for {{ persons }}: Identifiant déjà utilisé pour {{ persons }}
accompanying_course_work: 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 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