Compare commits

..

31 Commits

Author SHA1 Message Date
0fd76d3fa8 Add PersonIdentifierManagerInterface to PersonACLAwareRepository and enhance search logic for identifiers
- Inject `PersonIdentifierManagerInterface` into `PersonACLAwareRepository` for improved identifier handling.
- Update search queries to include logic for filtering and matching `PersonIdentifier` values.
- Modify test cases to support the new dependency and ensure proper coverage.
2025-09-24 00:01:28 +02:00
34af53130b 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.
2025-09-23 21:26:12 +02:00
a1fd395868 Add unique constraint for PersonIdentifier, implement UniqueIdentifierConstraint with validation logic, and include supporting tests
- Introduce `UniqueIdentifierConstraint` and its validator for ensuring identifier uniqueness.
- Add a database-level unique constraint on `PersonIdentifier` (`definition_id`, `value`).
- Implement repository method to fetch identifiers by definition and value.
- Include integration and unit tests for validation and repository functionality.
- Update `Person` entity with `Assert\Valid` annotation for `identifiers`.
2025-09-23 12:36:30 +02:00
b8a7cbb321 Add identifiers field in CreationPersonType and handle on_create logic in PersonIdentifiersType
- Introduce `identifiers` field to `CreationPersonType` with a dedicated form type.
- Update `PersonIdentifiersType` to support `step` option (`on_create` and `on_edit`).
- Skip certain identifiers in `on_create` step based on presence configuration.
- Adjust Twig template to display `identifiers` conditionally.
2025-09-22 14:03:59 +02:00
6124eb9e34 Fix isEmpty logic in StringIdentifier: Correct boolean comparison for trimmed content. 2025-09-22 14:03:58 +02:00
a5b06de92a 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. 2025-09-22 14:03:58 +02:00
52404956d2 Trim PersonIdentifier values during denormalization, implement RequiredIdentifierConstraint and validator, and add tests for empty value validation. 2025-09-22 14:03:57 +02:00
4207efd6bf Remove empty PersonIdentifier values during denormalization and add isEmpty logic to PersonIdentifierWorker. Include tests for empty value handling. 2025-09-22 14:03:57 +02:00
840fde4ad4 Filter PersonIdentifierWorker by presence during initialization and update type definitions. Add presence field to PersonIdentifierWorkerNormalizer. 2025-09-22 14:03:56 +02:00
3611ea2518 Refactor PersonIdentifierDefinition: Replace fully qualified \Doctrine\DBAL\Types\Types references with simplified Types aliases. 2025-09-22 14:03:55 +02:00
bbd4292cb9 Enhance PersonEdit form: Add birthdate input with validation, improve field error handling using hasValidationError, refactor birthDate to respect timezone offsets, and update translations for better user feedback. Replace DateTimeCreate with DateTimeWrite across types and components. 2025-09-22 14:03:55 +02:00
54f8c92240 Update DateNormalizer: Add return type hints for denormalize and normalize methods 2025-09-22 14:03:54 +02:00
5330befc8f eslint fixes 2025-09-22 14:03:54 +02:00
c19206be0c 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. 2025-09-22 14:03:53 +02:00
5ff374d2fa Refactor validation handling in apiMethods: Introduce strongly-typed ValidationException and ViolationFromMap. Replace generic validation logic with stricter, type-safe mappings. Update makeFetch to handle Symfony validation problems with enhanced error taxonomy. 2025-09-22 14:03:53 +02:00
4a73aaae94 Replace PhonenumberConstraint with MisdPhoneNumberConstraint across entities, deprecate outdated validation logic, and remove unused methods for improved phone number validation. 2025-09-22 14:03:53 +02:00
ff2c567d05 Update default center type fallback in PersonEdit.vue to "center" for consistency. 2025-09-22 14:03:52 +02:00
a734e84f28 Remove unused Person.vue import from types.ts for cleanup and improved code maintainability. 2025-09-22 14:03:51 +02:00
4367ed086e Enhance person creation workflow: Add onPersonCreated event handling in Create, CreateModal, and AddPersons. Update type definitions and integrate event emission for streamlined person management. 2025-09-22 14:03:51 +02:00
3227bfcd3a Remove serializer.yaml configuration, update PersonJsonNormalizer and PersonJsonDenormalizer for improved logic handling, adjust type hints in closures, and rename id to definition_id in PersonIdentifierWorkerNormalizer. 2025-09-22 14:03:50 +02:00
8d29fb260a Add validation and support for identifiers in PersonJsonDenormalizer, enhance altNames handling, and update tests for improved coverage. Adjust PersonIdentifierManager to handle identifier definitions by ID. 2025-09-22 14:03:50 +02:00
bda0743c63 Update test run guidelines to use the symfony command for executing PHPUnit tests 2025-09-22 14:03:49 +02:00
d9b730627f Introduce PersonJsonReadDenormalizer and PersonJsonDenormalizer to separate responsibilities for handling person denormalization. Add corresponding test classes for improved coverage. Refactor PersonJsonNormalizer to remove denormalization logic. 2025-09-22 14:03:49 +02:00
27548ad654 Add support for person identifiers workflow: update PersonEdit component, API methods, and modals for identifier handling during person creation. Adjust related types for improved consistency. 2025-09-22 14:03:48 +02:00
bec7297039 Add an api list of available person identifiers 2025-09-22 14:03:48 +02:00
852523e644 Refactor person management workflow: Introduce SetGender, SetCivility, and SetCenter lightweight interfaces. Replace PersonState with PersonEdit for streamlined type usage. Enhance queryItems logic and API methods for better consistency. Adjust AddPersons modal to handle query input. 2025-09-22 14:03:47 +02:00
c05d0aad47 Refactor person creation workflow: Introduce PersonEdit component and integrate it across Create, Person.vue, and modals for improved modularity. Update type definitions and API methods for consistency. 2025-09-22 14:03:47 +02:00
1c0ed9abc8 Enhance entity creation: Add CreateModal and integrate with AddPersons workflow. 2025-09-22 14:03:42 +02:00
9aed5cc216 Fix type hinting in PickEntity.vue for addNewEntity function 2025-09-22 14:03:25 +02:00
e4fe5bff68 Allow creating new entities directly from AddPersons modal 2025-09-22 14:03:25 +02:00
4c73c4d9d0 Refactor AddPersons modal into a separate PersonChooseModal component for improved modularity and reusability. 2025-09-22 14:03:24 +02:00
73 changed files with 5775 additions and 3382 deletions

View File

@@ -236,12 +236,14 @@ This must be a decision made by a human, not by an AI. Every AI task must abort
The tests are run from the project's root (not from the bundle's root: so, do not change the directory to any bundle directory before running tests).
Tests must be run using the `symfony` command:
```bash
# Run a specific test file
vendor/bin/phpunit path/to/TestFile.php
symfony composer exec phpunit -- path/to/TestFile.php
# Run a specific test method
vendor/bin/phpunit --filter methodName path/to/TestFile.php
symfony composer exec phpunit -- --filter methodName path/to/TestFile.php
```
#### Test Structure

View File

@@ -1,14 +1,14 @@
<template>
<location />
<location />
</template>
<script>
import Location from "ChillActivityAssets/vuejs/Activity/components/Location.vue";
export default {
name: "App",
components: {
Location,
},
name: "App",
components: {
Location,
},
};
</script>

View File

@@ -19,7 +19,6 @@ use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Address;
use libphonenumber\PhoneNumber;
use Symfony\Component\Validator\Constraints as Assert;
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
/**
* Immersion.
@@ -86,14 +85,14 @@ class Immersion implements \Stringable
* @Assert\NotBlank()
*/
#[ORM\Column(name: 'tuteurPhoneNumber', type: 'phone_number', nullable: true)]
#[PhonenumberConstraint(type: 'any')]
#[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber]
private ?PhoneNumber $tuteurPhoneNumber = null;
#[ORM\Column(name: 'structureAccName', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)]
private ?string $structureAccName = null;
#[ORM\Column(name: 'structureAccPhonenumber', type: 'phone_number', nullable: true)]
#[PhonenumberConstraint(type: 'any')]
#[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber]
private ?PhoneNumber $structureAccPhonenumber = null;
#[ORM\Column(name: 'structureAccEmail', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)]

View File

@@ -14,9 +14,9 @@ namespace Chill\MainBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Repository\LocationRepository;
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
use Doctrine\ORM\Mapping as ORM;
use libphonenumber\PhoneNumber;
use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as MisdPhoneNumberConstraint;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
@@ -67,12 +67,12 @@ class Location implements TrackCreationInterface, TrackUpdateInterface
#[Serializer\Groups(['read', 'write', 'docgen:read'])]
#[ORM\Column(type: 'phone_number', nullable: true)]
#[PhonenumberConstraint(type: 'any')]
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::ANY])]
private ?PhoneNumber $phonenumber1 = null;
#[Serializer\Groups(['read', 'write', 'docgen:read'])]
#[ORM\Column(type: 'phone_number', nullable: true)]
#[PhonenumberConstraint(type: 'any')]
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::ANY])]
private ?PhoneNumber $phonenumber2 = null;
#[Serializer\Groups(['read'])]

View File

@@ -23,7 +23,6 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
/**
* User.
@@ -116,7 +115,7 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
* The user's mobile phone number.
*/
#[ORM\Column(type: 'phone_number', nullable: true)]
#[PhonenumberConstraint]
#[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber]
private ?PhoneNumber $phonenumber = null;
/**

View File

@@ -31,6 +31,8 @@ interface PhoneNumberHelperInterface
/**
* Return true if the validation is configured and available.
*
* @deprecated this is an internal behaviour of the helper and should not be taken into account outside of the implementation
*/
public function isPhonenumberValidationConfigured(): bool;

View File

