Enhance validation in PersonEdit: Introduce hasValidationError and validationError helpers for form inputs. Improve error feedback for fields such as firstName, lastName, gender, and others. Refactor postPerson to handle validation exceptions and map errors to specific fields. Update related methods, styles, and API error type definitions.

This commit is contained in:
2025-09-17 20:21:50 +02:00
parent 5ff374d2fa
commit c19206be0c
7 changed files with 355 additions and 183 deletions

View File

@@ -117,9 +117,9 @@ export class ValidationException<
* Check that the exception is a ValidationExceptionInterface
* @param x
*/
export function isValidationException(
export function isValidationException<M extends Record<string, Record<string, unknown>>>(
x: unknown,
): x is ValidationExceptionInterface<Record<string, Record<string, unknown>>> {
): x is ValidationExceptionInterface<M> {
return (
x instanceof ValidationException ||
(typeof x === "object" &&

View File

@@ -56,7 +56,7 @@ import {
ONTHEFLY_CREATE_THIRDPARTY,
trans,
} from "translator";
import {CreatableEntityType, Person} from "ChillPersonAssets/types";
import { CreatableEntityType, Person } from "ChillPersonAssets/types";
import { CreateComponentConfig } from "ChillMainAssets/types";
import PersonEdit from "ChillPersonAssets/vuejs/_components/OnTheFly/PersonEdit.vue";
@@ -66,9 +66,8 @@ const props = withDefaults(defineProps<CreateComponentConfig>(), {
query: "",
});
const emit = defineEmits<{
(e: "onPersonCreated", payload: { person: Person }): void;
}>();
const emit =
defineEmits<(e: "onPersonCreated", payload: { person: Person }) => void>();
const type = ref<CreatableEntityType | null>(null);
@@ -112,9 +111,7 @@ function save(): void {
castPerson.value.postPerson();
}
defineExpose({save});
defineExpose({ save });
</script>
<style lang="css" scoped>

View File

@@ -2,9 +2,9 @@
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import Create from "ChillMainAssets/vuejs/OnTheFly/components/Create.vue";
import { CreateComponentConfig } from "ChillMainAssets/types";
import {trans, SAVE} from "translator";
import {useTemplateRef} from "vue";
import {Person} from "ChillPersonAssets/types";
import { trans, SAVE } from "translator";
import { useTemplateRef } from "vue";
import { Person } from "ChillPersonAssets/types";
const emit = defineEmits<{
(e: "onPersonCreated", payload: { person: Person }): void;
@@ -14,17 +14,16 @@ const emit = defineEmits<{
const props = defineProps<CreateComponentConfig>();
const modalDialogClass = { "modal-xl": true, "modal-scrollable": true };
type CreateComponentType = InstanceType<typeof Create>
type CreateComponentType = InstanceType<typeof Create>;
const create = useTemplateRef<CreateComponentType>("create");
function save(): void {
console.log('save from CreateModal');
console.log("save from CreateModal");
create.value?.save();
}
defineExpose({save})
defineExpose({ save });
</script>
<template>
@@ -49,7 +48,9 @@ defineExpose({save})
</div>
</template>
<template #footer>
<button class="btn btn-save" type="button" @click.prevent="save">{{ trans(SAVE) }}</button>
<button class="btn btn-save" type="button" @click.prevent="save">
{{ trans(SAVE) }}
</button>
</template>
</modal>
</teleport>

View File

@@ -10,7 +10,11 @@ import {
Scope,
Job,
PrivateCommentEmbeddable,
TranslatableString, DateTimeCreate, SetGender, SetCenter, SetCivility,
TranslatableString,
DateTimeCreate,
SetGender,
SetCenter,
SetCivility,
} from "ChillMainAssets/types";
import { StoredObject } from "ChillDocStoreAssets/types";
import { Thirdparty } from "../../../ChillThirdPartyBundle/Resources/public/types";

View File

@@ -1,6 +1,11 @@
import { fetchResults, makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import { Center, Civility, Gender } from "ChillMainAssets/types";
import {AltName, Person, PersonIdentifierWorker, PersonWrite} from "ChillPersonAssets/types";
import {
AltName,
Person,
PersonIdentifierWorker,
PersonWrite,
} from "ChillPersonAssets/types";
import person from "ChillPersonAssets/vuejs/_components/OnTheFly/Person.vue";
/*
@@ -30,12 +35,48 @@ export const getCivilities = async (): Promise<Civility[]> =>
export const getGenders = async (): Promise<Gender[]> =>
fetchResults("/api/1.0/main/gender.json");
export const getCentersForPersonCreation = async (): Promise<{showCenters: boolean; centers: Center[];}> =>
makeFetch("GET", "/api/1.0/person/creation/authorized-centers", null);
export const getCentersForPersonCreation = async (): Promise<{
showCenters: boolean;
centers: Center[];
}> => makeFetch("GET", "/api/1.0/person/creation/authorized-centers", null);
export const getPersonIdentifiers = async (): Promise<PersonIdentifierWorker[]> =>
fetchResults("/api/1.0/person/identifiers/workers");
export const getPersonIdentifiers = async (): Promise<
PersonIdentifierWorker[]
> => fetchResults("/api/1.0/person/identifiers/workers");
export const createPerson = async (person: PersonWrite): Promise<Person> => {
return makeFetch("POST", "/api/1.0/person/person.json", person);
export interface WritePersonViolationMap
extends Record<string, Record<string, unknown>> {
firstName: {
"{{ value }}": string | null;
};
lastName: {
"{{ value }}": string | null;
};
gender: {
"{{ value }}": string | null;
};
mobilenumber: {
"{{ types }}": string; // ex: "mobile number"
"{{ value }}": string; // ex: "+33 1 02 03 04 05"
};
phonenumber: {
"{{ types }}": string; // ex: "mobile number"
"{{ value }}": string; // ex: "+33 1 02 03 04 05"
};
email: {
"{{ value }}": string | null;
};
center: {
"{{ value }}": string | null;
};
civility: {
"{{ value }}": string | null;
};
}
export const createPerson = async (person: PersonWrite): Promise<Person> => {
return makeFetch<PersonWrite, Person, WritePersonViolationMap>(
"POST",
"/api/1.0/person/person.json",
person,
);
};

View File

@@ -37,7 +37,8 @@ import type {
Suggestion,
SearchOptions,
CreatableEntityType,
EntityType, Person,
EntityType,
Person,
} from "ChillPersonAssets/types";
import { marked } from "marked";
import options = marked.options;
@@ -113,8 +114,12 @@ function closeModalCreate() {
function onPersonCreated(payload: { person: Person }) {
console.log("onPersonCreated", payload);
showModalCreate.value = false;
const suggestion = {result: payload.person, relevance: 999999, key: "person"};
emit("addNewPersons", {selected: [suggestion]});
const suggestion = {
result: payload.person,
relevance: 999999,
key: "person",
};
emit("addNewPersons", { selected: [suggestion] });
}
</script>

View File

@@ -1,13 +1,26 @@
<template>
<div>
<div class="form-floating mb-3">
<input
class="form-control form-control-lg"
id="lastname"
v-model="lastName"
:placeholder="trans(PERSON_MESSAGES_PERSON_LASTNAME)"
/>
<label for="lastname">{{ trans(PERSON_MESSAGES_PERSON_LASTNAME) }}</label>
<div class="mb-3">
<div class="input-group has-validation">
<div class="form-floating">
<input
class="form-control form-control-lg"
:class="{ 'is-invalid': hasValidationError('lastName') }"
id="lastname"
v-model="lastName"
:placeholder="trans(PERSON_MESSAGES_PERSON_LASTNAME)"
/>
<label for="lastname">{{
trans(PERSON_MESSAGES_PERSON_LASTNAME)
}}</label>
</div>
</div>
<div
v-for="err in validationError('lastName')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
<div v-if="queryItems">
@@ -22,16 +35,27 @@
</ul>
</div>
<div class="form-floating mb-3">
<input
class="form-control form-control-lg"
id="firstname"
v-model="firstName"
:placeholder="trans(PERSON_MESSAGES_PERSON_FIRSTNAME)"
/>
<label for="firstname">{{
trans(PERSON_MESSAGES_PERSON_FIRSTNAME)
}}</label>
<div class="mb-3">
<div class="input-group has-validation">
<div class="form-floating">
<input
class="form-control form-control-lg"
:class="{ 'is-invalid': hasValidationError('firstName') }"
id="firstname"
v-model="firstName"
:placeholder="trans(PERSON_MESSAGES_PERSON_FIRSTNAME)"
/>
<label for="firstname">{{
trans(PERSON_MESSAGES_PERSON_FIRSTNAME)
}}</label>
</div>
</div>
<div
v-for="err in validationError('firstName')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
<div v-if="queryItems">
@@ -46,116 +70,190 @@
</ul>
</div>
<div
v-for="(a, i) in config.altNames"
:key="a.key"
class="form-floating mb-3"
>
<input
class="form-control form-control-lg"
:id="a.key"
:name="'label_'+a.key"
value=""
@input="onAltNameInput($event, a.key)"
/>
<label :for="'label_'+a.key">{{ localizeString(a.labels) }}</label>
<div v-for="(a, i) in config.altNames" :key="a.key" class="mb-3">
<div class="input-group has-validation">
<div class="form-floating">
<input
class="form-control form-control-lg"
:id="a.key"
:name="'label_' + a.key"
value=""
@input="onAltNameInput($event, a.key)"
/>
<label :for="'label_' + a.key">{{ localizeString(a.labels) }}</label>
</div>
</div>
</div>
<div
v-for="worker in config.identifiers"
:key="worker.definition_id"
class="form-floating mb-3"
class="mb-3"
>
<input
class="form-control form-control-lg"
type="text"
:name="'worker_'+worker.definition_id"
:placeholder="localizeString(worker.label)"
@input="onIdentifierInput($event, worker.definition_id)"
/>
<label :for="'worker_'+worker.definition_id">{{ localizeString(worker.label) }}</label>
<div class="input-group has-validation">
<div class="form-floating">
<input
class="form-control form-control-lg"
type="text"
:name="'worker_' + worker.definition_id"
:placeholder="localizeString(worker.label)"
@input="onIdentifierInput($event, worker.definition_id)"
/>
<label :for="'worker_' + worker.definition_id">{{
localizeString(worker.label)
}}</label>
</div>
</div>
</div>
<div class="form-floating mb-3">
<select class="form-select form-select-lg" id="gender" v-model="gender">
<option selected disabled>
{{ trans(PERSON_MESSAGES_PERSON_GENDER_PLACEHOLDER) }}
</option>
<option v-for="g in config.genders" :value="g.id" :key="g.id">
{{ g.label }}
</option>
</select>
<label>{{ trans(PERSON_MESSAGES_PERSON_GENDER_TITLE) }}</label>
<div class="mb-3">
<div class="input-group has-validation">
<div class="form-floating">
<select
class="form-select form-select-lg"
:class="{ 'is-invalid': hasValidationError('gender') }"
id="gender"
v-model="gender"
>
<option selected disabled>
{{ trans(PERSON_MESSAGES_PERSON_GENDER_PLACEHOLDER) }}
</option>
<option v-for="g in config.genders" :value="g.id" :key="g.id">
{{ g.label }}
</option>
</select>
<label for="gender" class="form-label">{{
trans(PERSON_MESSAGES_PERSON_GENDER_TITLE)
}}</label>
</div>
<div
v-for="err in validationError('gender')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
</div>
<div
class="form-floating mb-3"
v-if="showCenters && config.centers.length > 1"
>
<select class="form-select form-select-lg" id="center" v-model="center">
<option selected disabled>
{{ trans(PERSON_MESSAGES_PERSON_CENTER_PLACEHOLDER) }}
</option>
<option v-for="c in config.centers" :value="c" :key="c.id">
{{ c.name }}
</option>
</select>
<label>{{ trans(PERSON_MESSAGES_PERSON_CENTER_TITLE) }}</label>
<div class="mb-3" v-if="showCenters && config.centers.length > 1">
<div class="input-group">
<div class="form-floating">
<select
class="form-select form-select-lg"
id="center"
v-model="center"
>
<option selected disabled>
{{ trans(PERSON_MESSAGES_PERSON_CENTER_PLACEHOLDER) }}
</option>
<option v-for="c in config.centers" :value="c" :key="c.id">
{{ c.name }}
</option>
</select>
<label>{{ trans(PERSON_MESSAGES_PERSON_CENTER_TITLE) }}</label>
</div>
<div
v-for="err in validationError('center')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
</div>
<div class="form-floating mb-3">
<select
class="form-select form-select-lg"
id="civility"
v-model="civility"
>
<option selected disabled>
{{ trans(PERSON_MESSAGES_PERSON_CIVILITY_PLACEHOLDER) }}
</option>
<option v-for="c in config.civilities" :value="c.id" :key="c.id">
{{ localizeString(c.name) }}
</option>
</select>
<label>{{ trans(PERSON_MESSAGES_PERSON_CIVILITY_TITLE) }}</label>
<div class="mb-3">
<div class="input-group has-validation">
<div class="form-floating">
<select
class="form-select form-select-lg"
id="civility"
v-model="civility"
>
<option selected disabled>
{{ trans(PERSON_MESSAGES_PERSON_CIVILITY_PLACEHOLDER) }}
</option>
<option v-for="c in config.civilities" :value="c.id" :key="c.id">
{{ localizeString(c.name) }}
</option>
</select>
<label>{{ trans(PERSON_MESSAGES_PERSON_CIVILITY_TITLE) }}</label>
</div>
<div
v-for="err in validationError('civility')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
</div>
<div class="input-group mb-3">
<span class="input-group-text" id="phonenumber">
<i class="fa fa-fw fa-phone"></i>
</span>
<input
class="form-control form-control-lg"
v-model="phonenumber"
:placeholder="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)"
:aria-label="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)"
aria-describedby="phonenumber"
/>
<div class="mb-3">
<div class="input-group has-validation">
<span class="input-group-text" id="phonenumber">
<i class="fa fa-fw fa-phone"></i>
</span>
<div class="form-floating">
<input
class="form-control form-control-lg"
v-model="phonenumber"
:placeholder="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)"
:aria-label="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)"
aria-describedby="phonenumber"
/>
</div>
<div
v-for="err in validationError('phonenumber')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
</div>
<div class="input-group mb-3">
<span class="input-group-text" id="mobilenumber">
<i class="fa fa-fw fa-mobile"></i>
</span>
<input
class="form-control form-control-lg"
v-model="mobilenumber"
:placeholder="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)"
:aria-label="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)"
aria-describedby="mobilenumber"
/>
<div class="mb-3">
<div class="input-group has-validation">
<span class="input-group-text" id="mobilenumber">
<i class="fa fa-fw fa-mobile"></i>
</span>
<div class="form-floating">
<input
class="form-control form-control-lg"
v-model="mobilenumber"
:placeholder="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)"
:aria-label="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)"
aria-describedby="mobilenumber"
/>
</div>
<div
v-for="err in validationError('mobilenumber')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
</div>
<div class="input-group mb-3">
<span class="input-group-text" id="email">
<i class="fa fa-fw fa-at"></i>
</span>
<input
class="form-control form-control-lg"
v-model="email"
:placeholder="trans(PERSON_MESSAGES_PERSON_EMAIL)"
:aria-label="trans(PERSON_MESSAGES_PERSON_EMAIL)"
aria-describedby="email"
/>
<div class="mb-3">
<div class="input-group has-validation">
<span class="input-group-text" id="email">
<i class="fa fa-fw fa-at"></i>
</span>
<div class="form-floating">
<input
class="form-control form-control-lg"
v-model="email"
:placeholder="trans(PERSON_MESSAGES_PERSON_EMAIL)"
:aria-label="trans(PERSON_MESSAGES_PERSON_EMAIL)"
aria-describedby="email"
/>
</div>
<div
v-for="err in validationError('email')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
</div>
<div v-if="action === 'create'" class="input-group mb-3 form-check">
@@ -190,6 +288,7 @@
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue";
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
@@ -200,7 +299,9 @@ import {
getCivilities,
getGenders,
getPerson,
getPersonAltNames, getPersonIdentifiers,
getPersonAltNames,
getPersonIdentifiers,
WritePersonViolationMap,
} from "../../_api/OnTheFly";
import {
trans,
@@ -230,9 +331,12 @@ import {
PersonWrite,
PersonIdentifierWorker,
type Suggestion,
type EntitiesOrMe
type EntitiesOrMe,
} from "ChillPersonAssets/types";
import {
isValidationException,
ValidationExceptionInterface,
} from "ChillMainAssets/lib/api/apiMethods";
interface PersonEditComponentConfig {
id?: number | null;
@@ -246,11 +350,10 @@ const props = withDefaults(defineProps<PersonEditComponentConfig>(), {
type: "TODO",
});
const emit = defineEmits<{
(e: "onPersonCreated", payload: { person: Person }): void;
}>();
const emit =
defineEmits<(e: "onPersonCreated", payload: { person: Person }) => void>();
defineExpose({postPerson});
defineExpose({ postPerson });
const person = reactive<PersonWrite>({
type: "person",
@@ -318,7 +421,9 @@ const lastName = computed({
const gender = computed({
get: () => (person.gender ? person.gender.id : null),
set: (value: string | null) => {
person.gender = value ? { id: Number.parseInt(value), type: "chill_main_gender" } : null;
person.gender = value
? { id: Number.parseInt(value), type: "chill_main_gender" }
: null;
},
});
const civility = computed({
@@ -373,7 +478,7 @@ const center = computed({
if (null !== value) {
person.center = {
id: value.id,
type: (value).type,
type: value.type,
};
} else {
person.center = null;
@@ -385,39 +490,23 @@ const center = computed({
* Find the query items to display for suggestion
*/
const queryItems = computed(() => {
const words: null|string[] = props.query ? props.query.split(" ") : null;
const words: null | string[] = props.query ? props.query.split(" ") : null;
if (null === words) {
return null;
}
if (null === words) {
return null;
}
const firstNameWords = (person.firstName || "").trim().toLowerCase().split(" ");
const lastNameWords = (person.lastName || "").trim().toLowerCase().split(" ");
const firstNameWords = (person.firstName || "")
.trim()
.toLowerCase()
.split(" ");
const lastNameWords = (person.lastName || "").trim().toLowerCase().split(" ");
return words
.filter((word) => !firstNameWords.includes(word.toLowerCase()))
.filter((word) => !lastNameWords.includes(word.toLowerCase()));
return words
.filter((word) => !firstNameWords.includes(word.toLowerCase()))
.filter((word) => !lastNameWords.includes(word.toLowerCase()));
});
/*
function checkErrors() {
errors.value = [];
if (person.lastName === "") {
errors.value.push("Le nom ne doit pas être vide.");
}
if (person.firstName === "") {
errors.value.push("Le prénom ne doit pas être vide.");
}
if (!person.gender) {
errors.value.push("Le genre doit être renseigné");
}
if (showCenters.value && person.center === null) {
errors.value.push("Le centre doit être renseigné");
}
}
*/
async function loadData() {
if (props.id !== undefined && props.id !== null) {
const person = await getPerson(props.id);
@@ -429,7 +518,7 @@ function onAltNameInput(event: Event, key: string): void {
const value = target.value;
const updateAltNamesKey = person.altNames.findIndex((a) => a.key === key);
if (-1 === updateAltNamesKey) {
person.altNames.push({key, value})
person.altNames.push({ key, value });
} else {
person.altNames[updateAltNamesKey].value = value;
}
@@ -438,11 +527,17 @@ function onAltNameInput(event: Event, key: string): void {
function onIdentifierInput(event: Event, definition_id: number): void {
const target = event.target as HTMLInputElement;
const value = target.value;
const updateIdentifierKey = person.identifiers.findIndex((w) => w.definition_id === definition_id);
const updateIdentifierKey = person.identifiers.findIndex(
(w) => w.definition_id === definition_id,
);
if (-1 === updateIdentifierKey) {
person.identifiers.push({type: "person_identifier", definition_id, value: { content: value }})
person.identifiers.push({
type: "person_identifier",
definition_id,
value: { content: value },
});
} else {
person.identifiers[updateIdentifierKey].value = {content: value}
person.identifiers[updateIdentifierKey].value = { content: value };
}
}
@@ -461,15 +556,38 @@ function addQueryItem(field: "lastName" | "firstName", queryItem: string) {
}
}
type WritePersonViolationKey = Extract<keyof WritePersonViolationMap, string>;
const validationErrors = ref<Partial<Record<WritePersonViolationKey, string[]>>>({});
function validationError(
property: WritePersonViolationKey,
): string[] {
return validationErrors.value[property] ?? [];
}
function hasValidationError(
property: WritePersonViolationKey,
): boolean {
return validationError(property).length > 0;
}
function submitNewAddress(payload: { addressId: number }) {
// person.addressId = payload.addressId;
}
async function postPerson(): Promise<void> {
console.log('postPerson');
const createdPerson = await createPerson(person);
console.log("postPerson");
try {
const createdPerson = await createPerson(person);
emit('onPersonCreated', {person: createdPerson});
emit("onPersonCreated", { person: createdPerson });
} catch (e: unknown) {
if (isValidationException<WritePersonViolationMap>(e)) {
console.log(e.byProperty);
validationErrors.value = e.byProperty;
}
}
}
onMounted(() => {
@@ -495,10 +613,16 @@ onMounted(() => {
// if there is only one center, preselect it
person.center = {
id: config.centers[0].id,
type: (config.centers[0]).type ?? "center",
type: config.centers[0].type ?? "center",
};
}
});
}
});
</script>
<style lang="scss" scoped>
.was-validated-force {
display: block;
}
</style>