@@ -122,7 +122,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface
*/
public function isValidPhonenumberAny($phonenumber): bool
{
if (false === $this->isPhonenumberValidationConfigured()) {
if (false === $this->isConfigured) {
return true;
}
$validation = $this->performTwilioLookup($phonenumber);
@@ -142,7 +142,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface
*/
public function isValidPhonenumberLandOrVoip($phonenumber): bool
{
if (false === $this->isPhonenumberValidationConfigured()) {
if (false === $this->isConfigured) {
return true;
}
@@ -163,7 +163,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface
*/
public function isValidPhonenumberMobile($phonenumber): bool
{
if (false === $this->isPhonenumberValidationConfigured()) {
if (false === $this->isConfigured) {
return true;
}
@@ -178,7 +178,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface
private function performTwilioLookup($phonenumber)
{
if (false === $this->isPhonenumberValidationConfigured()) {
if (false === $this->isConfigured) {
return null;
}

View File

@@ -158,3 +158,18 @@ export const intervalISOToDays = (str: string | null): number | null => {
return days;
};
export function getTimezoneOffsetString(date: Date, timeZone: string): string {
const utcDate = new Date(date.toLocaleString("en-US", { timeZone: "UTC" }));
const tzDate = new Date(date.toLocaleString("en-US", { timeZone }));
const offsetMinutes = (utcDate.getTime() - tzDate.getTime()) / (60 * 1000);
// Inverser le signe pour avoir la convention ±HH:MM
const sign = offsetMinutes <= 0 ? "+" : "-";
const absMinutes = Math.abs(offsetMinutes);
const hours = String(Math.floor(absMinutes / 60)).padStart(2, "0");
const minutes = String(absMinutes % 60).padStart(2, "0");
return `${sign}${hours}:${minutes}`;
}

View File

@@ -1,8 +1,14 @@
import { Scope } from "../../types";
import {
DynamicKeys,
Scope,
ValidationExceptionInterface,
ValidationProblemFromMap,
ViolationFromMap
} from "../../types";
export type body = Record<string, boolean | string | number | null>;
export type fetchOption = Record<string, boolean | string | number | null>;
export type Primitive = string | number | boolean | null;
export type Params = Record<string, number | string>;
export interface Pagination {
@@ -25,20 +31,115 @@ export interface TransportExceptionInterface {
name: string;
}
export interface ValidationExceptionInterface
extends TransportExceptionInterface {
name: "ValidationException";
error: object;
violations: string[];
titles: string[];
propertyPaths: string[];
export class ValidationException<
M extends Record<string, Record<string, string|number>> = Record<
string,
Record<string, string|number>
>,
>
extends Error
implements ValidationExceptionInterface<M>
{
public readonly name = "ValidationException" as const;
public readonly problems: ValidationProblemFromMap<M>;
public readonly violations: string[];
public readonly violationsList: ViolationFromMap<M>[];
public readonly titles: string[];
public readonly propertyPaths: DynamicKeys<M> & string[];
public readonly byProperty: Record<Extract<keyof M, string>, string[]>;
constructor(problem: ValidationProblemFromMap<M>) {
const message = [problem.title, problem.detail].filter(Boolean).join(" — ");
super(message);
Object.setPrototypeOf(this, new.target.prototype);
this.problems = problem;
this.violationsList = problem.violations;
this.violations = problem.violations.map(
(v) => `${v.title}: ${v.propertyPath}`,
);
this.titles = problem.violations.map((v) => v.title);
this.propertyPaths = problem.violations.map(
(v) => v.propertyPath,
) as DynamicKeys<M> & string[];
this.byProperty = problem.violations.reduce(
(acc, v) => {
const key = v.propertyPath.replace('/\[\d+\]$/', "") as Extract<keyof M, string>;
(acc[key] ||= []).push(v.title);
return acc;
},
{} as Record<Extract<keyof M, string>, string[]>,
);
if (Error.captureStackTrace) {
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
);
}
}
export interface ValidationErrorResponse extends TransportExceptionInterface {
violations: {
title: string;
propertyPath: string;
}[];
/**
* Check that the exception is a ValidationExceptionInterface
* @param x
*/
export function isValidationException<M extends Record<string, Record<string, string|number>>>(
x: unknown,
): x is ValidationExceptionInterface<M> {
return (
x instanceof ValidationException ||
(typeof x === "object" &&
x !== null &&
(x as any).name === "ValidationException")
);
}
export function isValidationProblem(x: unknown): x is {
type: string;
title: string;
violations: { propertyPath: string; title: string }[];
} {
if (!x || typeof x !== "object") return false;
const o = x as any;
return (
typeof o.type === "string" &&
typeof o.title === "string" &&
Array.isArray(o.violations) &&
o.violations.every(
(v: any) =>
v &&
typeof v === "object" &&
typeof v.propertyPath === "string" &&
typeof v.title === "string",
)
);
}
export interface AccessExceptionInterface extends TransportExceptionInterface {
@@ -65,12 +166,151 @@ export interface ConflictHttpExceptionInterface
}
/**
* Generic api method that can be adapted to any fetch request
* Generic api method that can be adapted to any fetch request.
*
* This method is suitable make a single fetch. When performing a GET to fetch a list of elements, always consider pagination
* and use of the @link{fetchResults} method.
* What this does
* - Performs a single HTTP request using fetch and returns the parsed JSON as Output.
* - Interprets common API errors and throws typed exceptions you can catch in your UI.
* - When the server returns a Symfony validation problem (HTTP 422), the error is
* rethrown as a typed ValidationException that is aware of your Violation Map (see below).
*
* Important: For GET endpoints that return lists, prefer using fetchResults, which
* handles pagination and aggregation for you.
*
* Violation Map (M): make your 422 errors strongly typed
* ------------------------------------------------------
* Symfonys validation problem+json payload looks like this (simplified):
*
* {
* "type": "https://symfony.com/errors/validation",
* "title": "Validation Failed",
* "violations": [
* {
* "propertyPath": "mobilenumber",
* "title": "This value is not a valid phone number.",
* "parameters": {
* "{{ value }}": "+33 1 02 03 04 05",
* "{{ types }}": "mobile number"
* },
* "type": "urn:uuid:..."
* }
* ]
* }
*
* The makeFetch generic type parameter M lets you describe, field by field, which
* parameters may appear for each propertyPath. Doing so gives you full type-safety when
* consuming ValidationException in your UI code.
*
* How to build M (Violation Map)
* - M is a map where each key is a server-side propertyPath (string), and the value is a
* record describing the allowed keys in the parameters object for that property.
* - Keys in parameters are the exact strings you receive from Symfony, including the
* curly-braced placeholders such as "{{ value }}", "{{ types }}", etc.
*
* Example from Person creation (WritePersonViolationMap)
* -----------------------------------------------------
* In ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.ts youll find:
*
* export type WritePersonViolationMap = {
* gender: {
* "{{ value }}": string | null;
* };
* mobilenumber: {
* "{{ types }}": string; // ex: "mobile number"
* "{{ value }}": string; // ex: "+33 1 02 03 04 05"
* };
* };
*
* This means:
* - If the server reports a violation for propertyPath "gender", the parameters object
* is expected to contain a key "{{ value }}" with a string or null value.
* - If the server reports a violation for propertyPath "mobilenumber", the parameters
* may include "{{ value }}" and "{{ types }}" as strings.
*
* How makeFetch uses M
* - When the response has status 422 and the payload matches a Symfony validation
* problem, makeFetch casts it to ValidationProblemFromMap<M> and throws a
* ValidationException<M>.
* - The ValidationException exposes helpful, pre-computed fields:
* - exception.problem: the full typed payload
* - exception.violations: ["Title: propertyPath", ...]
* - exception.titles: ["Title 1", "Title 2", ...]
* - exception.propertyPaths: ["gender", "mobilenumber", ...] (typed from M)
* - exception.byProperty: { gender: [titles...], mobilenumber: [titles...] }
*
* Typical usage patterns
* ----------------------
* 1) GET without Validation Map (no 422 expected):
*
* const centers = await makeFetch<null, { showCenters: boolean; centers: Center[] }>(
* "GET",
* "/api/1.0/person/creation/authorized-centers",
* null
* );
*
* 2) POST with body and Violation Map:
*
* type WritePersonViolationMap = {
* gender: { "{{ value }}": string | null };
* mobilenumber: { "{{ types }}": string; "{{ value }}": string };
* };
*
* try {
* const created = await makeFetch<PersonWrite, Person, WritePersonViolationMap>(
* "POST",
* "/api/1.0/person/person.json",
* personPayload
* );
* // Success path
* } catch (e) {
* if (isValidationException(e)) {
* // Fully typed:
* e.propertyPaths.includes("mobilenumber");
* const firstTitleForMobile = e.byProperty.mobilenumber?.[0];
* // You can also inspect parameter values:
* const v = e.problem.violations.find(v => v.propertyPath === "mobilenumber");
* const rawValue = v?.parameters?.["{{ value }}"]; // typed as string
* } else {
* // Other error handling (AccessException, ConflictHttpException, etc.)
* }
* }
*
* Tips to design your Violation Map
* - Use exact propertyPath strings as exposed by the API (they usually match your
* DTO field names or entity property paths used by the validator).
* - Inside each property, list only the placeholders that you actually read in the UI
* (you can always add more later). This keeps your types strict but pragmatic.
* - If a field may not include parameters at all, you can set it to an empty object {}.
* - If you dont care about parameter typing, you can omit M entirely and rely on the
* default loose typing (Record<string, Primitive>), but youll lose safety.
*
* Error taxonomy thrown by makeFetch
* - ValidationException<M> when status = 422 and payload is a validation problem.
* - AccessException when status = 403.
* - ConflictHttpException when status = 409.
* - A generic error object for other non-ok statuses.
*
* @typeParam Input - Shape of the request body you send (if any)
* @typeParam Output - Shape of the successful JSON response you expect
* @typeParam M - Violation Map describing the per-field parameters you expect
* in Symfony validation violations. See examples above.
*
* @param method The HTTP method to use (POST, GET, PUT, PATCH, DELETE)
* @param url The absolute or relative URL to call
* @param body The request payload. If null/undefined, no body is sent
* @param options Extra fetch options/headers merged into the request
*
* @returns The parsed JSON response typed as Output. For 204 No Content, resolves
* with undefined (void).
*/
export const makeFetch = <Input, Output>(
export const makeFetch = async <
Input,
Output,
M extends Record<string, Record<string, string|number>> = Record<
string,
Record<string, string|number>
>,
>(
method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE",
url: string,
body?: body | Input | null,
@@ -90,7 +330,8 @@ export const makeFetch = <Input, Output>(
if (typeof options !== "undefined") {
opts = Object.assign(opts, options);
}
return fetch(url, opts).then((response) => {
return fetch(url, opts).then(async (response) => {
if (response.status === 204) {
return Promise.resolve();
}
@@ -100,9 +341,20 @@ export const makeFetch = <Input, Output>(
}
if (response.status === 422) {
return response.json().then((response) => {
throw ValidationException(response);
});
// Unprocessable Entity -> payload de validation Symfony
const json = await response.json().catch(() => undefined);
if (isValidationProblem(json)) {
// On ré-interprète le payload selon M (ParamMap) pour typer les violations
const problem = json as unknown as ValidationProblemFromMap<M>;
throw new ValidationException<M>(problem);
}
const err = new Error(
"Validation failed but payload is not a ValidationProblem",
);
(err as any).raw = json;
throw err;
}
if (response.status === 403) {
@@ -167,12 +419,6 @@ function _fetchAction<T>(
throw NotFoundException(response);
}
if (response.status === 422) {
return response.json().then((response) => {
throw ValidationException(response);
});
}
if (response.status === 403) {
throw AccessException(response);
}
@@ -231,24 +477,6 @@ export const fetchScopes = (): Promise<Scope[]> => {
return fetchResults("/api/1.0/main/scope.json");
};
/**
* Error objects to be thrown
*/
const ValidationException = (
response: ValidationErrorResponse,
): ValidationExceptionInterface => {
const error = {} as ValidationExceptionInterface;
error.name = "ValidationException";
error.violations = response.violations.map(
(violation) => `${violation.title}: ${violation.propertyPath}`,
);
error.titles = response.violations.map((violation) => violation.title);
error.propertyPaths = response.violations.map(
(violation) => violation.propertyPath,
);
return error;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const AccessException = (response: Response): AccessExceptionInterface => {
const error = {} as AccessExceptionInterface;

View File

@@ -1,14 +1,51 @@
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
import { CreatableEntityType } from "ChillPersonAssets/types";
export interface DateTime {
datetime: string;
datetime8601: string;
}
/**
* A date representation to use when we create or update a date
*/
export interface DateTimeWrite {
/**
* Must be a string in format Y-m-d\TH:i:sO
*/
datetime: string;
}
export interface Civility {
type: "chill_main_civility";
id: number;
abbreviation: TranslatableString;
active: boolean;
name: TranslatableString;
}
/**
* Lightweight reference to Civility, to use in POST or PUT requests.
*/
export interface SetCivility {
type: "chill_main_civility";
id: number;
}
export interface Gender {
type: "chill_main_gender";
id: number;
label: string;
genderTranslation: string;
}
/**
* Lightweight reference to a Gender, used in POST / PUT requests.
*/
export interface SetGender {
type: "chill_main_gender";
id: number;
// TODO
}
export interface Household {
@@ -28,6 +65,18 @@ export interface Center {
id: number;
type: "center";
name: string;
isActive: boolean;
}
/**
* SetCenter is a lightweight reference used in POST/PUT requests to associate an existing center with a resource.
* It links by id only and does not create or modify centers.
* Expected shape: { type: "center", id: number }.
* Requests will fail if the id is invalid, the center doesn't exist, or permissions are insufficient.
*/
export interface SetCenter {
id: number;
type: "center";
}
export interface Scope {
@@ -226,13 +275,63 @@ export interface TransportExceptionInterface {
name: string;
}
export interface ValidationExceptionInterface
extends TransportExceptionInterface {
type IndexedKey<Base extends string> = `${Base}[${number}]`;
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";
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[];
/** Only titles */
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 {
@@ -300,3 +399,12 @@ export interface TabDefinition {
icon: string | null;
counter: () => number;
}
/**
* Configuration for the CreateModal and Create component
*/
export interface CreateComponentConfig {
action?: string;
allowedTypes: CreatableEntityType[];
query?: string;
}

View File

@@ -12,24 +12,22 @@
ref="showAddress"
/>
<!-- step 1 -->
<teleport to="body" v-if="inModal">
<modal
v-if="flag.suggestPane"
modal-dialog-class="modal-dialog-scrollable modal-xl"
@close="resetPane"
>
<template #header>
<h2 class="modal-title">
{{ trans(getTextTitle) }}
<span v-if="flag.loading" class="loading">
<i class="fa fa-circle-o-notch fa-spin fa-fw" />
<span class="sr-only">{{
trans(ADDRESS_LOADING)
}}</span>
</span>
</h2>
</template>
<!-- step 1 -->
<teleport to="body" v-if="inModal">
<modal
v-if="flag.suggestPane"
modal-dialog-class="modal-dialog-scrollable modal-xl"
@close="resetPane"
>
<template #header>
<h2 class="modal-title">
{{ trans(getTextTitle) }}
<span v-if="flag.loading" class="loading">
<i class="fa fa-circle-o-notch fa-spin fa-fw" />
<span class="sr-only">{{ trans(ADDRESS_LOADING) }}</span>
</span>
</h2>
</template>
<template #body>
<suggest-pane
@@ -90,9 +88,7 @@
{{ trans(getTextTitle) }}
<span v-if="flag.loading" class="loading">
<i class="fa fa-circle-o-notch fa-spin fa-fw" />
<span class="sr-only">{{
trans(ADDRESS_LOADING)
}}</span>
<span class="sr-only">{{ trans(ADDRESS_LOADING) }}</span>
</span>
</h2>
</template>
@@ -175,9 +171,7 @@
{{ trans(getTextTitle) }}
<span v-if="flag.loading" class="loading">
<i class="fa fa-circle-o-notch fa-spin fa-fw" />
<span class="sr-only">{{
trans(ADDRESS_LOADING)
}}</span>
<span class="sr-only">{{ trans(ADDRESS_LOADING) }}</span>
</span>
</h2>
</template>
@@ -248,14 +242,14 @@ import {
} from "../api";
import {
CREATE_A_NEW_ADDRESS,
ADDRESS_LOADING,
ACTIVITY_CREATE_ADDRESS,
ACTIVITY_EDIT_ADDRESS,
ADDRESS_LOADING,
ACTIVITY_CREATE_ADDRESS,
ACTIVITY_EDIT_ADDRESS,
CANCEL,
SAVE,
PREVIOUS,
NEXT,
trans,
SAVE,
PREVIOUS,
NEXT,
trans,
} from "translator";
import ShowPane from "./ShowPane.vue";
import SuggestPane from "./SuggestPane.vue";
@@ -265,16 +259,17 @@ import DatePane from "./DatePane.vue";
export default {
name: "AddAddress",
setup() {
return {
trans,
CREATE_A_NEW_ADDRESS,
ADDRESS_LOADING,
CANCEL,
SAVE,
PREVIOUS,
NEXT,
};
},props: ["context", "options", "addressChangedCallback"],
return {
trans,
CREATE_A_NEW_ADDRESS,
ADDRESS_LOADING,
CANCEL,
SAVE,
PREVIOUS,
NEXT,
};
},
props: ["context", "options", "addressChangedCallback"],
components: {
Modal,
ShowPane,
@@ -394,9 +389,9 @@ export default {
) {
console.log("this.options.title", this.options.title);
return this.context.edit
? ACTIVITY_EDIT_ADDRESS
: ACTIVITY_CREATE_ADDRESS;
return this.context.edit
? ACTIVITY_EDIT_ADDRESS
: ACTIVITY_CREATE_ADDRESS;
}
return this.context.edit
? this.defaultz.title.edit

View File

@@ -55,9 +55,7 @@
:placeholder="trans(ADDRESS_BUILDING_NAME)"
v-model="buildingName"
/>
<label for="buildingName">{{
trans(ADDRESS_BUILDING_NAME)
}}</label>
<label for="buildingName">{{ trans(ADDRESS_BUILDING_NAME) }}</label>
</div>
<div class="form-floating my-1">
<input
@@ -79,9 +77,7 @@
:placeholder="trans(ADDRESS_DISTRIBUTION)"
v-model="distribution"
/>
<label for="distribution">{{
trans(ADDRESS_DISTRIBUTION)
}}</label>
<label for="distribution">{{ trans(ADDRESS_DISTRIBUTION) }}</label>
</div>
</div>
</div>
@@ -89,35 +85,36 @@
<script>
import {
ADDRESS_STREET,
ADDRESS_STREET_NUMBER,
ADDRESS_FLOOR,
ADDRESS_CORRIDOR,
ADDRESS_STEPS,
ADDRESS_FLAT,
ADDRESS_BUILDING_NAME,
ADDRESS_DISTRIBUTION,
ADDRESS_EXTRA,
ADDRESS_FILL_AN_ADDRESS,
trans,
ADDRESS_STREET,
ADDRESS_STREET_NUMBER,
ADDRESS_FLOOR,
ADDRESS_CORRIDOR,
ADDRESS_STEPS,
ADDRESS_FLAT,
ADDRESS_BUILDING_NAME,
ADDRESS_DISTRIBUTION,
ADDRESS_EXTRA,
ADDRESS_FILL_AN_ADDRESS,
trans,
} from "translator";
export default {
name: "AddressMore",
setup() {
return {
ADDRESS_STREET,
ADDRESS_STREET_NUMBER,
ADDRESS_FLOOR,
ADDRESS_CORRIDOR,
ADDRESS_STEPS,
ADDRESS_FLAT,
ADDRESS_BUILDING_NAME,
ADDRESS_DISTRIBUTION,
ADDRESS_EXTRA,
ADDRESS_FILL_AN_ADDRESS,
trans,
};
},props: ["entity", "isNoAddress"],
return {
ADDRESS_STREET,
ADDRESS_STREET_NUMBER,
ADDRESS_FLOOR,
ADDRESS_CORRIDOR,
ADDRESS_STEPS,
ADDRESS_FLAT,
ADDRESS_BUILDING_NAME,
ADDRESS_DISTRIBUTION,
ADDRESS_EXTRA,
ADDRESS_FILL_AN_ADDRESS,
trans,
};
},
props: ["entity", "isNoAddress"],
computed: {
floor: {
set(value) {

View File

@@ -57,9 +57,7 @@
:placeholder="trans(ADDRESS_STREET_NUMBER)"
v-model="streetNumber"
/>
<label for="streetNumber">{{
trans(ADDRESS_STREET_NUMBER)
}}</label>
<label for="streetNumber">{{ trans(ADDRESS_STREET_NUMBER) }}</label>
</div>
</div>
</div>
@@ -72,31 +70,32 @@ import {
fetchReferenceAddresses,
} from "../../api.js";
import {
ADDRESS_STREET,
ADDRESS_STREET_NUMBER,
ADDRESS_ADDRESS,
MULTISELECT_SELECTED_LABEL,
MULTISELECT_SELECT_LABEL,
ADDRESS_SELECT_ADDRESS,
ADDRESS_CREATE_ADDRESS,
trans,
ADDRESS_STREET,
ADDRESS_STREET_NUMBER,
ADDRESS_ADDRESS,
MULTISELECT_SELECTED_LABEL,
MULTISELECT_SELECT_LABEL,
ADDRESS_SELECT_ADDRESS,
ADDRESS_CREATE_ADDRESS,
trans,
} from "translator";
export default {
name: "AddressSelection",
components: { VueMultiselect },
setup() {
return {
ADDRESS_STREET,
ADDRESS_STREET_NUMBER,
ADDRESS_ADDRESS,
MULTISELECT_SELECTED_LABEL,
MULTISELECT_SELECT_LABEL,
ADDRESS_SELECT_ADDRESS,
ADDRESS_CREATE_ADDRESS,
trans,
};
},props: ["entity", "context", "updateMapCenter", "flag", "checkErrors"],
return {
ADDRESS_STREET,
ADDRESS_STREET_NUMBER,
ADDRESS_ADDRESS,
MULTISELECT_SELECTED_LABEL,
MULTISELECT_SELECT_LABEL,
ADDRESS_SELECT_ADDRESS,
ADDRESS_CREATE_ADDRESS,
trans,
};
},
props: ["entity", "context", "updateMapCenter", "flag", "checkErrors"],
data() {
return {
value: this.context.edit ? this.entity.address.addressReference : null,

View File

@@ -61,31 +61,32 @@
import VueMultiselect from "vue-multiselect";
import { searchCities, fetchCities } from "../../api.js";
import {
MULTISELECT_SELECTED_LABEL,
MULTISELECT_SELECT_LABEL,
ADDRESS_POSTAL_CODE_CODE,
ADDRESS_POSTAL_CODE_NAME,
ADDRESS_CREATE_POSTAL_CODE,
ADDRESS_CITY,
ADDRESS_SELECT_CITY,
trans,
MULTISELECT_SELECTED_LABEL,
MULTISELECT_SELECT_LABEL,
ADDRESS_POSTAL_CODE_CODE,
ADDRESS_POSTAL_CODE_NAME,
ADDRESS_CREATE_POSTAL_CODE,
ADDRESS_CITY,
ADDRESS_SELECT_CITY,
trans,
} from "translator";
export default {
name: "CitySelection",
components: { VueMultiselect },
setup() {
return {
MULTISELECT_SELECTED_LABEL,
MULTISELECT_SELECT_LABEL,
ADDRESS_CITY,
ADDRESS_SELECT_CITY,
ADDRESS_POSTAL_CODE_CODE,
ADDRESS_POSTAL_CODE_NAME,
ADDRESS_CREATE_POSTAL_CODE,
trans,
};
},props: [
return {
MULTISELECT_SELECTED_LABEL,
MULTISELECT_SELECT_LABEL,
ADDRESS_CITY,
ADDRESS_SELECT_CITY,
ADDRESS_POSTAL_CODE_CODE,
ADDRESS_POSTAL_CODE_NAME,
ADDRESS_CREATE_POSTAL_CODE,
trans,
};
},
props: [
"entity",
"context",
"focusOnAddress",

View File

@@ -24,27 +24,28 @@
import VueMultiselect from "vue-multiselect";
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
import {
MULTISELECT_SELECTED_LABEL,
MULTISELECT_SELECT_LABEL,
MULTISELECT_DESELECT_LABEL,
ADDRESS_COUNTRY,
ADDRESS_SELECT_COUNTRY,
trans,
MULTISELECT_SELECTED_LABEL,
MULTISELECT_SELECT_LABEL,
MULTISELECT_DESELECT_LABEL,
ADDRESS_COUNTRY,
ADDRESS_SELECT_COUNTRY,
trans,
} from "translator";
export default {
name: "CountrySelection",
components: { VueMultiselect },
setup() {
return {
MULTISELECT_SELECTED_LABEL,
MULTISELECT_SELECT_LABEL,
MULTISELECT_DESELECT_LABEL,
ADDRESS_COUNTRY,
ADDRESS_SELECT_COUNTRY,
trans,
};
},props: ["context", "entity", "flag", "checkErrors"],
return {
MULTISELECT_SELECTED_LABEL,
MULTISELECT_SELECT_LABEL,
MULTISELECT_DESELECT_LABEL,
ADDRESS_COUNTRY,
ADDRESS_SELECT_COUNTRY,
trans,
};
},
props: ["context", "entity", "flag", "checkErrors"],
emits: ["getCities"],
data() {
return {

View File

@@ -106,10 +106,10 @@ import AddressMap from "./AddAddress/AddressMap";
import AddressMore from "./AddAddress/AddressMore";
import ActionButtons from "./ActionButtons.vue";
import {
ADDRESS_SELECT_AN_ADDRESS_TITLE,
ADDRESS_IS_CONFIDENTIAL,
ADDRESS_IS_NO_ADDRESS,
trans,
ADDRESS_SELECT_AN_ADDRESS_TITLE,
ADDRESS_IS_CONFIDENTIAL,
ADDRESS_IS_NO_ADDRESS,
trans,
} from "translator";
export default {
@@ -123,13 +123,14 @@ export default {
ActionButtons,
},
setup() {
return {
trans,
ADDRESS_SELECT_AN_ADDRESS_TITLE,
ADDRESS_IS_CONFIDENTIAL,
ADDRESS_IS_NO_ADDRESS,
};
},props: [
return {
trans,
ADDRESS_SELECT_AN_ADDRESS_TITLE,
ADDRESS_IS_CONFIDENTIAL,
ADDRESS_IS_NO_ADDRESS,
};
},
props: [
"context",
"options",
"defaultz",

View File

@@ -11,9 +11,7 @@
<div v-if="flag.success" class="alert alert-success">
{{ trans(getSuccessText) }}
<span v-if="forceRedirect">{{
trans(ADDRESS_WAIT_REDIRECTION)
}}</span>
<span v-if="forceRedirect">{{ trans(ADDRESS_WAIT_REDIRECTION) }}</span>
</div>
<div
@@ -101,34 +99,36 @@
import AddressRenderBox from "ChillMainAssets/vuejs/_components/Entity/AddressRenderBox.vue";
import ActionButtons from "./ActionButtons.vue";
import {
ACTIVITY_CREATE_ADDRESS,
ACTIVITY_EDIT_ADDRESS,
ADDRESS_NOT_YET_ADDRESS,
ADDRESS_WAIT_REDIRECTION,
ADDRESS_LOADING,
ADDRESS_ADDRESS_EDIT_SUCCESS,
ADDRESS_ADDRESS_NEW_SUCCESS,
trans,
ACTIVITY_CREATE_ADDRESS,
ACTIVITY_EDIT_ADDRESS,
ADDRESS_NOT_YET_ADDRESS,
ADDRESS_WAIT_REDIRECTION,
ADDRESS_LOADING,
ADDRESS_ADDRESS_EDIT_SUCCESS,
ADDRESS_ADDRESS_NEW_SUCCESS,
trans,
} from "translator";
export default {
name: "ShowPane",
methods: {},components: {
methods: {},
components: {
AddressRenderBox,
ActionButtons,
},
setup() {
return {
trans,
ACTIVITY_CREATE_ADDRESS,
ACTIVITY_EDIT_ADDRESS,
ADDRESS_NOT_YET_ADDRESS,
ADDRESS_WAIT_REDIRECTION,
ADDRESS_LOADING,
ADDRESS_ADDRESS_NEW_SUCCESS,
ADDRESS_ADDRESS_EDIT_SUCCESS,
};
},props: [
return {
trans,
ACTIVITY_CREATE_ADDRESS,
ACTIVITY_EDIT_ADDRESS,
ADDRESS_NOT_YET_ADDRESS,
ADDRESS_WAIT_REDIRECTION,
ADDRESS_LOADING,
ADDRESS_ADDRESS_NEW_SUCCESS,
ADDRESS_ADDRESS_EDIT_SUCCESS,
};
},
props: [
"context",
"defaultz",
"options",
@@ -169,17 +169,19 @@ export default {
this.options.button.text.create !== null)
) {
// console.log('this.options.button.text', this.options.button.text)
return this.context.edit
? ACTIVITY_CREATE_ADDRESS
: ACTIVITY_EDIT_ADDRESS;
}
console.log("defaultz", this.defaultz);
return this.context.edit
? ACTIVITY_CREATE_ADDRESS
: ACTIVITY_EDIT_ADDRESS;
}
console.log("defaultz", this.defaultz);
return this.context.edit
? this.defaultz.button.text.edit
: this.defaultz.button.text.create;
},
getSuccessText() {
return this.context.edit ? ADDRESS_ADDRESS_EDIT_SUCCESS : ADDRESS_ADDRESS_NEW_SUCCESS;
return this.context.edit
? ADDRESS_ADDRESS_EDIT_SUCCESS
: ADDRESS_ADDRESS_NEW_SUCCESS;
},
onlyButton() {
return typeof this.options.onlyButton !== "undefined"

View File

@@ -1,6 +1,6 @@
<template>
<ul class="nav nav-tabs">
<li v-if="allowedTypes.includes('person')" class="nav-item">
<li v-if="containsPerson" class="nav-item">
<a class="nav-link" :class="{ active: isActive('person') }">
<label for="person">
<input
@@ -14,7 +14,7 @@
</label>
</a>
</li>
<li v-if="allowedTypes.includes('thirdparty')" class="nav-item">
<li v-if="containsThirdParty" class="nav-item">
<a class="nav-link" :class="{ active: isActive('thirdparty') }">
<label for="thirdparty">
<input
@@ -31,11 +31,12 @@
</ul>
<div class="my-4">
<on-the-fly-person
<PersonEdit
v-if="type === 'person'"
:action="action"
action="create"
:query="query"
ref="castPerson"
@onPersonCreated="(payload) => emit('onPersonCreated', payload)"
/>
<on-the-fly-thirdparty
@@ -46,34 +47,47 @@
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import OnTheFlyPerson from "ChillPersonAssets/vuejs/_components/OnTheFly/Person.vue";
import OnTheFlyThirdparty from "ChillThirdPartyAssets/vuejs/_components/OnTheFly/ThirdParty.vue";
import {
trans,
ONTHEFLY_CREATE_PERSON,
ONTHEFLY_CREATE_THIRDPARTY,
trans,
} from "translator";
import { CreatableEntityType, Person } from "ChillPersonAssets/types";
import { CreateComponentConfig } from "ChillMainAssets/types";
import PersonEdit from "ChillPersonAssets/vuejs/_components/OnTheFly/PersonEdit.vue";
const props = defineProps({
action: String,
allowedTypes: Array,
query: String,
const props = withDefaults(defineProps<CreateComponentConfig>(), {
allowedTypes: ["person"],
action: "create",
query: "",
});
const type = ref(null);
const emit =
defineEmits<(e: "onPersonCreated", payload: { person: Person }) => void>();
const radioType = computed({
const type = ref<CreatableEntityType | null>(null);
const radioType = computed<CreatableEntityType | null>({
get: () => type.value,
set: (val) => {
set: (val: CreatableEntityType | null) => {
type.value = val;
console.log("## type:", val, ", action:", props.action);
},
});
const castPerson = ref(null);
const castThirdparty = ref(null);
type PersonEditComponent = InstanceType<typeof PersonEdit>;
type AnyComponentInstance =
| InstanceType<typeof OnTheFlyPerson>
| InstanceType<typeof OnTheFlyThirdparty>
| null;
const castPerson = ref<PersonEditComponent>(null);
const castThirdparty = ref<AnyComponentInstance>(null);
onMounted(() => {
type.value =
@@ -82,30 +96,22 @@ onMounted(() => {
: "person";
});
function isActive(tab) {
function isActive(tab: CreatableEntityType) {
return type.value === tab;
}
function castDataByType() {
switch (radioType.value) {
case "person":
return castPerson.value.$data.person;
case "thirdparty":
let data = castThirdparty.value.$data.thirdparty;
if (data.address !== undefined && data.address !== null) {
data.address = { id: data.address.address_id };
} else {
data.address = null;
}
return data;
default:
throw Error("Invalid type of entity");
}
const containsThirdParty = computed<boolean>(() =>
props.allowedTypes.includes("thirdparty"),
);
const containsPerson = computed<boolean>(() => {
return props.allowedTypes.includes("person");
});
function save(): void {
castPerson.value.postPerson();
}
defineExpose({
castDataByType,
});
defineExpose({ save });
</script>
<style lang="css" scoped>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
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";
const emit = defineEmits<{
(e: "onPersonCreated", payload: { person: Person }): void;
(e: "close"): void;
}>();
const props = defineProps<CreateComponentConfig>();
const modalDialogClass = { "modal-xl": true, "modal-scrollable": true };
type CreateComponentType = InstanceType<typeof Create>;
const create = useTemplateRef<CreateComponentType>("create");
function save(): void {
console.log("save from CreateModal");
create.value?.save();
}
defineExpose({ save });
</script>
<template>
<teleport to="body">
<modal
@close="() => emit('close')"
:modal-dialog-class="modalDialogClass"
:hide-footer="false"
>
<template #header>
<h3 class="modal-title">{{ modalTitle }}</h3>
</template>
<template #body-head>
<div class="modal-body">
<Create
ref="create"
:allowedTypes="props.allowedTypes"
:action="props.action"
:query="props.query"
@onPersonCreated="(payload) => emit('onPersonCreated', payload)"
></Create>
</div>
</template>
<template #footer>
<button class="btn btn-save" type="button" @click.prevent="save">
{{ trans(SAVE) }}
</button>
</template>
</modal>
</teleport>
</template>
<style scoped lang="scss"></style>

View File

@@ -40,6 +40,7 @@
:key="uniqid"
:buttonTitle="translatedListOfTypes"
:modalTitle="translatedListOfTypes"
:allowCreate="true"
@addNewPersons="addNewEntity"
>
</add-persons>
@@ -76,6 +77,7 @@ import {
EntitiesOrMe,
EntityType,
SearchOptions,
Suggestion,
} from "ChillPersonAssets/types";
import {
PICK_ENTITY_MODAL_TITLE,
@@ -182,7 +184,7 @@ function addNewSuggested(entity: EntitiesOrMe) {
emits("addNewEntity", { entity });
}
function addNewEntity({ selected }: addNewEntities) {
function addNewEntity({ selected }: { selected: Suggestion[] }) {
Object.values(selected).forEach((item) => {
emits("addNewEntity", { entity: item.result });
});

View File

@@ -52,23 +52,15 @@ import { trans, MODAL_ACTION_CLOSE } from "translator";
import { defineProps } from "vue";
export interface ModalProps {
modalDialogClass: string;
hideFooter: boolean;
modalDialogClass?: string | Record<string, boolean>;
hideFooter?: boolean;
show?: boolean;
}
defineProps({
modalDialogClass: {
type: String,
default: "",
},
hideFooter: {
type: Boolean,
default: false,
},
show: {
type: Boolean,
default: true,
},
const props = withDefaults(defineProps<ModalProps>(), {
modalDialogClass: "",
hideFooter: false,
show: true,
});
const emits = defineEmits<{

View File

@@ -22,7 +22,7 @@ class DateNormalizer implements ContextAwareNormalizerInterface, DenormalizerInt
{
public function __construct(private readonly RequestStack $requestStack, private readonly ParameterBagInterface $parameterBag) {}
public function denormalize($data, $type, $format = null, array $context = [])
public function denormalize($data, $type, $format = null, array $context = []): ?\DateTimeInterface
{
if (null === $data) {
return null;
@@ -51,7 +51,7 @@ class DateNormalizer implements ContextAwareNormalizerInterface, DenormalizerInt
return $result;
}
public function normalize($date, $format = null, array $context = [])
public function normalize($date, $format = null, array $context = []): array
{
/* @var DateTimeInterface $date */
switch ($format) {

View File

@@ -46,7 +46,10 @@ class PhonenumberNormalizer implements ContextAwareNormalizerInterface, Denormal
try {
return $this->phoneNumberUtil->parse($data, $this->defaultCarrierCode);
} catch (NumberParseException $e) {
throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
$phonenumber = new PhoneNumber();
$phonenumber->setRawInput($data);
return $phonenumber;
}
}

View File

@@ -13,6 +13,9 @@ namespace Chill\MainBundle\Validation\Constraint;
use Symfony\Component\Validator\Constraint;
/**
* @deprecated use odolbeau/phonenumber validator instead
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)]
class PhonenumberConstraint extends Constraint
{

View File

@@ -16,6 +16,9 @@ use Psr\Log\LoggerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* @deprecated use https://github.com/odolbeau/phone-number-bundle/blob/master/src/Validator/Constraints/PhoneNumberValidator.php instead
*/
final class ValidPhonenumber extends ConstraintValidator
{
public function __construct(private readonly LoggerInterface $logger, private readonly PhoneNumberHelperInterface $phonenumberHelper) {}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
use Chill\MainBundle\Serializer\Model\Collection;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface;
final readonly class PersonIdentifierListApiController
{
public function __construct(
private Security $security,
private SerializerInterface $serializer,
private PersonIdentifierManagerInterface $personIdentifierManager,
private PaginatorFactoryInterface $paginatorFactory,
) {}
#[Route('/api/1.0/person/identifiers/workers', name: 'person_person_identifiers_worker_list')]
public function list(): Response
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException();
}
$workers = $this->personIdentifierManager->getWorkers();
$paginator = $this->paginatorFactory->create(count($workers));
$paginator->setItemsPerPage(count($workers));
$collection = new Collection($workers, $paginator);
return new JsonResponse($this->serializer->serialize($collection, 'json'), json: true);
}
}

View File

@@ -96,7 +96,6 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
// We can get rid of this file when the service 'chill.person.repository.person' is no more used.
// We should use the PersonRepository service instead of a custom service name.
$loader->load('services/repository.yaml');
$loader->load('services/serializer.yaml');
$loader->load('services/security.yaml');
$loader->load('services/doctrineEventListener.yaml');
$loader->load('services/accompanyingPeriodConsistency.yaml');

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Entity\Identifier;
enum IdentifierPresenceEnum: string
{
/**
* The person identifier is not editable by any user.
*
* The identifier is intended to be added by an import script, for instance.
*/
case NOT_EDITABLE = 'NOT_EDITABLE';
/**
* The person identifier is present on the edit form only.
*/
case ON_EDIT = 'ON_EDIT';
/**
* The person identifier is present on both person's creation form, and edit form.
*/
case ON_CREATION = 'ON_CREATION';
/**
* The person identifier is required to create the person. It should not be empty.
*/
case REQUIRED = 'REQUIRED';
public function isEditableByUser(): bool
{
return IdentifierPresenceEnum::NOT_EDITABLE !== $this;
}
}

View File

@@ -12,10 +12,13 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Entity\Identifier;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\PersonIdentifier\Validator\UniqueIdentifierConstraint;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'chill_person_identifier')]
#[ORM\UniqueConstraint(name: 'chill_person_identifier_unique', columns: ['definition_id', 'value'])]
#[UniqueIdentifierConstraint]
class PersonIdentifier
{
#[ORM\Id]
@@ -30,7 +33,7 @@ class PersonIdentifier
#[ORM\Column(name: 'value', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
private array $value = [];
#[ORM\Column(name: 'canonical', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => '[]'])]
#[ORM\Column(name: 'canonical', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
private string $canonical = '';
public function __construct(

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Entity\Identifier;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
@@ -18,23 +19,23 @@ use Doctrine\ORM\Mapping as ORM;
class PersonIdentifierDefinition
{
#[ORM\Id]
#[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)]
#[ORM\Column(name: 'id', type: Types::INTEGER)]
#[ORM\GeneratedValue]
private ?int $id = null;
#[ORM\Column(name: 'active', type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => true])]
#[ORM\Column(name: 'active', type: Types::BOOLEAN, nullable: false, options: ['default' => true])]
private bool $active = true;
public function __construct(
#[ORM\Column(name: 'label', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])]
#[ORM\Column(name: 'label', type: Types::JSON, nullable: false, options: ['default' => '[]'])]
private array $label,
#[ORM\Column(name: 'engine', type: \Doctrine\DBAL\Types\Types::STRING, length: 100)]
#[ORM\Column(name: 'engine', type: Types::STRING, length: 100)]
private string $engine,
#[ORM\Column(name: 'is_searchable', type: \Doctrine\DBAL\Types\Types::BOOLEAN, options: ['default' => false])]
#[ORM\Column(name: 'is_searchable', type: Types::BOOLEAN, options: ['default' => false])]
private bool $isSearchable = false,
#[ORM\Column(name: 'is_editable_by_users', type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => false])]
private bool $isEditableByUsers = false,
#[ORM\Column(name: 'data', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
#[ORM\Column(name: 'presence', type: Types::STRING, nullable: false, enumType: IdentifierPresenceEnum::class, options: ['default' => IdentifierPresenceEnum::ON_EDIT])]
private IdentifierPresenceEnum $presence = IdentifierPresenceEnum::ON_EDIT,
#[ORM\Column(name: 'data', type: Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
private array $data = [],
) {}
@@ -58,11 +59,6 @@ class PersonIdentifierDefinition
return $this->engine;
}
public function setEngine(string $engine): void
{
$this->engine = $engine;
}
public function isSearchable(): bool
{
return $this->isSearchable;
@@ -75,12 +71,7 @@ class PersonIdentifierDefinition
public function isEditableByUsers(): bool
{
return $this->isEditableByUsers;
}
public function setIsEditableByUsers(bool $isEditableByUsers): void
{
$this->isEditableByUsers = $isEditableByUsers;
return $this->presence->isEditableByUser();
}
public function isActive(): bool
@@ -104,4 +95,16 @@ class PersonIdentifierDefinition
{
$this->data = $data;
}
public function getPresence(): IdentifierPresenceEnum
{
return $this->presence;
}
public function setPresence(IdentifierPresenceEnum $presence): self
{
$this->presence = $presence;
return $this;
}
}

View File

@@ -27,7 +27,6 @@ use Chill\MainBundle\Entity\Language;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Entity\Household\PersonHouseholdAddress;
@@ -36,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;
@@ -47,6 +47,7 @@ use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
use libphonenumber\PhoneNumber;
use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as MisdPhoneNumberConstraint;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -273,6 +274,8 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
private ?int $id = null;
#[ORM\OneToMany(mappedBy: 'person', targetEntity: PersonIdentifier::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[RequiredIdentifierConstraint]
#[Assert\Valid]
private Collection $identifiers;
/**
@@ -319,7 +322,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* The person's mobile phone number.
*/
#[ORM\Column(type: 'phone_number', nullable: true)]
#[PhonenumberConstraint(type: 'mobile')]
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::MOBILE])]
private ?PhoneNumber $mobilenumber = null;
/**
@@ -359,7 +362,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* The person's phonenumber.
*/
#[ORM\Column(type: 'phone_number', nullable: true)]
#[PhonenumberConstraint(type: 'landline')]
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::FIXED_LINE, MisdPhoneNumberConstraint::VOIP, MisdPhoneNumberConstraint::PERSONAL_NUMBER])]
private ?PhoneNumber $phonenumber = null;
/**

View File

@@ -89,6 +89,11 @@ final class CreationPersonType extends AbstractType
'label' => false,
]);
$builder->add('identifiers', PersonIdentifiersType::class, [
'by_reference' => false,
'step' => 'on_create',
]);
if ($this->askCenters) {
$builder
->add('center', PickCenterType::class, [

View File

@@ -12,10 +12,12 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Form;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\Identifier\IdentifierPresenceEnum;
use Chill\PersonBundle\Form\DataMapper\PersonIdentifiersDataMapper;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class PersonIdentifiersType extends AbstractType
{
@@ -32,6 +34,12 @@ final class PersonIdentifiersType extends AbstractType
continue;
}
// skip some on creation
if ('on_create' === $options['step']
&& IdentifierPresenceEnum::ON_EDIT === $worker->getDefinition()->getPresence()) {
continue;
}
$subBuilder = $builder->create(
'identifier_'.$worker->getDefinition()->getId(),
options: [
@@ -45,4 +53,10 @@ final class PersonIdentifiersType extends AbstractType
$builder->setDataMapper($this->identifiersDataMapper);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('step', 'on_edit')
->setAllowedValues('step', ['on_edit', 'on_create']);
}
}

View File

@@ -26,7 +26,7 @@ final readonly class StringIdentifier implements PersonIdentifierEngineInterface
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
{
return $value['content'] ?? '';
return trim($value['content'] ?? '');
}
public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void
@@ -36,6 +36,11 @@ final readonly class StringIdentifier implements PersonIdentifierEngineInterface
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
{
return $identifier?->getValue()['content'] ?? '';
return trim($identifier?->getValue()['content'] ?? '');
}
public function isEmpty(PersonIdentifier $identifier): bool
{
return '' === trim($identifier->getValue()['content'] ?? '');
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier\Normalizer;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
final readonly class PersonIdentifierWorkerNormalizer implements NormalizerInterface
{
public function normalize($object, ?string $format = null, array $context = []): array
{
if (!$object instanceof PersonIdentifierWorker) {
throw new \Symfony\Component\Serializer\Exception\UnexpectedValueException();
}
return [
'type' => 'person_identifier_worker',
'definition_id' => $object->getDefinition()->getId(),
'engine' => $object->getDefinition()->getEngine(),
'label' => $object->getDefinition()->getLabel(),
'isActive' => $object->getDefinition()->isActive(),
'presence' => $object->getDefinition()->getPresence()->value,
];
}
public function supportsNormalization($data, ?string $format = null): bool
{
return $data instanceof PersonIdentifierWorker;
}
}

View File

@@ -24,4 +24,12 @@ interface PersonIdentifierEngineInterface
public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void;
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string;
/**
* Return true if the identifier must be considered as empty.
*
* This is in use when the identifier is validated and must be required. If the identifier is empty and is required
* by the definition, the validation will fails.
*/
public function isEmpty(PersonIdentifier $identifier): bool;
}

View File

@@ -13,6 +13,7 @@ namespace Chill\PersonBundle\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Chill\PersonBundle\PersonIdentifier\Exception\EngineNotFoundException;
use Chill\PersonBundle\PersonIdentifier\Exception\PersonIdentifierDefinitionNotFoundException;
use Chill\PersonBundle\Repository\Identifier\PersonIdentifierDefinitionRepository;
final readonly class PersonIdentifierManager implements PersonIdentifierManagerInterface
@@ -44,8 +45,16 @@ final readonly class PersonIdentifierManager implements PersonIdentifierManagerI
return $workers;
}
public function buildWorkerByPersonIdentifierDefinition(PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker
public function buildWorkerByPersonIdentifierDefinition(int|PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker
{
if (is_int($personIdentifierDefinition)) {
$id = $personIdentifierDefinition;
$personIdentifierDefinition = $this->personIdentifierDefinitionRepository->find($id);
if (null === $personIdentifierDefinition) {
throw new PersonIdentifierDefinitionNotFoundException($id);
}
}
return new PersonIdentifierWorker($this->getEngine($personIdentifierDefinition->getEngine()), $personIdentifierDefinition);
}

View File

@@ -22,5 +22,8 @@ interface PersonIdentifierManagerInterface
*/
public function getWorkers(): array;
public function buildWorkerByPersonIdentifierDefinition(PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker;
/**
* @param int|PersonIdentifierDefinition $personIdentifierDefinition an instance of PersonIdentifierDefinition, or his id
*/
public function buildWorkerByPersonIdentifierDefinition(int|PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker;
}

View File

@@ -46,4 +46,12 @@ final readonly class PersonIdentifierWorker
{
return $this->identifierEngine->renderAsString($identifier, $this->definition);
}
/**
* Return true if the identifier must be considered as empty.
*/
public function isEmpty(PersonIdentifier $identifier): bool
{
return $this->identifierEngine->isEmpty($identifier);
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier\Validator;
use Symfony\Component\Validator\Constraint;
/**
* Test that the required constraints are present.
*/
#[\Attribute]
class RequiredIdentifierConstraint extends Constraint
{
public string $message = 'person_identifier.This identifier must be set';
public function getTargets(): string
{
return self::PROPERTY_CONSTRAINT;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier\Validator;
use Chill\PersonBundle\Entity\Identifier\IdentifierPresenceEnum;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
final class RequiredIdentifierConstraintValidator extends ConstraintValidator
{
public function __construct(private readonly PersonIdentifierManagerInterface $identifierManager) {}
public function validate($value, Constraint $constraint)
{
if (!$constraint instanceof RequiredIdentifierConstraint) {
throw new UnexpectedTypeException($constraint, RequiredIdentifierConstraint::class);
}
if (!$value instanceof Collection) {
throw new UnexpectedValueException($value, Collection::class);
}
foreach ($this->identifierManager->getWorkers() as $worker) {
if (IdentifierPresenceEnum::REQUIRED !== $worker->getDefinition()->getPresence()) {
continue;
}
$identifier = $value->findFirst(fn (int $key, PersonIdentifier $identifier) => $identifier->getDefinition() === $worker->getDefinition());
if (null === $identifier || $worker->isEmpty($identifier)) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $worker->renderAsString($identifier))
->setParameter('definition_id', (string) $worker->getDefinition()->getId())
->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05')
->addViolation();
}
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute]
class UniqueIdentifierConstraint extends Constraint
{
public string $message = 'person_identifier.Identifier must be unique. The same identifier already exists for {{ persons }}';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier\Validator;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Repository\Identifier\PersonIdentifierRepository;
use Chill\PersonBundle\Templating\Entity\PersonRenderInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
class UniqueIdentifierConstraintValidator extends ConstraintValidator
{
public function __construct(
private readonly PersonIdentifierRepository $personIdentifierRepository,
private readonly PersonRenderInterface $personRender,
) {}
public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof UniqueIdentifierConstraint) {
throw new UnexpectedTypeException($constraint, UniqueIdentifierConstraint::class);
}
if (!$value instanceof PersonIdentifier) {
throw new UnexpectedValueException($value, PersonIdentifier::class);
}
$identifiers = $this->personIdentifierRepository->findByDefinitionAndValue($value->getDefinition(), $value->getValue());
if (count($identifiers) > 0) {
$persons = array_map(fn (PersonIdentifier $idf): string => $this->personRender->renderString($idf->getPerson(), []), $identifiers);
$this->context->buildViolation($constraint->message)
->setParameter('{{ persons }}', implode(', ', $persons))
->setParameter('definition_id', (string) $value->getDefinition()->getId())
->addViolation();
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Repository\Identifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\Persistence\ManagerRegistry;
class PersonIdentifierRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PersonIdentifier::class);
}
public function findByDefinitionAndValue(PersonIdentifierDefinition $definition, array $value): array
{
return $this->createQueryBuilder('p')
->where('p.definition = :definition')
->andWhere('p.value = :value')
->setParameter('definition', $definition)
->setParameter('value', $value, Types::JSON)
->getQuery()
->getResult();
}
}

View File

@@ -17,6 +17,8 @@ use Chill\MainBundle\Search\ParsingException;
use Chill\MainBundle\Search\SearchApiQuery;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
@@ -27,7 +29,13 @@ use Symfony\Component\Security\Core\Security;
final readonly class PersonACLAwareRepository implements PersonACLAwareRepositoryInterface
{
public function __construct(private Security $security, private EntityManagerInterface $em, private CountryRepository $countryRepository, private AuthorizationHelperInterface $authorizationHelper) {}
public function __construct(
private Security $security,
private EntityManagerInterface $em,
private CountryRepository $countryRepository,
private AuthorizationHelperInterface $authorizationHelper,
private PersonIdentifierManagerInterface $personIdentifierManager,
) {}
public function buildAuthorizedQuery(
?string $default = null,
@@ -107,6 +115,15 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor
$query
->setFromClause('chill_person_person AS person');
$idDefinitionWorkers = array_map(
fn (PersonIdentifierWorker $worker) => $worker->getDefinition()->getId(),
array_filter(
$this->personIdentifierManager->getWorkers(),
fn (PersonIdentifierWorker $worker) => $worker->getDefinition()->isSearchable()
)
);
$idDefinitionWorkerQuestionMarks = implode(', ', array_fill(0, count($idDefinitionWorkers), '?'));
$pertinence = [];
$pertinenceArgs = [];
$andWhereSearchClause = [];
@@ -124,10 +141,25 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor
'(starts_with(LOWER(UNACCENT(lastname)), UNACCENT(LOWER(?))))::int';
\array_push($pertinenceArgs, $str, $str, $str, $str);
$andWhereSearchClause[] =
'(LOWER(UNACCENT(?)) <<% person.fullnamecanonical OR '.
"person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' )";
\array_push($andWhereSearchClauseArgs, $str, $str);
$q = [
'LOWER(UNACCENT(?)) <<% person.fullnamecanonical',
"person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' ",
];
$qArguments = [$str, $str];
if (count($idDefinitionWorkers) > 0) {
$q[] = $mq = "EXISTS (
SELECT 1 FROM chill_person_identifier AS identifier
WHERE identifier.canonical LIKE LOWER(UNACCENT(?)) || '%' AND identifier.definition_id IN ({$idDefinitionWorkerQuestionMarks})
AND person.id = identifier.person_id
)";
$pertinence[] = "({$mq})::int * 1_000_000";
$qArguments = [...$qArguments, $str, ...$idDefinitionWorkers];
$pertinenceArgs = [...$pertinenceArgs, $str, ...$idDefinitionWorkers];
}
$andWhereSearchClause[] = '('.implode(' OR ', $q).')';
$andWhereSearchClauseArgs = [...$andWhereSearchClauseArgs, ...$qArguments];
}
$query->andWhereClause(

View File

@@ -10,16 +10,25 @@ import {
Scope,
Job,
PrivateCommentEmbeddable,
TranslatableString,
DateTimeWrite,
SetGender,
SetCenter,
SetCivility,
} from "ChillMainAssets/types";
import { StoredObject } from "ChillDocStoreAssets/types";
import { Thirdparty } from "../../../ChillThirdPartyBundle/Resources/public/types";
import { Calendar } from "../../../ChillCalendarBundle/Resources/public/types";
import Person from "./vuejs/_components/OnTheFly/Person.vue";
export interface AltName {
label: string;
labels: TranslatableString;
key: string;
}
export interface AltNameWrite {
key: string;
value: string;
}
export interface Person {
id: number;
type: "person";
@@ -43,6 +52,32 @@ export interface Person {
current_residential_addresses: Address[];
}
export interface PersonIdentifierWrite {
type: "person_identifier";
definition_id: number;
value: object;
}
/**
* Person representation to create or update a Person
*/
export interface PersonWrite {
type: "person";
firstName: string;
lastName: string;
altNames: AltNameWrite[];
// address: number | null;
birthdate: DateTimeWrite | null;
deathdate: DateTimeWrite | null;
phonenumber: string;
mobilenumber: string;
email: string;
gender: SetGender | null;
center: SetCenter | null;
civility: SetCivility | null;
identifiers: PersonIdentifierWrite[];
}
export interface AccompanyingPeriod {
id: number;
type: "accompanying_period";
@@ -328,11 +363,18 @@ export interface AccompanyingPeriodWorkEvaluationDocument {
workflows: object[];
}
/**
* Entity types that a user can create
*/
export type CreatableEntityType = "person" | "thirdparty";
/**
* Entities that can be search and selected by a user
*/
export type EntityType =
| CreatableEntityType
| "user_group"
| "user"
| "person"
| "thirdparty"
| "household";
export type Entities = (UserGroup | User | Person | Thirdparty | Household) & {
@@ -370,7 +412,8 @@ export interface Search {
export interface SearchOptions {
uniq: boolean;
type: string[];
/** @deprecated */
type: EntityType[];
priority: number | null;
button: {
size: string;
@@ -380,6 +423,17 @@ export interface SearchOptions {
};
}
type PersonIdentifierPresence = 'NOT_EDITABLE' | 'ON_EDIT' | 'ON_CREATION' | 'REQUIRED';
export interface PersonIdentifierWorker {
type: "person_identifier_worker";
definition_id: number;
engine: string;
label: TranslatableString;
isActive: boolean;
presence: PersonIdentifierPresence;
}
export class MakeFetchException extends Error {
sta: number;
txt: string;

View File

@@ -1,88 +0,0 @@
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
/*
* GET a person by id
*/
const getPerson = (id) => {
const url = `/api/1.0/person/person/${id}.json`;
return fetch(url).then((response) => {
if (response.ok) {
return response.json();
}
throw Error("Error with request resource response");
});
};
const getPersonAltNames = () =>
fetch("/api/1.0/person/config/alt_names.json").then((response) => {
if (response.ok) {
return response.json();
}
throw Error("Error with request resource response");
});
const getCivilities = () =>
fetch("/api/1.0/main/civility.json").then((response) => {
if (response.ok) {
return response.json();
}
throw Error("Error with request resource response");
});
const getGenders = () => makeFetch("GET", "/api/1.0/main/gender.json");
// .then(response => {
// console.log(response)
// if (response.ok) { return response.json(); }
// throw Error('Error with request resource response');
// });
const getCentersForPersonCreation = () =>
makeFetch("GET", "/api/1.0/person/creation/authorized-centers", null);
/*
* POST a new person
*/
const postPerson = (body) => {
const url = `/api/1.0/person/person.json`;
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json;charset=utf-8",
},
body: JSON.stringify(body),
}).then((response) => {
if (response.ok) {
return response.json();
}
throw Error("Error with request resource response");
});
};
/*
* PATCH an existing person
*/
const patchPerson = (id, body) => {
const url = `/api/1.0/person/person/${id}.json`;
return fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json;charset=utf-8",
},
body: JSON.stringify(body),
}).then((response) => {
if (response.ok) {
return response.json();
}
throw Error("Error with request resource response");
});
};
export {
getCentersForPersonCreation,
getPerson,
getPersonAltNames,
getCivilities,
getGenders,
postPerson,
patchPerson,
};

View File

@@ -0,0 +1,87 @@
import { fetchResults, makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import { Center, Civility, Gender } from "ChillMainAssets/types";
import {
AltName,
Person,
PersonIdentifierWorker,
PersonWrite,
} from "ChillPersonAssets/types";
import person from "ChillPersonAssets/vuejs/_components/OnTheFly/Person.vue";
/*
* GET a person by id
*/
export const getPerson = async (id: number): Promise<Person> => {
const url = `/api/1.0/person/person/${id}.json`;
return fetch(url).then((response) => {
if (response.ok) {
return response.json();
}
throw Error("Error with request resource response");
});
};
export const getPersonAltNames = async (): Promise<AltName[]> =>
fetch("/api/1.0/person/config/alt_names.json").then((response) => {
if (response.ok) {
return response.json();
}
throw Error("Error with request resource response");
});
export const getCivilities = async (): Promise<Civility[]> =>
fetchResults("/api/1.0/main/civility.json");
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 getPersonIdentifiers = async (): Promise<
PersonIdentifierWorker[]
> => fetchResults("/api/1.0/person/identifiers/workers");
export interface WritePersonViolationMap
extends Record<string, Record<string, string>> {
firstName: {
"{{ value }}": string
};
lastName: {
"{{ value }}": string;
};
gender: {
"{{ value }}": string;
};
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;
};
center: {
"{{ value }}": string;
};
civility: {
"{{ value }}": string;
};
birthdate: {};
identifiers: {
"{{ value }}": string;
"definition_id": string;
};
}
export const createPerson = async (person: PersonWrite): Promise<Person> => {
return makeFetch<PersonWrite, Person, WritePersonViolationMap>(
"POST",
"/api/1.0/person/person.json",
person,
);
};

View File

@@ -3,487 +3,126 @@
class="btn"
:class="getClassButton"
:title="buttonTitle"
@click="openModal"
@click="openModalChoose"
>
<span v-if="displayTextButton">{{ buttonTitle }}</span>
</a>
<teleport to="body">
<modal
v-if="showModal"
@close="closeModal"
:modal-dialog-class="modalDialogClass"
:show="showModal"
:hide-footer="false"
>
<template #header>
<h3 class="modal-title">
{{ modalTitle }}
</h3>
</template>
<person-choose-modal
v-if="showModalChoose"
:modal-title="modalTitle"
:options="options"
:suggested="suggested"
:selected="selected"
:modal-dialog-class="'modal-dialog-scrollable modal-xl'"
:allow-create="props.allowCreate"
@close="closeModalChoose"
@addNewPersons="(payload) => emit('addNewPersons', payload)"
@onAskForCreate="onAskForCreate"
/>
<template #body-head>
<div class="modal-body">
<div class="search">
<label class="col-form-label" style="float: right">
{{
trans(ADD_PERSONS_SUGGESTED_COUNTER, {
count: suggestedCounter,
})
}}
</label>
<input
id="search-persons"
name="query"
v-model="query"
:placeholder="trans(ADD_PERSONS_SEARCH_SOME_PERSONS)"
ref="searchRef"
/>
<i class="fa fa-search fa-lg" />
<i
class="fa fa-times"
v-if="queryLength >= 3"
@click="resetSuggestion"
/>
</div>
</div>
<div class="modal-body" v-if="checkUniq === 'checkbox'">
<div class="count">
<span>
<a v-if="suggestedCounter > 2" @click="selectAll">
{{ trans(ACTION_CHECK_ALL) }}
</a>
<a v-if="selectedCounter > 0" @click="resetSelection">
<i v-if="suggestedCounter > 2"> </i>
{{ trans(ACTION_RESET) }}
</a>
</span>
<span v-if="selectedCounter > 0">
{{
trans(ADD_PERSONS_SELECTED_COUNTER, {
count: selectedCounter,
})
}}
</span>
</div>
</div>
</template>
<template #body>
<div class="results">
<person-suggestion
v-for="item in selectedAndSuggested.slice().reverse()"
:key="itemKey(item)"
:item="item"
:search="search"
:type="checkUniq"
@save-form-on-the-fly="saveFormOnTheFly"
@new-prior-suggestion="newPriorSuggestion"
@update-selected="updateSelected"
/>
<div class="create-button">
<on-the-fly
v-if="
queryLength >= 3 &&
(options.type.includes('person') ||
options.type.includes('thirdparty'))
"
:button-text="trans(ONTHEFLY_CREATE_BUTTON, { q: query })"
:allowed-types="options.type"
:query="query"
action="create"
@save-form-on-the-fly="saveFormOnTheFly"
ref="onTheFly"
/>
</div>
</div>
</template>
<template #footer>
<button
class="btn btn-create"
@click.prevent="
() => {
$emit('addNewPersons', {
selected: selectedComputed,
});
query = '';
closeModal();
}
"
>
{{ trans(ACTION_ADD) }}
</button>
</template>
</modal>
</teleport>
<CreateModal
v-if="creatableEntityTypes.length > 0 && showModalCreate"
:allowed-types="creatableEntityTypes"
:query="query"
@close="closeModalCreate"
@onPersonCreated="onPersonCreated"
></CreateModal>
</template>
<script setup lang="ts">
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { ref, reactive, computed, nextTick, watch, shallowRef } from "vue";
import PersonSuggestion from "./AddPersons/PersonSuggestion.vue";
import { searchEntities } from "ChillPersonAssets/vuejs/_api/AddPersons";
import OnTheFly from "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import {
trans,
ADD_PERSONS_SUGGESTED_COUNTER,
ADD_PERSONS_SEARCH_SOME_PERSONS,
ADD_PERSONS_SELECTED_COUNTER,
ONTHEFLY_CREATE_BUTTON,
ACTION_CHECK_ALL,
ACTION_RESET,
ACTION_ADD,
} from "translator";
import {
import { ref, computed } from "vue";
import PersonChooseModal from "./AddPersons/PersonChooseModal.vue";
import type {
Suggestion,
Search,
AddPersonResult as OriginalResult,
SearchOptions,
CreatableEntityType,
EntityType,
Person,
} from "ChillPersonAssets/types";
import { marked } from "marked";
import options = marked.options;
import CreateModal from "ChillMainAssets/vuejs/OnTheFly/components/CreateModal.vue";
// Extend Result type to include optional addressId
type Result = OriginalResult & { addressId?: number };
interface AddPersonsConfig {
suggested?: Suggestion[];
selected?: Suggestion[];
buttonTitle: string;
modalTitle: string;
options: SearchOptions;
allowCreate?: boolean;
types?: EntityType[] | undefined;
}
const props = defineProps({
suggested: { type: Array as () => Suggestion[], default: () => [] },
selected: { type: Array as () => Suggestion[], default: () => [] },
buttonTitle: { type: String, required: true },
modalTitle: { type: String, required: true },
options: { type: Object as () => SearchOptions, required: true },
const props = withDefaults(defineProps<AddPersonsConfig>(), {
suggested: () => [],
selected: () => [],
allowCreate: () => true,
types: () => ["person"],
});
defineEmits(["addNewPersons"]);
const emit =
defineEmits<
(e: "addNewPersons", payload: { selected: Suggestion[] }) => void
>();
const showModal = ref(false);
const modalDialogClass = ref("modal-dialog-scrollable modal-xl");
const modal = shallowRef({
showModal: false,
modalDialogClass: "modal-dialog-scrollable modal-xl",
});
const search = reactive({
query: "" as string,
previousQuery: "" as string,
currentSearchQueryController: null as AbortController | null,
suggested: props.suggested as Suggestion[],
selected: props.selected as Suggestion[],
priorSuggestion: {} as Partial<Suggestion>,
});
const searchRef = ref<HTMLInputElement | null>(null);
const onTheFly = ref<InstanceType<typeof OnTheFly> | null>(null);
const query = computed({
get: () => search.query,
set: (val) => setQuery(val),
});
const queryLength = computed(() => search.query.length);
const suggestedCounter = computed(() => search.suggested.length);
const selectedComputed = computed(() => search.selected);
const selectedCounter = computed(() => search.selected.length);
const showModalChoose = ref(false);
const showModalCreate = ref(false);
const query = ref("");
const getClassButton = computed(() => {
let size = props.options?.button?.size ?? "";
let type = props.options?.button?.type ?? "btn-create";
return size ? size + " " + type : type;
const size = props.options?.button?.size ?? "";
const type = props.options?.button?.type ?? "btn-create";
return size ? `${size} ${type}` : type;
});
const displayTextButton = computed(() =>
props.options?.button?.display !== undefined
? props.options.button.display
: true,
);
const checkUniq = computed(() =>
props.options.uniq === true ? "radio" : "checkbox",
);
const priorSuggestion = computed(() => search.priorSuggestion);
const hasPriorSuggestion = computed(() => !!search.priorSuggestion.key);
const itemKey = (item: Suggestion) => item.result.type + item.result.id;
function addPriorSuggestion() {
if (hasPriorSuggestion.value) {
// Type assertion is safe here due to the checks above
search.suggested.unshift(priorSuggestion.value as Suggestion);
search.selected.unshift(priorSuggestion.value as Suggestion);
newPriorSuggestion(null);
const creatableEntityTypes = computed<CreatableEntityType[]>(() => {
if (typeof props.options.type !== "undefined") {
return props.options.type.filter(
(e: EntityType) => e === "thirdparty" || e === "person",
);
}
}
const selectedAndSuggested = computed(() => {
addPriorSuggestion();
const uniqBy = (a: Suggestion[], key: (item: Suggestion) => string) => [
...new Map(a.map((x) => [key(x), x])).values(),
];
let union = [
...new Set([
...search.suggested.slice().reverse(),
...search.selected.slice().reverse(),
]),
];
return uniqBy(union, (k: Suggestion) => k.key);
return props.types.filter(
(e: EntityType) => e === "thirdparty" || e === "person",
);
});
function openModal() {
showModal.value = true;
nextTick(() => {
if (searchRef.value) searchRef.value.focus();
});
}
function closeModal() {
showModal.value = false;
function onAskForCreate(payload: { query: string }) {
query.value = payload.query;
showModalChoose.value = false;
showModalCreate.value = true;
}
function setQuery(q: string) {
search.query = q;
// Clear previous search if any
if (search.currentSearchQueryController) {
search.currentSearchQueryController.abort();
search.currentSearchQueryController = null;
}
if (q === "") {
loadSuggestions([]);
return;
}
// Debounce delay based on query length
const delay = q.length > 3 ? 300 : 700;
setTimeout(() => {
// Only search if query hasn't changed in the meantime
if (q !== search.query) return;
search.currentSearchQueryController = new AbortController();
searchEntities(
{ query: q, options: props.options },
search.currentSearchQueryController.signal,
)
.then((suggested: Search) => {
loadSuggestions(suggested.results);
})
.catch((error: DOMException) => {
if (error instanceof DOMException && error.name === "AbortError") {
// Request was aborted, ignore
return;
}
throw error;
});
}, delay);
function openModalChoose() {
showModalChoose.value = true;
}
function loadSuggestions(suggestedArr: Suggestion[]) {
search.suggested = suggestedArr;
search.suggested.forEach((item) => {
item.key = itemKey(item);
});
function closeModalChoose() {
showModalChoose.value = false;
}
function updateSelected(value: Suggestion[]) {
search.selected = value;
function closeModalCreate() {
showModalCreate.value = false;
}
function resetSuggestion() {
search.query = "";
search.suggested = [];
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] });
}
function resetSelection() {
search.selected = [];
}
function resetSearch() {
resetSelection();
resetSuggestion();
}
function selectAll() {
search.suggested.forEach((item) => {
search.selected.push(item);
});
}
function newPriorSuggestion(entity: Result | null) {
if (entity !== null) {
let suggestion = {
key: entity.type + entity.id,
relevance: 0.5,
result: entity,
};
search.priorSuggestion = suggestion;
} else {
search.priorSuggestion = {};
}
}
async function saveFormOnTheFly({
type,
data,
}: {
type: string;
data: Result;
}) {
try {
if (type === "person") {
const responsePerson: Result = await makeFetch(
"POST",
"/api/1.0/person/person.json",
data,
);
newPriorSuggestion(responsePerson);
if (onTheFly.value) onTheFly.value.closeModal();
if (data.addressId != null) {
const household = { type: "household" };
const address = { id: data.addressId };
try {
const responseHousehold: Result = await makeFetch(
"POST",
"/api/1.0/person/household.json",
household,
);
const member = {
concerned: [
{
person: {
type: "person",
id: responsePerson.id,
},
start_date: {
datetime: `${new Date().toISOString().split("T")[0]}T00:00:00+02:00`,
},
holder: false,
comment: null,
},
],
destination: {
type: "household",
id: responseHousehold.id,
},
composition: null,
};
await makeFetch(
"POST",
"/api/1.0/person/household/members/move.json",
member,
);
try {
const _response = await makeFetch(
"POST",
`/api/1.0/person/household/${responseHousehold.id}/address.json`,
address,
);
console.log(_response);
} catch (error) {
console.error(error);
}
} catch (error) {
console.error(error);
}
}
} else if (type === "thirdparty") {
const response: Result = await makeFetch(
"POST",
"/api/1.0/thirdparty/thirdparty.json",
data,
);
newPriorSuggestion(response);
if (onTheFly.value) onTheFly.value.closeModal();
}
} catch (error) {
console.error(error);
}
}
watch(
() => props.selected,
(newSelected) => {
search.selected = newSelected;
},
{ deep: true },
);
watch(
() => props.suggested,
(newSuggested) => {
search.suggested = newSuggested;
},
{ deep: true },
);
watch(
() => modal,
(val) => {
showModal.value = val.value.showModal;
modalDialogClass.value = val.value.modalDialogClass;
},
{ deep: true },
);
defineExpose({
resetSearch,
showModal,
});
</script>
<style lang="scss">
li.add-persons {
a {
cursor: pointer;
}
}
div.body-head {
overflow-y: unset;
div.modal-body:first-child {
margin: auto 4em;
div.search {
position: relative;
input {
width: 100%;
padding: 1.2em 1.5em 1.2em 2.5em;
//margin: 1em 0;
}
i {
position: absolute;
opacity: 0.5;
padding: 0.65em 0;
top: 50%;
}
i.fa-search {
left: 0.5em;
}
i.fa-times {
right: 1em;
padding: 0.75em 0;
cursor: pointer;
}
}
}
div.modal-body:last-child {
padding-bottom: 0;
}
div.count {
margin: -0.5em 0 0.7em;
display: flex;
justify-content: space-between;
a {
cursor: pointer;
}
}
}
.create-button > a {
margin-top: 0.5em;
margin-left: 2.6em;
}
<style lang="scss" scoped>
/* Button styles can remain here if needed */
</style>

View File

@@ -0,0 +1,429 @@
<template>
<teleport to="body">
<modal
@close="() => emit('close')"
:modal-dialog-class="modalDialogClass"
:hide-footer="false"
>
<template #header>
<h3 class="modal-title">{{ modalTitle }}</h3>
</template>
<template #body-head>
<div class="modal-body">
<div class="search">
<label class="col-form-label" style="float: right">
{{
trans(ADD_PERSONS_SUGGESTED_COUNTER, {
count: suggestedCounter,
})
}}
</label>
<input
id="search-persons"
name="query"
v-model="query"
:placeholder="trans(ADD_PERSONS_SEARCH_SOME_PERSONS)"
ref="searchRef"
/>
<i class="fa fa-search fa-lg" />
<i
class="fa fa-times"
v-if="queryLength >= 3"
@click="resetSuggestion"
/>
</div>
</div>
<div class="modal-body" v-if="checkUniq === 'checkbox'">
<div class="count">
<span>
<a v-if="suggestedCounter > 2" @click="selectAll">
{{ trans(ACTION_CHECK_ALL) }}
</a>
<a v-if="selectedCounter > 0" @click="resetSelection">
<i v-if="suggestedCounter > 2"> </i>
{{ trans(ACTION_RESET) }}
</a>
</span>
<span v-if="selectedCounter > 0">
{{
trans(ADD_PERSONS_SELECTED_COUNTER, { count: selectedCounter })
}}
</span>
</div>
</div>
</template>
<template #body>
<div class="results">
<person-suggestion
v-for="item in selectedAndSuggested.slice().reverse()"
:key="itemKey(item)"
:item="item"
:search="search"
:type="checkUniq"
@new-prior-suggestion="newPriorSuggestion"
@update-selected="updateSelected"
/>
<div
v-if="props.allowCreate && query.length > 0"
class="create-button"
>
<button type="button" @click="emit('onAskForCreate', { query })">
{{ trans(ONTHEFLY_CREATE_BUTTON, { q: query }) }}
</button>
<!--
TODO remove this
<on-the-fly
v-if="
queryLength >= 3 &&
(options.type.includes('person') || options.type.includes('thirdparty'))
"
:button-text="trans(ONTHEFLY_CREATE_BUTTON, { q: query })"
:allowed-types="options.type"
:query="query"
action="create"
@save-form-on-the-fly="saveFormOnTheFly"
ref="onTheFly"
/>
-->
</div>
</div>
</template>
<template #footer>
<button
type="button"
class="btn btn-create"
@click.prevent="pickEntities"
>
{{ trans(ACTION_ADD) }}
</button>
</template>
</modal>
</teleport>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick, watch, onMounted } from "vue";
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import PersonSuggestion from "./PersonSuggestion.vue";
import OnTheFly from "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue";
import { searchEntities } from "ChillPersonAssets/vuejs/_api/AddPersons";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import {
trans,
ADD_PERSONS_SUGGESTED_COUNTER,
ADD_PERSONS_SEARCH_SOME_PERSONS,
ADD_PERSONS_SELECTED_COUNTER,
ONTHEFLY_CREATE_BUTTON,
ACTION_CHECK_ALL,
ACTION_RESET,
ACTION_ADD,
} from "translator";
import type {
Suggestion,
Search,
AddPersonResult as OriginalResult,
SearchOptions,
EntitiesOrMe,
} from "ChillPersonAssets/types";
type Result = OriginalResult & { addressId?: number };
interface Props {
modalTitle: string;
options: SearchOptions;
suggested?: Suggestion[];
selected?: Suggestion[];
modalDialogClass?: string;
allowCreate?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
suggested: () => [],
selected: () => [],
modalDialogClass: "modal-dialog-scrollable modal-xl",
allowCreate: () => true,
});
const emit = defineEmits<{
(e: "close"): void;
/** @deprecated use 'onPickEntities' */
(e: "addNewPersons", payload: { selected: Suggestion[] }): void;
(e: "onPickEntities", payload: { selected: EntitiesOrMe[] }): void;
(e: "onAskForCreate", payload: { query: string }): void;
}>();
const searchRef = ref<HTMLInputElement | null>(null);
const onTheFly = ref<InstanceType<typeof OnTheFly> | null>(null);
onMounted(() => {
// give the focus on the search bar
searchRef.value?.focus();
});
const search = reactive({
query: "" as string,
previousQuery: "" as string,
currentSearchQueryController: null as AbortController | null,
suggested: (props.suggested ?? []) as Suggestion[],
selected: (props.selected ?? []) as Suggestion[],
priorSuggestion: {} as Partial<Suggestion>,
});
watch(
() => props.selected,
(newSelected) => {
search.selected = newSelected ? [...newSelected] : [];
},
{ deep: true },
);
watch(
() => props.suggested,
(newSuggested) => {
search.suggested = newSuggested ? [...newSuggested] : [];
},
{ deep: true },
);
const query = computed({
get: () => search.query,
set: (val: string) => setQuery(val),
});
const queryLength = computed(() => search.query.length);
const suggestedCounter = computed(() => search.suggested.length);
const selectedComputed = computed<Suggestion[]>(() => search.selected);
const selectedCounter = computed(() => search.selected.length);
const checkUniq = computed(() =>
props.options.uniq === true ? "radio" : "checkbox",
);
const priorSuggestion = computed(() => search.priorSuggestion);
const hasPriorSuggestion = computed(() => !!search.priorSuggestion.key);
const itemKey = (item: Suggestion) => item.result.type + item.result.id;
function addPriorSuggestion() {
if (hasPriorSuggestion.value) {
search.suggested.unshift(priorSuggestion.value as Suggestion);
search.selected.unshift(priorSuggestion.value as Suggestion);
newPriorSuggestion(null);
}
}
const selectedAndSuggested = computed(() => {
addPriorSuggestion();
const uniqBy = (a: Suggestion[], key: (item: Suggestion) => string) => [
...new Map(a.map((x) => [key(x), x])).values(),
];
const union = [
...new Set([
...search.suggested.slice().reverse(),
...search.selected.slice().reverse(),
]),
];
return uniqBy(union, (k: Suggestion) => k.key);
});
function setQuery(q: string) {
search.query = q;
if (search.currentSearchQueryController) {
search.currentSearchQueryController.abort();
search.currentSearchQueryController = null;
}
if (q === "") {
loadSuggestions([]);
return;
}
const delay = q.length > 3 ? 300 : 700;
setTimeout(() => {
if (q !== search.query) return;
search.currentSearchQueryController = new AbortController();
searchEntities(
{ query: q, options: props.options },
search.currentSearchQueryController.signal,
)
.then((suggested: Search) => {
loadSuggestions(suggested.results);
})
.catch((error: DOMException) => {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
throw error;
});
}, delay);
}
function loadSuggestions(suggestedArr: Suggestion[]) {
search.suggested = suggestedArr;
search.suggested.forEach((item) => {
item.key = itemKey(item);
});
}
function updateSelected(value: Suggestion[]) {
search.selected = value;
}
function resetSuggestion() {
search.query = "";
search.suggested = [];
}
function resetSelection() {
search.selected = [];
}
function resetSearch() {
resetSelection();
resetSuggestion();
}
function selectAll() {
search.suggested.forEach((item) => {
search.selected.push(item);
});
}
function newPriorSuggestion(entity: Result | null) {
if (entity !== null) {
const suggestion: Suggestion = {
key: entity.type + entity.id,
relevance: 0.5,
result: entity,
} as Suggestion;
search.priorSuggestion = suggestion;
} else {
search.priorSuggestion = {};
}
}
/**
* Triggered when the user clicks on the "add" button.
*/
function pickEntities(): void {
emit("addNewPersons", { selected: search.selected });
emit("onPickEntities", {
selected: search.selected.map((s: Suggestion) => s.result),
});
search.query = "";
emit("close");
}
/*
TODO remove this
async function saveFormOnTheFly({ type, data }: { type: string; data: Result }) {
try {
if (type === 'person') {
const responsePerson: Result = await makeFetch('POST', '/api/1.0/person/person.json', data);
newPriorSuggestion(responsePerson);
onTheFly.value?.closeModal();
if (data.addressId != null) {
const household = { type: 'household' };
const address = { id: data.addressId };
try {
const responseHousehold: Result = await makeFetch('POST', '/api/1.0/person/household.json', household);
const member = {
concerned: [
{
person: { type: 'person', id: responsePerson.id },
start_date: { datetime: `${new Date().toISOString().split('T')[0]}T00:00:00+02:00` },
holder: false,
comment: null,
},
],
destination: { type: 'household', id: responseHousehold.id },
composition: null,
};
await makeFetch('POST', '/api/1.0/person/household/members/move.json', member);
try {
await makeFetch('POST', `/api/1.0/person/household/${responseHousehold.id}/address.json`, address);
} catch (error) {
console.error(error);
}
} catch (error) {
console.error(error);
}
}
} else if (type === 'thirdparty') {
const response: Result = await makeFetch('POST', '/api/1.0/thirdparty/thirdparty.json', data);
newPriorSuggestion(response);
onTheFly.value?.closeModal();
}
} catch (error) {
console.error(error);
}
}
*/
defineExpose({ resetSearch });
</script>
<style lang="scss" scoped>
li.add-persons {
a {
cursor: pointer;
}
}
div.body-head {
overflow-y: unset;
div.modal-body:first-child {
margin: auto 4em;
div.search {
position: relative;
input {
width: 100%;
padding: 1.2em 1.5em 1.2em 2.5em;
}
i {
position: absolute;
opacity: 0.5;
padding: 0.65em 0;
top: 50%;
}
i.fa-search {
left: 0.5em;
}
i.fa-times {
right: 1em;
padding: 0.75em 0;
cursor: pointer;
}
}
}
div.modal-body:last-child {
padding-bottom: 0;
}
div.count {
margin: -0.5em 0 0.7em;
display: flex;
justify-content: space-between;
a {
cursor: pointer;
}
}
}
.create-button > a {
margin-top: 0.5em;
margin-left: 2.6em;
}
</style>

View File

@@ -35,7 +35,7 @@ const { person, isCut = false, addAge = true } = toRefs(props);
const altNameLabel = computed(() => {
if (!person.value.altNames) return "";
return person.value.altNames.map((a: AltName) => a.label).join("");
return person.value.altNames.map((a: AltName) => a.labels).join("");
});
const altNameKey = computed(() => {

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="action === 'show'">
<div v-if="action === 'show' && person !== null">
<div class="flex-table">
<person-render-box
render="bloc"
@@ -22,445 +22,48 @@
</div>
<div v-else-if="action === 'edit' || action === 'create'">
<div class="form-floating mb-3">
<input
class="form-control form-control-lg"
id="lastname"
v-model="lastName"
:placeholder="trans(PERSON_MESSAGES_PERSON_LASTNAME)"
@change="checkErrors"
/>
<label for="lastname">{{ trans(PERSON_MESSAGES_PERSON_LASTNAME) }}</label>
</div>
<div v-if="queryItems">
<ul class="list-suggest add-items inline">
<li
v-for="(qi, i) in queryItems"
:key="i"
@click="addQueryItem('lastName', qi)"
>
<span class="person-text">{{ qi }}</span>
</li>
</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)"
@change="checkErrors"
/>
<label for="firstname">{{
trans(PERSON_MESSAGES_PERSON_FIRSTNAME)
}}</label>
</div>
<div v-if="queryItems">
<ul class="list-suggest add-items inline">
<li
v-for="(qi, i) in queryItems"
:key="i"
@click="addQueryItem('firstName', qi)"
>
<span class="person-text">{{ qi }}</span>
</li>
</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"
:value="personAltNamesLabels[i]"
@input="onAltNameInput"
/>
<label :for="a.key">{{ localizeString(a.labels) }}</label>
</div>
<!-- TODO fix placeholder if undefined
-->
<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>
<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>
<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>
<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>
<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>
<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>
<div v-if="action === 'create'" class="input-group mb-3 form-check">
<input
class="form-check-input"
type="checkbox"
v-model="showAddressForm"
name="showAddressForm"
/>
<label class="form-check-label">
{{ trans(PERSON_MESSAGES_PERSON_ADDRESS_SHOW_ADDRESS_FORM) }}
</label>
</div>
<div
v-if="action === 'create' && showAddressFormValue"
class="form-floating mb-3"
>
<p>{{ trans(PERSON_MESSAGES_PERSON_ADDRESS_WARNING) }}</p>
<AddAddress
:context="addAddress.context"
:options="addAddress.options"
:addressChangedCallback="submitNewAddress"
ref="addAddress"
/>
</div>
<div class="alert alert-warning" v-if="errors.length">
<ul>
<li v-for="(e, i) in errors" :key="i">{{ e }}</li>
</ul>
</div>
<PersonEdit
:id="props.id"
:type="props.type"
:action="props.action"
:query="props.query"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from "vue";
import {
getCentersForPersonCreation,
getCivilities,
getGenders,
getPerson,
getPersonAltNames,
} from "../../_api/OnTheFly";
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { getPerson } from "../../_api/OnTheFly";
import PersonRenderBox from "../Entity/PersonRenderBox.vue";
import AddAddress from "ChillMainAssets/vuejs/Address/components/AddAddress.vue";
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
import {
trans,
PERSON_MESSAGES_PERSON_LASTNAME,
PERSON_MESSAGES_PERSON_FIRSTNAME,
PERSON_MESSAGES_PERSON_GENDER_PLACEHOLDER,
PERSON_MESSAGES_PERSON_GENDER_TITLE,
PERSON_MESSAGES_PERSON_CENTER_PLACEHOLDER,
PERSON_MESSAGES_PERSON_CENTER_TITLE,
PERSON_MESSAGES_PERSON_CIVILITY_PLACEHOLDER,
PERSON_MESSAGES_PERSON_CIVILITY_TITLE,
PERSON_MESSAGES_PERSON_PHONENUMBER,
PERSON_MESSAGES_PERSON_MOBILENUMBER,
PERSON_MESSAGES_PERSON_EMAIL,
PERSON_MESSAGES_PERSON_ADDRESS_SHOW_ADDRESS_FORM,
PERSON_MESSAGES_PERSON_ADDRESS_WARNING,
} from "translator";
import PersonEdit from "./PersonEdit.vue";
import type { Person } from "ChillPersonAssets/types";
const props = defineProps({
id: [String, Number],
type: String,
action: String,
query: String,
});
const person = reactive({
type: "person",
lastName: "",
firstName: "",
altNames: [],
addressId: null,
center: null,
gender: null,
civility: null,
birthdate: null,
phonenumber: "",
mobilenumber: "",
email: "",
});
const config = reactive({
altNames: [],
civilities: [],
centers: [],
genders: [],
});
const showCenters = ref(false);
const showAddressFormValue = ref(false);
const errors = ref([]);
const addAddress = reactive({
options: {
button: {
text: { create: "person.address.create_address" },
size: "btn-sm",
},
title: { create: "person.address.create_address" },
},
context: {
target: {},
edit: false,
addressId: null,
defaults: window.addaddress,
},
});
const firstName = computed({
get: () => person.firstName,
set: (value) => {
person.firstName = value;
},
});
const lastName = computed({
get: () => person.lastName,
set: (value) => {
person.lastName = value;
},
});
const gender = computed({
get: () => (person.gender ? person.gender.id : null),
set: (value) => {
person.gender = { id: value, type: "chill_main_gender" };
},
});
const civility = computed({
get: () => (person.civility ? person.civility.id : null),
set: (value) => {
person.civility = { id: value, type: "chill_main_civility" };
},
});
const birthDate = computed({
get: () => (person.birthdate ? person.birthdate.datetime.split("T")[0] : ""),
set: (value) => {
if (person.birthdate) {
person.birthdate.datetime = value + "T00:00:00+0100";
} else {
person.birthdate = { datetime: value + "T00:00:00+0100" };
}
},
});
const phonenumber = computed({
get: () => person.phonenumber,
set: (value) => {
person.phonenumber = value;
},
});
const mobilenumber = computed({
get: () => person.mobilenumber,
set: (value) => {
person.mobilenumber = value;
},
});
const email = computed({
get: () => person.email,
set: (value) => {
person.email = value;
},
});
const showAddressForm = computed({
get: () => showAddressFormValue.value,
set: (value) => {
showAddressFormValue.value = value;
},
});
const center = computed({
get: () => {
const c = config.centers.find(
(c) => person.center !== null && person.center.id === c.id,
);
return typeof c === "undefined" ? null : c;
},
set: (value) => {
person.center = { id: value.id, type: value.type };
},
});
const genderClass = computed(() => {
switch (person.gender && person.gender.id) {
case "woman":
return "fa-venus";
case "man":
return "fa-mars";
case "both":
return "fa-neuter";
case "unknown":
return "fa-genderless";
default:
return "fa-genderless";
}
});
const genderTranslation = computed(() => {
switch (person.gender && person.gender.genderTranslation) {
case "woman":
return PERSON_MESSAGES_PERSON_GENDER_WOMAN;
case "man":
return PERSON_MESSAGES_PERSON_GENDER_MAN;
case "neutral":
return PERSON_MESSAGES_PERSON_GENDER_NEUTRAL;
case "unknown":
return PERSON_MESSAGES_PERSON_GENDER_UNKNOWN;
default:
return PERSON_MESSAGES_PERSON_GENDER_UNKNOWN;
}
});
const feminized = computed(() =>
person.gender && person.gender.id === "woman" ? "e" : "",
);
const personAltNamesLabels = computed(() =>
person.altNames.map((a) => (a ? a.label : "")),
);
const queryItems = computed(() =>
props.query ? props.query.split(" ") : null,
);
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é");
}
interface Props {
id: string | number;
type?: string;
action: "show" | "edit" | "create";
query?: string;
}
function loadData() {
getPerson(props.id).then((p) => {
Object.assign(person, p);
const props = defineProps<Props>();
const person = ref<Person | null>(null);
function loadData(): void {
if (props.id === undefined || props.id === null) {
return;
}
const idNum = typeof props.id === "string" ? Number(props.id) : props.id;
if (!Number.isFinite(idNum)) {
return;
}
getPerson(idNum as number).then((p) => {
person.value = p;
});
}
function onAltNameInput(event) {
const key = event.target.id;
const label = event.target.value;
let updateAltNames = person.altNames.filter((a) => a.key !== key);
updateAltNames.push({ key: key, label: label });
person.altNames = updateAltNames;
}
function addQueryItem(field, queryItem) {
switch (field) {
case "lastName":
person.lastName = person.lastName
? (person.lastName += ` ${queryItem}`)
: queryItem;
break;
case "firstName":
person.firstName = person.firstName
? (person.firstName += ` ${queryItem}`)
: queryItem;
break;
}
}
function submitNewAddress(payload) {
person.addressId = payload.addressId;
}
onMounted(() => {
getPersonAltNames().then((altNames) => {
config.altNames = altNames;
});
getCivilities().then((civilities) => {
if ("results" in civilities) {
config.civilities = civilities.results;
}
});
getGenders().then((genders) => {
if ("results" in genders) {
config.genders = genders.results;
}
});
if (props.action !== "create") {
loadData();
} else {
getCentersForPersonCreation().then((params) => {
config.centers = params.centers.filter((c) => c.isActive);
showCenters.value = params.showCenters;
if (showCenters.value && config.centers.length === 1) {
person.center = config.centers[0];
}
});
}
});
defineExpose(genderClass, genderTranslation, feminized, birthDate);
</script>

View File

@@ -0,0 +1,707 @@
<template>
<div>
<div class="mb-3">
<div class="input-group has-validation">
<div class="form-floating">
<input
class="form-control form-control-lg"
:class="{ 'is-invalid': hasViolation('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 violationTitles('lastName')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
<div v-if="queryItems">
<ul class="list-suggest add-items inline">
<li
v-for="(qi, i) in queryItems"
:key="i"
@click="addQueryItem('lastName', qi)"
>
<span class="person-text">{{ qi }}</span>
</li>
</ul>
</div>
<div class="mb-3">
<div class="input-group has-validation">
<div class="form-floating">
<input
class="form-control form-control-lg"
:class="{ 'is-invalid': hasViolation('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 violationTitles('firstName')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
<div v-if="queryItems">
<ul class="list-suggest add-items inline">
<li
v-for="(qi, i) in queryItems"
:key="i"
@click="addQueryItem('firstName', qi)"
>
<span class="person-text">{{ qi }}</span>
</li>
</ul>
</div>
<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="mb-3"
>
<div class="input-group has-validation">
<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)"
@input="onIdentifierInput($event, worker.definition_id)"
/>
<label :for="'worker_' + worker.definition_id">{{
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>
<div class="mb-3">
<div class="input-group has-validation">
<div class="form-floating">
<select
class="form-select form-select-lg"
:class="{ 'is-invalid': hasViolation('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 violationTitles('gender')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
</div>
<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"
:class="{ 'is-invalid': hasViolation('center') }"
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 for="center">{{ trans(PERSON_MESSAGES_PERSON_CENTER_TITLE) }}</label>
</div>
<div
v-for="err in violationTitles('center')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
</div>
<div class="mb-3">
<div class="input-group has-validation">
<div class="form-floating">
<select
class="form-select form-select-lg"
:class="{ 'is-invalid': hasViolation('civility') }"
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 for="civility">{{ trans(PERSON_MESSAGES_PERSON_CIVILITY_TITLE) }}</label>
</div>
<div
v-for="err in violationTitles('civility')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
</div>
<div class="mb-3">
<div class="input-group has-validation">
<span class="input-group-text">
<i class="bi bi-cake2-fill"></i>
</span>
<div class="form-floating">
<input
class="form-control form-control-lg"
:class="{ 'is-invalid': hasViolation('birthdate') }"
name="birthdate"
type="date"
v-model="birthDate"
:placeholder="trans(BIRTHDATE)"
:aria-label="trans(BIRTHDATE)"
/>
<label for="birthdate">{{ trans(BIRTHDATE) }}</label>
</div>
<div
v-for="err in violationTitles('birthdate')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
</div>
<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"
:class="{ 'is-invalid': hasViolation('phonenumber') }"
v-model="phonenumber"
:placeholder="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)"
:aria-label="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)"
aria-describedby="phonenumber"
/>
<label for="phonenumber">{{ trans(PERSON_MESSAGES_PERSON_PHONENUMBER) }}</label>
</div>
<div
v-for="err in violationTitles('phonenumber')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
</div>
<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"
:class="{ 'is-invalid': hasViolation('mobilenumber') }"
v-model="mobilenumber"
:placeholder="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)"
:aria-label="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)"
aria-describedby="mobilenumber"
/>
<label for="mobilenumber">{{ trans(PERSON_MESSAGES_PERSON_MOBILENUMBER) }}</label>
</div>
<div
v-for="err in violationTitles('mobilenumber')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
</div>
<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"
:class="{ 'is-invalid': hasViolation('email') }"
v-model="email"
:placeholder="trans(PERSON_MESSAGES_PERSON_EMAIL)"
:aria-label="trans(PERSON_MESSAGES_PERSON_EMAIL)"
aria-describedby="email"
/>
<label for="email">{{ trans(PERSON_MESSAGES_PERSON_EMAIL) }}</label>
</div>
<div
v-for="err in violationTitles('email')"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
</div>
<div v-if="action === 'create'" class="input-group mb-3 form-check">
<input
class="form-check-input"
type="checkbox"
v-model="showAddressForm"
name="showAddressForm"
/>
<label class="form-check-label">
{{ trans(PERSON_MESSAGES_PERSON_ADDRESS_SHOW_ADDRESS_FORM) }}
</label>
</div>
<div
v-if="action === 'create' && showAddressFormValue"
class="form-floating mb-3"
>
<p>{{ trans(PERSON_MESSAGES_PERSON_ADDRESS_WARNING) }}</p>
<AddAddress
:context="addAddress.context"
:options="addAddress.options"
:addressChangedCallback="submitNewAddress"
ref="addAddress"
/>
</div>
<div class="alert alert-warning" v-if="errors.length">
<ul>
<li v-for="(e, i) in errors" :key="i">{{ e }}</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue";
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
import AddAddress from "ChillMainAssets/vuejs/Address/components/AddAddress.vue";
import {
createPerson,
getCentersForPersonCreation,
getCivilities,
getGenders,
getPerson,
getPersonAltNames,
getPersonIdentifiers,
WritePersonViolationMap,
} from "../../_api/OnTheFly";
import {
trans,
BIRTHDATE,
PERSON_EDIT_ERROR_WHILE_SAVING,
PERSON_MESSAGES_PERSON_LASTNAME,
PERSON_MESSAGES_PERSON_FIRSTNAME,
PERSON_MESSAGES_PERSON_GENDER_PLACEHOLDER,
PERSON_MESSAGES_PERSON_GENDER_TITLE,
PERSON_MESSAGES_PERSON_CENTER_PLACEHOLDER,
PERSON_MESSAGES_PERSON_CENTER_TITLE,
PERSON_MESSAGES_PERSON_CIVILITY_PLACEHOLDER,
PERSON_MESSAGES_PERSON_CIVILITY_TITLE,
PERSON_MESSAGES_PERSON_PHONENUMBER,
PERSON_MESSAGES_PERSON_MOBILENUMBER,
PERSON_MESSAGES_PERSON_EMAIL,
PERSON_MESSAGES_PERSON_ADDRESS_SHOW_ADDRESS_FORM,
PERSON_MESSAGES_PERSON_ADDRESS_WARNING,
} from "translator";
import {
Center,
Civility,
Gender,
DateTimeWrite, ValidationExceptionInterface,
} from "ChillMainAssets/types";
import {
AltName,
Person,
PersonWrite,
PersonIdentifierWorker,
type Suggestion,
type EntitiesOrMe,
} from "ChillPersonAssets/types";
import {
isValidationException,
} from "ChillMainAssets/lib/api/apiMethods";
import {useToast} from "vue-toast-notification";
import {getTimezoneOffsetString, ISOToDate} from "ChillMainAssets/chill/js/date";
interface PersonEditComponentConfig {
id?: number | null;
type?: string;
action: "edit" | "create";
query: string;
}
const props = withDefaults(defineProps<PersonEditComponentConfig>(), {
id: null,
type: "TODO",
});
const emit =
defineEmits<(e: "onPersonCreated", payload: { person: Person }) => void>();
defineExpose({ postPerson });
const toast = useToast();
const person = reactive<PersonWrite>({
type: "person",
firstName: "",
lastName: "",
altNames: [],
// address: null,
birthdate: null,
deathdate: null,
phonenumber: "",
mobilenumber: "",
email: "",
gender: null,
center: null,
civility: null,
identifiers: [],
});
const config = reactive<{
altNames: AltName[];
civilities: Civility[];
centers: Center[];
genders: Gender[];
identifiers: PersonIdentifierWorker[];
}>({
altNames: [],
civilities: [],
centers: [],
genders: [],
identifiers: [],
});
const showCenters = ref(false);
const showAddressFormValue = ref(false);
const errors = ref<string[]>([]);
const addAddress = reactive({
options: {
button: {
text: { create: "person.address.create_address" },
size: "btn-sm",
},
title: { create: "person.address.create_address" },
},
context: {
target: {},
edit: false,
addressId: null as number | null,
defaults: (window as any).addaddress,
},
});
const firstName = computed({
get: () => person.firstName,
set: (value: string) => {
person.firstName = value;
},
});
const lastName = computed({
get: () => person.lastName,
set: (value: string) => {
person.lastName = value;
},
});
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;
},
});
const civility = computed({
get: () => (person.civility ? person.civility.id : null),
set: (value: number | null) => {
person.civility =
value !== null ? { id: value, type: "chill_main_civility" } : null;
},
});
const birthDate = computed({
get: () => (person.birthdate ? person.birthdate.datetime.split("T")[0] : ""),
set: (value: string) => {
const date = ISOToDate(value);
if (null === date) {
person.birthdate = null;
return;
}
const offset = getTimezoneOffsetString(date, Intl.DateTimeFormat().resolvedOptions().timeZone);
if (person.birthdate) {
person.birthdate.datetime = value + "T00:00:00" + offset;
} else {
person.birthdate = { datetime: value + "T00:00:00" + offset };
}
}
});
const phonenumber = computed({
get: () => person.phonenumber,
set: (value: string) => {
person.phonenumber = value;
},
});
const mobilenumber = computed({
get: () => person.mobilenumber,
set: (value: string) => {
person.mobilenumber = value;
},
});
const email = computed({
get: () => person.email,
set: (value: string) => {
person.email = value;
},
});
const showAddressForm = computed({
get: () => showAddressFormValue.value,
set: (value: boolean) => {
showAddressFormValue.value = value;
},
});
const center = computed({
get: () => {
const c = config.centers.find(
(c) => person.center !== null && person.center.id === c.id,
);
return typeof c === "undefined" ? null : c;
},
set: (value: Center | null) => {
if (null !== value) {
person.center = {
id: value.id,
type: value.type,
};
} else {
person.center = null;
}
},
});
/**
* Find the query items to display for suggestion
*/
const queryItems = computed(() => {
const words: null | string[] = props.query ? props.query.split(" ") : null;
if (null === words) {
return null;
}
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()));
});
async function loadData() {
if (props.id !== undefined && props.id !== null) {
const person = await getPerson(props.id);
}
}
function onAltNameInput(event: Event, key: string): void {
const target = event.target as HTMLInputElement;
const value = target.value;
const updateAltNamesKey = person.altNames.findIndex((a) => a.key === key);
if (-1 === updateAltNamesKey) {
person.altNames.push({ key, value });
} else {
person.altNames[updateAltNamesKey].value = value;
}
}
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,
);
if (-1 === updateIdentifierKey) {
person.identifiers.push({
type: "person_identifier",
definition_id,
value: { content: value },
});
} else {
person.identifiers[updateIdentifierKey].value = { content: value };
}
}
function addQueryItem(field: "lastName" | "firstName", queryItem: string) {
switch (field) {
case "lastName":
person.lastName = person.lastName
? (person.lastName += ` ${queryItem}`)
: queryItem;
break;
case "firstName":
person.firstName = person.firstName
? (person.firstName += ` ${queryItem}`)
: queryItem;
break;
}
}
type WritePersonViolationKey = Extract<keyof WritePersonViolationMap, string>;
const violationsList = ref<ValidationExceptionInterface<WritePersonViolationMap>|null>(null);
function violationTitles<P extends WritePersonViolationKey>(property: P): string[] {
if (null === violationsList.value) {
return [];
}
return violationsList.value.violationsByNormalizedProperty(property).map((v) => v.title);
}
function violationTitlesWithParameter<
P extends WritePersonViolationKey,
Param extends Extract<keyof WritePersonViolationMap[P], string>
>(
property: P,
with_parameter: Param,
with_parameter_value: WritePersonViolationMap[P][Param],
): string[] {
if (violationsList.value === null) {
return [];
}
return violationsList.value.violationsByNormalizedPropertyAndParams(property, with_parameter, with_parameter_value)
.map((v) => v.title);
}
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 violationTitlesWithParameter(property, with_parameter, with_parameter_value).length > 0;
}
function submitNewAddress(payload: { addressId: number }) {
// person.addressId = payload.addressId;
}
async function postPerson(): Promise<void> {
try {
const createdPerson = await createPerson(person);
emit("onPersonCreated", { person: createdPerson });
} catch (e: unknown) {
if (isValidationException<WritePersonViolationMap>(e)) {
violationsList.value = e;
} else {
toast.error(trans(PERSON_EDIT_ERROR_WHILE_SAVING));
}
}
}
onMounted(() => {
getPersonAltNames().then((altNames) => {
config.altNames = altNames;
});
getCivilities().then((civilities) => {
config.civilities = civilities;
});
getGenders().then((genders) => {
config.genders = genders;
});
getPersonIdentifiers().then((identifiers) => {
config.identifiers = identifiers.filter(
(w: PersonIdentifierWorker) =>
w.presence === 'ON_CREATION' || w.presence === 'REQUIRED'
);
});
if (props.action !== "create") {
loadData();
} else {
getCentersForPersonCreation().then((params) => {
config.centers = params.centers.filter((c: Center) => c.isActive);
showCenters.value = params.showCenters;
if (showCenters.value && config.centers.length === 1) {
// if there is only one center, preselect it
person.center = {
id: config.centers[0].id,
type: config.centers[0].type ?? "center",
};
}
});
}
});
</script>
<style lang="scss" scoped>
.was-validated-force {
display: block;
}
</style>

View File

@@ -88,6 +88,15 @@
<div data-suggest-container="{{ altName.vars.full_name|e('html_attr') }}" class="col-sm-8" style="margin-left: auto;"></div>
{% endfor %}
{% endif %}
{% if form.identifiers|length > 0 %}
{% for f in form.identifiers %}
<div class="row mb-1" style="display:flex;">
{{ form_row(f) }}
</div>
{% endfor %}
{% else %}
{{ form_widget(form.identifiers) }}
{% endif %}
{{ form_row(form.gender, { 'label' : 'Gender'|trans }) }}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Serializer\Normalizer;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Civility;
use Chill\MainBundle\Entity\Gender;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonAltName;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use libphonenumber\PhoneNumber;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait;
/**
* Denormalize a Person entity from a JSON-like array structure, creating or updating an existing instance.
*
* To find an existing instance by his id, see the @see{PersonJsonReadDenormalizer}.
*/
final class PersonJsonDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
{
use DenormalizerAwareTrait;
use ObjectToPopulateTrait;
public function __construct(private readonly PersonIdentifierManagerInterface $personIdentifierManager) {}
public function denormalize($data, string $type, ?string $format = null, array $context = []): Person
{
$person = $this->extractObjectToPopulate($type, $context);
if (null === $person) {
$person = new Person();
}
// Setters applied directly per known field for readability
if (\array_key_exists('firstName', $data)) {
$person->setFirstName($data['firstName']);
}
if (\array_key_exists('lastName', $data)) {
$person->setLastName($data['lastName']);
}
if (\array_key_exists('phonenumber', $data)) {
$person->setPhonenumber($this->denormalizer->denormalize($data['phonenumber'], PhoneNumber::class, $format, $context));
}
if (\array_key_exists('mobilenumber', $data)) {
$person->setMobilenumber($this->denormalizer->denormalize($data['mobilenumber'], PhoneNumber::class, $format, $context));
}
if (\array_key_exists('gender', $data) && null !== $data['gender']) {
$gender = $this->denormalizer->denormalize($data['gender'], Gender::class, $format, []);
$person->setGender($gender);
}
if (\array_key_exists('birthdate', $data)) {
$object = $this->denormalizer->denormalize($data['birthdate'], \DateTime::class, $format, $context);
$person->setBirthdate($object);
}
if (\array_key_exists('deathdate', $data)) {
$object = $this->denormalizer->denormalize($data['deathdate'], \DateTimeImmutable::class, $format, $context);
$person->setDeathdate($object);
}
if (\array_key_exists('center', $data)) {
$object = $this->denormalizer->denormalize($data['center'], Center::class, $format, $context);
$person->setCenter($object);
}
if (\array_key_exists('altNames', $data)) {
foreach ($data['altNames'] as $altNameData) {
if (!array_key_exists('key', $altNameData)
|| !array_key_exists('value', $altNameData)
|| '' === trim($altNameData['key'])
) {
throw new UnexpectedValueException('format for alt name is not correct');
}
$altNameKey = $altNameData['key'];
$altNameValue = $altNameData['value'];
$altName = $person->getAltNames()->findFirst(fn (int $key, PersonAltName $personAltName) => $personAltName->getKey() === $altNameKey);
if (null === $altName) {
$altName = new PersonAltName();
$person->addAltName($altName);
}
$altName->setKey($altNameKey)->setLabel($altNameValue);
}
}
if (\array_key_exists('identifiers', $data)) {
foreach ($data['identifiers'] as $identifierData) {
if (!array_key_exists('definition_id', $identifierData)
|| !array_key_exists('value', $identifierData)
|| !is_int($identifierData['definition_id'])
|| !is_array($identifierData['value'])
) {
throw new UnexpectedValueException('format for identifiers is not correct');
}
$definitionId = $identifierData['definition_id'];
$value = $identifierData['value'];
$worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($definitionId);
if (!$worker->getDefinition()->isEditableByUsers()) {
continue;
}
$personIdentifier = $person->getIdentifiers()->findFirst(fn (int $key, PersonIdentifier $personIdentifier) => $personIdentifier->getId() === $definitionId);
if (null === $personIdentifier) {
$personIdentifier = new PersonIdentifier($worker->getDefinition());
$person->addIdentifier($personIdentifier);
}
$personIdentifier->setValue($value);
$personIdentifier->setCanonical($worker->canonicalizeValue($value));
if ($worker->isEmpty($personIdentifier)) {
$person->removeIdentifier($personIdentifier);
}
}
}
if (\array_key_exists('email', $data)) {
$person->setEmail($data['email']);
}
if (\array_key_exists('civility', $data) && null !== $data['civility']) {
$civility = $this->denormalizer->denormalize($data['civility'], Civility::class, $format, []);
$person->setCivility($civility);
}
return $person;
}
public function supportsDenormalization($data, $type, $format = null): bool
{
return Person::class === $type && 'person' === ($data['type'] ?? null) && !isset($data['id']);
}
}

View File

@@ -11,169 +11,32 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Serializer\Normalizer;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Civility;
use Chill\MainBundle\Entity\Gender;
use Chill\MainBundle\Phonenumber\PhoneNumberHelperInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderExtension;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonAltName;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Repository\ResidentialAddressRepository;
use Doctrine\Common\Collections\Collection;
use libphonenumber\PhoneNumber;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* Serialize a Person entity.
*/
class PersonJsonNormalizer implements DenormalizerAwareInterface, NormalizerAwareInterface, PersonJsonNormalizerInterface
class PersonJsonNormalizer implements NormalizerAwareInterface, NormalizerInterface
{
use DenormalizerAwareTrait;
use NormalizerAwareTrait;
use ObjectToPopulateTrait;
public function __construct(
private readonly ChillEntityRenderExtension $render,
/* TODO: replace by PersonRenderInterface, as sthis is the only one required */
private readonly PersonRepository $repository,
private readonly CenterResolverManagerInterface $centerResolverManager,
private readonly ResidentialAddressRepository $residentialAddressRepository,
private readonly PhoneNumberHelperInterface $phoneNumberHelper,
) {}
public function denormalize($data, $type, $format = null, array $context = [])
{
$person = $this->extractObjectToPopulate($type, $context);
if (\array_key_exists('id', $data) && null === $person) {
$person = $this->repository->find($data['id']);
if (null === $person) {
throw new UnexpectedValueException("The person with id \"{$data['id']}\" does ".'not exists');
}
// currently, not allowed to update a person through api
// if instantiated with id
return $person;
}
if (null === $person) {
$person = new Person();
}
$fields = [
'firstName',
'lastName',
'phonenumber',
'mobilenumber',
'gender',
'birthdate',
'deathdate',
'center',
'altNames',
'email',
'civility',
];
$fields = array_filter(
$fields,
static fn (string $field): bool => \array_key_exists($field, $data)
);
foreach ($fields as $item) {
switch ($item) {
case 'firstName':
$person->setFirstName($data[$item]);
break;
case 'lastName':
$person->setLastName($data[$item]);
break;
case 'phonenumber':
$person->setPhonenumber($this->denormalizer->denormalize($data[$item], PhoneNumber::class, $format, $context));
break;
case 'mobilenumber':
$person->setMobilenumber($this->denormalizer->denormalize($data[$item], PhoneNumber::class, $format, $context));
break;
case 'gender':
$gender = $this->denormalizer->denormalize($data[$item], Gender::class, $format, []);
$person->setGender($gender);
break;
case 'birthdate':
$object = $this->denormalizer->denormalize($data[$item], \DateTime::class, $format, $context);
$person->setBirthdate($object);
break;
case 'deathdate':
$object = $this->denormalizer->denormalize($data[$item], \DateTimeImmutable::class, $format, $context);
$person->setDeathdate($object);
break;
case 'center':
$object = $this->denormalizer->denormalize($data[$item], Center::class, $format, $context);
$person->setCenter($object);
break;
case 'altNames':
foreach ($data[$item] as $altName) {
$oldAltName = $person
->getAltNames()
->filter(static fn (PersonAltName $n): bool => $n->getKey() === $altName['key'])->first();
if (false === $oldAltName) {
$newAltName = new PersonAltName();
$newAltName->setKey($altName['key']);
$newAltName->setLabel($altName['label']);
$person->addAltName($newAltName);
} else {
$oldAltName->setLabel($altName['label']);
}
}
break;
case 'email':
$person->setEmail($data[$item]);
break;
case 'civility':
$civility = $this->denormalizer->denormalize($data[$item], Civility::class, $format, []);
$person->setCivility($civility);
break;
}
}
return $person;
}
/**
* @param Person $person
* @param string|null $format

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Serializer\Normalizer;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\PersonRepository;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\LogicException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
/**
* Find a Person entity by his id during the denormalization process.
*/
readonly class PersonJsonReadDenormalizer implements DenormalizerInterface
{
public function __construct(private PersonRepository $repository) {}
public function denormalize($data, string $type, ?string $format = null, array $context = []): Person
{
if (!is_array($data)) {
throw new InvalidArgumentException();
}
if (\array_key_exists('id', $data)) {
$person = $this->repository->find($data['id']);
if (null === $person) {
throw new UnexpectedValueException("The person with id \"{$data['id']}\" does ".'not exists');
}
return $person;
}
throw new LogicException();
}
public function supportsDenormalization($data, string $type, ?string $format = null)
{
return is_array($data) && Person::class === $type && 'person' === ($data['type'] ?? null) && isset($data['id']);
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Tests\Controller;
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
use Chill\MainBundle\Serializer\Normalizer\CollectionNormalizer;
use Chill\PersonBundle\Controller\PersonIdentifierListApiController;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Chill\PersonBundle\PersonIdentifier\Normalizer\PersonIdentifierWorkerNormalizer;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Serializer;
/**
* @internal
*
* @coversNothing
*/
class PersonIdentifierListApiControllerTest extends TestCase
{
use ProphecyTrait;
public function testListAccessDenied(): void
{
$security = $this->prophesize(Security::class);
$security->isGranted('ROLE_USER')->willReturn(false)->shouldBeCalledOnce();
$serializer = new Serializer([new PersonIdentifierWorkerNormalizer(), new CollectionNormalizer()], [new JsonEncoder()]);
$personIdentifierManager = $this->prophesize(PersonIdentifierManagerInterface::class);
$paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class);
$controller = new PersonIdentifierListApiController(
$security->reveal(),
$serializer,
$personIdentifierManager->reveal(),
$paginatorFactory->reveal(),
);
$this->expectException(AccessDeniedHttpException::class);
$controller->list();
}
public function testListSuccess(): void
{
// Build 3 workers
$engine = new class () implements PersonIdentifierEngineInterface {
public static function getName(): string
{
return 'dummy';
}
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
{
return null;
}
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {}
public function renderAsString(?\Chill\PersonBundle\Entity\Identifier\PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
{
return '';
}
};
$definition1 = new PersonIdentifierDefinition(['en' => 'Label 1'], 'dummy');
$definition2 = new PersonIdentifierDefinition(['en' => 'Label 2'], 'dummy');
$definition3 = new PersonIdentifierDefinition(['en' => 'Label 3'], 'dummy');
// simulate persisted ids
$r = new \ReflectionProperty(PersonIdentifierDefinition::class, 'id');
$r->setAccessible(true);
$r->setValue($definition1, 1);
$r->setValue($definition2, 2);
$r->setValue($definition3, 3);
$workers = [
new PersonIdentifierWorker($engine, $definition1),
new PersonIdentifierWorker($engine, $definition2),
new PersonIdentifierWorker($engine, $definition3),
];
$security = $this->prophesize(Security::class);
$security->isGranted('ROLE_USER')->willReturn(true)->shouldBeCalledOnce();
$personIdentifierManager = $this->prophesize(PersonIdentifierManagerInterface::class);
$personIdentifierManager->getWorkers()->willReturn($workers)->shouldBeCalledOnce();
$paginator = $this->prophesize(\Chill\MainBundle\Pagination\PaginatorInterface::class);
$paginator->setItemsPerPage(3)->shouldBeCalledOnce();
$paginator->getCurrentPageFirstItemNumber()->willReturn(0);
$paginator->getItemsPerPage()->willReturn(count($workers));
$paginator->getTotalItems()->willReturn(count($workers));
$paginator->hasNextPage()->willReturn(false);
$paginator->hasPreviousPage()->willReturn(false);
$paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class);
$paginatorFactory->create(3)->willReturn($paginator->reveal())->shouldBeCalledOnce();
$serializer = new Serializer([
new PersonIdentifierWorkerNormalizer(),
new CollectionNormalizer(),
], [new JsonEncoder()]);
$controller = new PersonIdentifierListApiController(
$security->reveal(),
$serializer,
$personIdentifierManager->reveal(),
$paginatorFactory->reveal(),
);
$response = $controller->list();
self::assertSame(200, $response->getStatusCode());
$body = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertIsArray($body);
self::assertArrayHasKey('count', $body);
self::assertArrayHasKey('pagination', $body);
self::assertArrayHasKey('results', $body);
self::assertSame(3, $body['count']);
self::assertCount(3, $body['results']);
// spot check one item
self::assertSame('person_identifier_worker', $body['results'][0]['type']);
self::assertSame(1, $body['results'][0]['id']);
self::assertSame('dummy', $body['results'][0]['engine']);
self::assertSame(['en' => 'Label 1'], $body['results'][0]['label']);
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Tests\PersonIdentifier\Normalizer;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Chill\PersonBundle\PersonIdentifier\Normalizer\PersonIdentifierWorkerNormalizer;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
/**
* @internal
*
* @coversNothing
*/
class PersonIdentifierWorkerNormalizerTest extends TestCase
{
public function testSupportsNormalization(): void
{
$engine = new class () implements PersonIdentifierEngineInterface {
public static function getName(): string
{
return 'dummy';
}
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
{
return null;
}
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {}
public function renderAsString(?\Chill\PersonBundle\Entity\Identifier\PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
{
return '';
}
};
$definition = new PersonIdentifierDefinition(label: ['en' => 'SSN'], engine: 'string');
$worker = new PersonIdentifierWorker($engine, $definition);
$normalizer = new PersonIdentifierWorkerNormalizer();
self::assertTrue($normalizer->supportsNormalization($worker));
self::assertFalse($normalizer->supportsNormalization(new \stdClass()));
}
public function testNormalizeReturnsExpectedArray(): void
{
$engine = new class () implements PersonIdentifierEngineInterface {
public static function getName(): string
{
return 'dummy';
}
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
{
return null;
}
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {}
public function renderAsString(?\Chill\PersonBundle\Entity\Identifier\PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
{
return '';
}
};
$definition = new PersonIdentifierDefinition(label: ['en' => 'SSN'], engine: 'string');
$definition->setActive(false);
$worker = new PersonIdentifierWorker($engine, $definition);
$normalizer = new PersonIdentifierWorkerNormalizer();
$normalized = $normalizer->normalize($worker);
self::assertSame([
'type' => 'person_identifier_worker',
'id' => null,
'engine' => 'string',
'label' => ['en' => 'SSN'],
'isActive' => false,
], $normalized);
}
public function testNormalizeThrowsOnInvalidObject(): void
{
$normalizer = new PersonIdentifierWorkerNormalizer();
$this->expectException(UnexpectedValueException::class);
$normalizer->normalize(new \stdClass());
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Tests\PersonIdentifier\Validator;
use Chill\PersonBundle\Entity\Identifier\IdentifierPresenceEnum;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
use Chill\PersonBundle\PersonIdentifier\Validator\RequiredIdentifierConstraint;
use Chill\PersonBundle\PersonIdentifier\Validator\RequiredIdentifierConstraintValidator;
use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Argument;
/**
* @internal
*/
#[CoversClass(RequiredIdentifierConstraintValidator::class)]
final class RequiredIdentifierConstraintValidatorTest extends ConstraintValidatorTestCase
{
use ProphecyTrait;
private PersonIdentifierDefinition $requiredDefinition;
protected function createValidator(): RequiredIdentifierConstraintValidator
{
$this->requiredDefinition = new PersonIdentifierDefinition(
label: ['fr' => 'Identifiant requis'],
engine: 'test.engine',
);
$this->requiredDefinition->setPresence(IdentifierPresenceEnum::REQUIRED);
$reflection = new \ReflectionClass($this->requiredDefinition);
$id = $reflection->getProperty('id');
$id->setValue($this->requiredDefinition, 1);
// Mock only the required methods of the engine used by the validator through the worker
$engineProphecy = $this->prophesize(PersonIdentifierEngineInterface::class);
$engineProphecy->isEmpty(Argument::type(PersonIdentifier::class))
->will(function (array $args): bool {
/** @var PersonIdentifier $identifier */
$identifier = $args[0];
return '' === trim($identifier->getValue()['content'] ?? '');
});
$engineProphecy->renderAsString(Argument::any(), Argument::any())
->will(function (array $args): string {
/** @var PersonIdentifier|null $identifier */
$identifier = $args[0] ?? null;
return $identifier?->getValue()['content'] ?? '';
});
$worker = new PersonIdentifierWorker($engineProphecy->reveal(), $this->requiredDefinition);
// Mock only the required method used by the validator
$managerProphecy = $this->prophesize(PersonIdentifierManagerInterface::class);
$managerProphecy->getWorkers()->willReturn([$worker]);
return new RequiredIdentifierConstraintValidator($managerProphecy->reveal());
}
public function testThrowsOnNonCollectionValue(): void
{
$this->expectException(UnexpectedValueException::class);
$this->validator->validate(new \stdClass(), new RequiredIdentifierConstraint());
}
public function testThrowsOnInvalidConstraintType(): void
{
$this->expectException(UnexpectedTypeException::class);
// Provide a valid Collection value so the type check reaches the constraint check
$this->validator->validate(new ArrayCollection(), new NotBlank());
}
public function testNoViolationWhenRequiredIdentifierPresentAndNotEmpty(): void
{
$identifier = new PersonIdentifier($this->requiredDefinition);
$identifier->setValue(['content' => 'ABC']);
$collection = new ArrayCollection([$identifier]);
$this->validator->validate($collection, new RequiredIdentifierConstraint());
$this->assertNoViolation();
}
public function testViolationWhenRequiredIdentifierMissing(): void
{
$collection = new ArrayCollection();
$this->validator->validate($collection, new RequiredIdentifierConstraint());
$this->buildViolation('person_identifier.This identifier must be set')
->setParameter('{{ value }}', '')
->setParameter('definition_id', '1')
->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05')
->assertRaised();
}
public function testViolationWhenRequiredIdentifierIsEmpty(): void
{
$identifier = new PersonIdentifier($this->requiredDefinition);
$identifier->setValue(['content' => ' ']);
$collection = new ArrayCollection([$identifier]);
$this->validator->validate($collection, new RequiredIdentifierConstraint());
$this->buildViolation('person_identifier.This identifier must be set')
->setParameter('{{ value }}', ' ')
->setParameter('definition_id', '1')
->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05')
->assertRaised();
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Tests\PersonIdentifier\Validator;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\PersonIdentifier\Validator\UniqueIdentifierConstraint;
use Chill\PersonBundle\PersonIdentifier\Validator\UniqueIdentifierConstraintValidator;
use Chill\PersonBundle\Repository\Identifier\PersonIdentifierRepository;
use Chill\PersonBundle\Templating\Entity\PersonRenderInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @internal
*/
#[CoversClass(UniqueIdentifierConstraintValidator::class)]
final class UniqueIdentifierConstraintValidatorTest extends ConstraintValidatorTestCase
{
use ProphecyTrait;
/**
* @var ObjectProphecy|PersonIdentifierRepository
*/
private ObjectProphecy $repository;
/**
* @var ObjectProphecy|PersonRenderInterface
*/
private ObjectProphecy $personRender;
protected function setUp(): void
{
$this->repository = $this->prophesize(PersonIdentifierRepository::class);
$this->personRender = $this->prophesize(PersonRenderInterface::class);
parent::setUp();
}
protected function createValidator(): UniqueIdentifierConstraintValidator
{
return new UniqueIdentifierConstraintValidator($this->repository->reveal(), $this->personRender->reveal());
}
public function testThrowsOnInvalidConstraintType(): void
{
$this->expectException(UnexpectedTypeException::class);
// Provide a valid value so execution reaches the constraint type check
$definition = new PersonIdentifierDefinition(['en' => 'Test'], 'string');
$identifier = new PersonIdentifier($definition);
$identifier->setValue(['value' => 'ABC']);
$this->validator->validate($identifier, new NotBlank());
}
public function testThrowsOnInvalidValueType(): void
{
$this->expectException(UnexpectedValueException::class);
$this->validator->validate(new \stdClass(), new UniqueIdentifierConstraint());
}
public function testNoViolationWhenNoDuplicate(): void
{
$definition = new PersonIdentifierDefinition(['en' => 'Test'], 'string');
$identifier = new PersonIdentifier($definition);
$identifier->setValue(['value' => 'UNIQ']);
// Configure repository mock to return empty array
$this->repository->findByDefinitionAndValue($definition, ['value' => 'UNIQ'])->willReturn([]);
$this->validator->validate($identifier, new UniqueIdentifierConstraint());
$this->assertNoViolation();
}
public function testViolationWhenDuplicateFound(): void
{
$definition = new PersonIdentifierDefinition(['en' => 'SSN'], 'string');
$reflectionClass = new \ReflectionClass($definition);
$reflectionId = $reflectionClass->getProperty('id');
$reflectionId->setValue($definition, 1);
$personA = new Person();
$personA->setFirstName('Alice')->setLastName('Anderson');
$personB = new Person();
$personB->setFirstName('Bob')->setLastName('Brown');
$dup1 = new PersonIdentifier($definition);
$dup1->setPerson($personA);
$dup1->setValue(['value' => '123']);
$dup2 = new PersonIdentifier($definition);
$dup2->setPerson($personB);
$dup2->setValue(['value' => '123']);
// Repository returns duplicates
$this->repository->findByDefinitionAndValue($definition, ['value' => '123'])->willReturn([$dup1, $dup2]);
// Person renderer returns names
$this->personRender->renderString($personA, Argument::type('array'))->willReturn('Alice Anderson');
$this->personRender->renderString($personB, Argument::type('array'))->willReturn('Bob Brown');
$identifier = new PersonIdentifier($definition);
$identifier->setPerson(new Person());
$identifier->setValue(['value' => '123']);
$constraint = new UniqueIdentifierConstraint();
$this->validator->validate($identifier, $constraint);
$this->buildViolation($constraint->message)
->setParameter('{{ persons }}', 'Alice Anderson, Bob Brown')
->setParameter('definition_id', '1')
->assertRaised();
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Tests\Repository\Identifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\Identifier\PersonIdentifierRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class PersonIdentifierRepositoryTest extends KernelTestCase
{
public function testFindByDefinitionAndValue(): void
{
self::bootKernel();
$container = self::getContainer();
/** @var EntityManagerInterface $em */
$em = $container->get(EntityManagerInterface::class);
// Get a random existing person from fixtures
/** @var Person|null $person */
$person = $em->getRepository(Person::class)->findOneBy([]);
self::assertNotNull($person, 'An existing Person is required for this integration test.');
// Create a definition
$definition = new PersonIdentifierDefinition(['en' => 'Test Identifier'], 'string');
$em->persist($definition);
$em->flush();
// Create an identifier attached to the person
$value = ['value' => 'ABC-'.bin2hex(random_bytes(4))];
$identifier = new PersonIdentifier($definition);
$identifier->setPerson($person);
$identifier->setValue($value);
$identifier->setCanonical('canonical-'.$value['value']);
$em->persist($identifier);
$em->flush();
// Use the repository to find by definition and value
/** @var PersonIdentifierRepository $repo */
$repo = $container->get(PersonIdentifierRepository::class);
$results = $repo->findByDefinitionAndValue($definition, $value);
self::assertNotEmpty($results, 'Repository should return at least one result.');
self::assertContainsOnlyInstancesOf(PersonIdentifier::class, $results);
self::assertTrue(in_array($identifier->getId(), array_map(static fn (PersonIdentifier $pi) => $pi->getId(), $results), true));
// Cleanup
foreach ($results as $res) {
$em->remove($res);
}
$em->flush();
$em->remove($definition);
$em->flush();
}
}

View File

@@ -18,6 +18,7 @@ use Chill\MainBundle\Repository\CountryRepository;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonPhone;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Chill\PersonBundle\Repository\PersonACLAwareRepository;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\ORM\EntityManagerInterface;
@@ -42,6 +43,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase
private EntityManagerInterface $entityManager;
private PersonIdentifierManagerInterface $personIdentifierManager;
protected function setUp(): void
{
self::bootKernel();
@@ -49,6 +52,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->countryRepository = self::getContainer()->get(CountryRepository::class);
$this->centerRepository = self::getContainer()->get(CenterRepositoryInterface::class);
$this->personIdentifierManager = self::getContainer()->get(PersonIdentifierManagerInterface::class);
}
public function testCountByCriteria()
@@ -66,7 +71,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase
$security->reveal(),
$this->entityManager,
$this->countryRepository,
$authorizationHelper->reveal()
$authorizationHelper->reveal(),
$this->personIdentifierManager,
);
$number = $repository->countBySearchCriteria('diallo');
@@ -89,7 +95,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase
$security->reveal(),
$this->entityManager,
$this->countryRepository,
$authorizationHelper->reveal()
$authorizationHelper->reveal(),
$this->personIdentifierManager,
);
$results = $repository->findBySearchCriteria(0, 5, false, 'diallo');
@@ -120,7 +127,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase
$security->reveal(),
$this->entityManager,
$this->countryRepository,
$authorizationHelper->reveal()
$authorizationHelper->reveal(),
$this->personIdentifierManager,
);
$actual = $repository->findByPhone($phoneNumber, 0, 10);

View File

@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Tests\Serializer\Normalizer;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Civility;
use Chill\MainBundle\Entity\Gender;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
use Chill\PersonBundle\Serializer\Normalizer\PersonJsonDenormalizer;
use libphonenumber\PhoneNumber;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
/**
* @internal
*
* @covers \Chill\PersonBundle\Serializer\Normalizer\PersonJsonDenormalizer
*/
final class PersonJsonDenormalizerTest extends TestCase
{
private function createIdentifierManager(): PersonIdentifierManagerInterface
{
return new class () implements PersonIdentifierManagerInterface {
public function getWorkers(): array
{
return [];
}
public function buildWorkerByPersonIdentifierDefinition(int|PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker
{
if (is_int($personIdentifierDefinition)) {
$definition = new PersonIdentifierDefinition(['en' => 'Test'], 'dummy');
// Force the id for testing purposes
$r = new \ReflectionProperty(PersonIdentifierDefinition::class, 'id');
$r->setAccessible(true);
$r->setValue($definition, $personIdentifierDefinition);
} else {
$definition = $personIdentifierDefinition;
}
$engine = new class () implements PersonIdentifierEngineInterface {
public static function getName(): string
{
return 'dummy';
}
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
{
// trivial canonicalization for tests
return isset($value['content']) ? (string) $value['content'] : null;
}
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {}
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
{
return '';
}
public function isEmpty(PersonIdentifier $identifier): bool
{
$value = $identifier->getValue();
$content = isset($value['content']) ? trim((string) $value['content']) : '';
return '' === $content;
}
};
return new PersonIdentifierWorker($engine, $definition);
}
};
}
public function testSupportsDenormalizationReturnsTrueForValidData(): void
{
$denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager());
$data = [
'type' => 'person',
// important: new Person (creation) must not contain an id
];
self::assertTrue($denormalizer->supportsDenormalization($data, Person::class));
}
public function testSupportsDenormalizationReturnsFalseForInvalidData(): void
{
$denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager());
// not an array
self::assertFalse($denormalizer->supportsDenormalization('not-an-array', Person::class));
// missing type
self::assertFalse($denormalizer->supportsDenormalization([], Person::class));
// wrong type value
self::assertFalse($denormalizer->supportsDenormalization(['type' => 'not-person'], Person::class));
// id present means it's not a create payload for this denormalizer
self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person', 'id' => 123], Person::class));
// wrong target class
self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person'], \stdClass::class));
}
public function testDenormalizeMapsPayloadToPersonProperties(): void
{
$json = <<<'JSON'
{
"type": "person",
"firstName": "Jérome",
"lastName": "diallo",
"altNames": [
{
"key": "jeune_fille",
"value": "FJ"
}
],
"birthdate": null,
"deathdate": null,
"phonenumber": "",
"mobilenumber": "",
"email": "",
"gender": {
"id": 5,
"type": "chill_main_gender"
},
"center": {
"id": 1,
"type": "center"
},
"civility": null,
"identifiers": [
{
"type": "person_identifier",
"value": {
"content": "789456"
},
"definition_id": 5
}
]
}
JSON;
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
$inner = new class () implements DenormalizerInterface {
public ?Gender $gender = null;
public ?Center $center = null;
public function denormalize($data, $type, $format = null, array $context = [])
{
if (PhoneNumber::class === $type) {
return '' === $data ? null : new PhoneNumber();
}
if (\DateTime::class === $type || \DateTimeImmutable::class === $type) {
return null === $data ? null : new \DateTimeImmutable((string) $data);
}
if (Gender::class === $type) {
return $this->gender ??= new Gender();
}
if (Center::class === $type) {
return $this->center ??= new Center();
}
if (Civility::class === $type) {
return null; // input is null in our payload
}
return null;
}
public function supportsDenormalization($data, $type, $format = null)
{
return true;
}
};
$denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager());
$denormalizer->setDenormalizer($inner);
$person = $denormalizer->denormalize($data, Person::class);
self::assertInstanceOf(Person::class, $person);
self::assertSame('Jérome', $person->getFirstName());
self::assertSame('diallo', $person->getLastName());
// phone numbers: empty strings map to null via the inner denormalizer stub
self::assertNull($person->getPhonenumber());
self::assertNull($person->getMobilenumber());
// email passes through as is
self::assertSame('', $person->getEmail());
// nested objects are provided by our inner denormalizer and must be set back on the Person
self::assertSame($inner->gender, $person->getGender());
self::assertSame($inner->center, $person->getCenter());
// dates are null in the provided payload
self::assertNull($person->getBirthdate());
self::assertNull($person->getDeathdate());
// civility is null as provided
self::assertNull($person->getCivility());
// altNames: make sure the alt name with key jeune_fille has label FJ
$found = false;
foreach ($person->getAltNames() as $altName) {
if ('jeune_fille' === $altName->getKey()) {
$found = true;
self::assertSame('FJ', $altName->getLabel());
}
}
self::assertTrue($found, 'Expected altNames to contain key "jeune_fille"');
$found = false;
foreach ($person->getIdentifiers() as $identifier) {
if (5 === $identifier->getDefinition()->getId()) {
$found = true;
self::assertSame(['content' => '789456'], $identifier->getValue());
}
}
self::assertTrue($found, 'Expected identifiers with definition id 5');
}
public function testDenormalizeRemovesEmptyIdentifier(): void
{
$data = [
'type' => 'person',
'firstName' => 'Alice',
'lastName' => 'Smith',
'identifiers' => [
[
'type' => 'person_identifier',
'value' => ['content' => ''],
'definition_id' => 7,
],
],
];
$denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager());
$person = $denormalizer->denormalize($data, Person::class);
// The identifier with empty content must be considered empty and removed
self::assertSame(0, $person->getIdentifiers()->count(), 'Expected no identifiers to remain on the person');
}
public function testDenormalizeRemovesPreviouslyExistingIdentifierWhenIncomingValueIsEmpty(): void
{
// Prepare an existing Person with a pre-existing identifier (definition id = 9)
$definition = new PersonIdentifierDefinition(['en' => 'Test'], 'dummy');
$ref = new \ReflectionProperty(PersonIdentifierDefinition::class, 'id');
$ref->setValue($definition, 9);
$existingIdentifier = new PersonIdentifier($definition);
$existingIdentifier->setValue(['content' => 'ABC']);
$person = new Person();
$person->addIdentifier($existingIdentifier);
// Also set the identifier's own id = 9 so that the denormalizer logic matches it
// (the current denormalizer matches by PersonIdentifier->getId() === definition_id)
$refId = new \ReflectionProperty(PersonIdentifier::class, 'id');
$refId->setValue($existingIdentifier, 9);
// Incoming payload sets the same definition id with an empty value
$data = [
'type' => 'person',
'identifiers' => [
[
'type' => 'person_identifier',
'value' => ['content' => ''],
'definition_id' => 9,
],
],
];
$denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager());
// Use AbstractNormalizer::OBJECT_TO_POPULATE to update the existing person
$result = $denormalizer->denormalize($data, Person::class, null, [
AbstractNormalizer::OBJECT_TO_POPULATE => $person,
]);
self::assertSame($person, $result, 'Denormalizer should update and return the provided Person instance');
self::assertSame(0, $person->getIdentifiers()->count(), 'The previously existing identifier should be removed when incoming value is empty');
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Tests\Serializer\Normalizer;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Serializer\Normalizer\PersonJsonReadDenormalizer;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @covers \Chill\PersonBundle\Serializer\Normalizer\PersonJsonReadDenormalizer
*/
final class PersonJsonReadDenormalizerTest extends TestCase
{
public function testSupportsDenormalizationReturnsTrueForValidData(): void
{
$repository = $this->getMockBuilder(PersonRepository::class)
->disableOriginalConstructor()
->getMock();
$denormalizer = new PersonJsonReadDenormalizer($repository);
$data = [
'type' => 'person',
'id' => 123,
];
self::assertTrue($denormalizer->supportsDenormalization($data, Person::class));
}
public function testSupportsDenormalizationReturnsFalseForInvalidData(): void
{
$repository = $this->getMockBuilder(PersonRepository::class)
->disableOriginalConstructor()
->getMock();
$denormalizer = new PersonJsonReadDenormalizer($repository);
// not an array
self::assertFalse($denormalizer->supportsDenormalization('not-an-array', Person::class));
// missing type
self::assertFalse($denormalizer->supportsDenormalization(['id' => 1], Person::class));
// wrong type value
self::assertFalse($denormalizer->supportsDenormalization(['type' => 'not-person', 'id' => 1], Person::class));
// missing id
self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person'], Person::class));
// wrong target class
self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person', 'id' => 1], \stdClass::class));
}
public function testDenormalizeReturnsPersonFromRepository(): void
{
$person = new Person();
$repository = $this->getMockBuilder(PersonRepository::class)
->disableOriginalConstructor()
->onlyMethods(['find'])
->getMock();
$repository->expects(self::once())
->method('find')
->with(123)
->willReturn($person);
$denormalizer = new PersonJsonReadDenormalizer($repository);
$result = $denormalizer->denormalize(['id' => 123], Person::class);
self::assertSame($person, $result);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -108,3 +108,9 @@ services:
Chill\PersonBundle\PersonIdentifier\Rendering\:
resource: '../PersonIdentifier/Rendering'
Chill\PersonBundle\PersonIdentifier\Normalizer\:
resource: '../PersonIdentifier/Normalizer'
Chill\PersonBundle\PersonIdentifier\Validator\:
resource: '../PersonIdentifier/Validator'

View File

@@ -1,11 +0,0 @@
---
services:
# note: normalizers are loaded from ../services.yaml
Chill\PersonBundle\Serializer\Normalizer\:
autowire: true
autoconfigure: true
resource: '../../Serializer/Normalizer'
tags:
- { name: 'serializer.normalizer', priority: 64 }

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Person;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250918095044 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add more details about presence in PersonIdentifier';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_person_identifier_definition ADD presence VARCHAR(255) DEFAULT \'ON_EDIT\' NOT NULL');
$this->addSql('UPDATE chill_person_identifier_definition SET presence = \'NOT_EDITABLE\' WHERE is_editable_by_users IS FALSE');
$this->addSql('ALTER TABLE chill_person_identifier_definition DROP is_editable_by_users');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_person_identifier_definition ADD is_editable_by_users BOOLEAN DEFAULT false NOT NULL');
$this->addSql('UPDATE chill_person_identifier_definition SET is_editable_by_users = true WHERE presence <> \'NOT_EDITABLE\' ');
$this->addSql('ALTER TABLE chill_person_identifier_definition DROP presence');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\Person;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20250922151020 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add unique constraint for person identifiers';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE UNIQUE INDEX chill_person_identifier_unique ON chill_person_identifier (definition_id, value)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX chill_person_identifier_unique');
}
}

View File

@@ -105,6 +105,8 @@ Administrative status: Situation administrative
person:
Identifiers: Identifiants
person_edit:
Error while saving: Erreur lors de l'enregistrement
# dédoublonnage
Old person: Doublon
@@ -1547,7 +1549,7 @@ person_messages:
center_id: "Identifiant du centre"
center_type: "Type de centre"
center_name: "Territoire"
phonenumber: "Téléphone"
phonenumber: "Téléphone fixe"
mobilenumber: "Mobile"
altnames: "Autres noms"
email: "Courriel"

View File

@@ -73,5 +73,9 @@ 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.
Identifier must be unique. The same identifier already exists for {{ persons }}: Identifiant déjà utilisé pour {{ persons }}
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

View File

@@ -17,7 +17,6 @@ use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Civility;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ReadableCollection;
@@ -206,12 +205,12 @@ class ThirdParty implements TrackCreationInterface, TrackUpdateInterface, \Strin
#[Groups(['read', 'write', 'docgen:read', 'docgen:read:3party:parent'])]
#[ORM\Column(name: 'telephone', type: 'phone_number', nullable: true)]
#[PhonenumberConstraint(type: 'any')]
#[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber]
private ?PhoneNumber $telephone = null;
#[Groups(['read', 'write', 'docgen:read', 'docgen:read:3party:parent'])]
#[ORM\Column(name: 'telephone2', type: 'phone_number', nullable: true)]
#[PhonenumberConstraint(type: 'any')]
#[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber]
private ?PhoneNumber $telephone2 = null;
#[ORM\Column(name: 'types', type: \Doctrine\DBAL\Types\Types::JSON, nullable: true)]