Compare commits

..

3 Commits

106 changed files with 4153 additions and 6727 deletions

View File

@@ -0,0 +1,6 @@
kind: Feature
body: Admin interface for Motive entity
time: 2025-10-07T15:59:45.597029709+02:00
custom:
Issue: ""
SchemaChange: No schema change

View File

@@ -236,14 +236,12 @@ 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). 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 ```bash
# Run a specific test file # Run a specific test file
symfony composer exec phpunit -- path/to/TestFile.php vendor/bin/phpunit path/to/TestFile.php
# Run a specific test method # Run a specific test method
symfony composer exec phpunit -- --filter methodName path/to/TestFile.php vendor/bin/phpunit --filter methodName path/to/TestFile.php
``` ```
#### Test Structure #### Test Structure

View File

@@ -19,6 +19,7 @@ use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Address; use Chill\MainBundle\Entity\Address;
use libphonenumber\PhoneNumber; use libphonenumber\PhoneNumber;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
/** /**
* Immersion. * Immersion.
@@ -85,14 +86,14 @@ class Immersion implements \Stringable
* @Assert\NotBlank() * @Assert\NotBlank()
*/ */
#[ORM\Column(name: 'tuteurPhoneNumber', type: 'phone_number', nullable: true)] #[ORM\Column(name: 'tuteurPhoneNumber', type: 'phone_number', nullable: true)]
#[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber] #[PhonenumberConstraint(type: 'any')]
private ?PhoneNumber $tuteurPhoneNumber = null; private ?PhoneNumber $tuteurPhoneNumber = null;
#[ORM\Column(name: 'structureAccName', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)] #[ORM\Column(name: 'structureAccName', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)]
private ?string $structureAccName = null; private ?string $structureAccName = null;
#[ORM\Column(name: 'structureAccPhonenumber', type: 'phone_number', nullable: true)] #[ORM\Column(name: 'structureAccPhonenumber', type: 'phone_number', nullable: true)]
#[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber] #[PhonenumberConstraint(type: 'any')]
private ?PhoneNumber $structureAccPhonenumber = null; private ?PhoneNumber $structureAccPhonenumber = null;
#[ORM\Column(name: 'structureAccEmail', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)] #[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\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Repository\LocationRepository; use Chill\MainBundle\Repository\LocationRepository;
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use libphonenumber\PhoneNumber; use libphonenumber\PhoneNumber;
use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as MisdPhoneNumberConstraint;
use Symfony\Component\Serializer\Annotation as Serializer; use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap; use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
@@ -67,12 +67,12 @@ class Location implements TrackCreationInterface, TrackUpdateInterface
#[Serializer\Groups(['read', 'write', 'docgen:read'])] #[Serializer\Groups(['read', 'write', 'docgen:read'])]
#[ORM\Column(type: 'phone_number', nullable: true)] #[ORM\Column(type: 'phone_number', nullable: true)]
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::ANY])] #[PhonenumberConstraint(type: 'any')]
private ?PhoneNumber $phonenumber1 = null; private ?PhoneNumber $phonenumber1 = null;
#[Serializer\Groups(['read', 'write', 'docgen:read'])] #[Serializer\Groups(['read', 'write', 'docgen:read'])]
#[ORM\Column(type: 'phone_number', nullable: true)] #[ORM\Column(type: 'phone_number', nullable: true)]
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::ANY])] #[PhonenumberConstraint(type: 'any')]
private ?PhoneNumber $phonenumber2 = null; private ?PhoneNumber $phonenumber2 = null;
#[Serializer\Groups(['read'])] #[Serializer\Groups(['read'])]

View File

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

View File

@@ -31,8 +31,6 @@ interface PhoneNumberHelperInterface
/** /**
* Return true if the validation is configured and available. * 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; public function isPhonenumberValidationConfigured(): bool;

View File

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

View File

@@ -158,18 +158,3 @@ export const intervalISOToDays = (str: string | null): number | null => {
return days; 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,14 +1,8 @@
import { import { Scope } from "../../types";
DynamicKeys,
Scope,
ValidationExceptionInterface,
ValidationProblemFromMap,
ViolationFromMap
} from "../../types";
export type body = Record<string, boolean | string | number | null>; export type body = Record<string, boolean | string | number | null>;
export type fetchOption = Record<string, boolean | string | number | null>; export type fetchOption = Record<string, boolean | string | number | null>;
export type Primitive = string | number | boolean | null;
export type Params = Record<string, number | string>; export type Params = Record<string, number | string>;
export interface Pagination { export interface Pagination {
@@ -31,115 +25,20 @@ export interface TransportExceptionInterface {
name: string; name: string;
} }
export class ValidationException< export interface ValidationExceptionInterface
M extends Record<string, Record<string, string|number>> = Record< extends TransportExceptionInterface {
string, name: "ValidationException";
Record<string, string|number> error: object;
>, violations: string[];
> titles: string[];
extends Error propertyPaths: string[];
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>[] { export interface ValidationErrorResponse extends TransportExceptionInterface {
return this.violationsList.filter((v) => v.propertyPath.replace(/\[\d+\]$/, "") === property); violations: {
}
violationsByNormalizedPropertyAndParams<
P extends Extract<keyof M, string>,
K extends Extract<keyof M[P], string>
>(
property: P,
param: K,
param_value: M[P][K]
): ViolationFromMap<M>[]
{
const list = this.violationsByNormalizedProperty(property);
return list.filter(
(v): boolean =>
!!v.parameters &&
// `with_parameter in v.parameters` check indexing
param in v.parameters &&
// the cast is safe, because we have overloading that bind the types
(v.parameters as M[P])[param] === param_value
);
}
}
/**
* Check that the exception is a ValidationExceptionInterface
* @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; title: string;
violations: { propertyPath: string; title: string }[]; propertyPath: 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 { export interface AccessExceptionInterface extends TransportExceptionInterface {
@@ -166,151 +65,12 @@ 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
* *
* What this does * This method is suitable make a single fetch. When performing a GET to fetch a list of elements, always consider pagination
* - Performs a single HTTP request using fetch and returns the parsed JSON as Output. * and use of the @link{fetchResults} method.
* - 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 = async < export const makeFetch = <Input, Output>(
Input,
Output,
M extends Record<string, Record<string, string|number>> = Record<
string,
Record<string, string|number>
>,
>(
method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE", method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE",
url: string, url: string,
body?: body | Input | null, body?: body | Input | null,
@@ -330,8 +90,7 @@ export const makeFetch = async <
if (typeof options !== "undefined") { if (typeof options !== "undefined") {
opts = Object.assign(opts, options); opts = Object.assign(opts, options);
} }
return fetch(url, opts).then((response) => {
return fetch(url, opts).then(async (response) => {
if (response.status === 204) { if (response.status === 204) {
return Promise.resolve(); return Promise.resolve();
} }
@@ -341,20 +100,9 @@ export const makeFetch = async <
} }
if (response.status === 422) { if (response.status === 422) {
// Unprocessable Entity -> payload de validation Symfony return response.json().then((response) => {
const json = await response.json().catch(() => undefined); throw ValidationException(response);
});
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) { if (response.status === 403) {
@@ -419,6 +167,12 @@ function _fetchAction<T>(
throw NotFoundException(response); throw NotFoundException(response);
} }
if (response.status === 422) {
return response.json().then((response) => {
throw ValidationException(response);
});
}
if (response.status === 403) { if (response.status === 403) {
throw AccessException(response); throw AccessException(response);
} }
@@ -477,6 +231,24 @@ export const fetchScopes = (): Promise<Scope[]> => {
return fetchResults("/api/1.0/main/scope.json"); 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 // eslint-disable-next-line @typescript-eslint/no-unused-vars
const AccessException = (response: Response): AccessExceptionInterface => { const AccessException = (response: Response): AccessExceptionInterface => {
const error = {} as AccessExceptionInterface; const error = {} as AccessExceptionInterface;

View File

@@ -1,17 +0,0 @@
import {Gender, GenderTranslation} from "ChillMainAssets/types";
/**
* Translates a given gender object into its corresponding gender translation string.
*
* @param {Gender|null} gender - The gender object to be translated, null values are also supported
* @return {GenderTranslation} Returns the gender translation string corresponding to the provided gender,
* or "unknown" if the gender is null.
*/
export function toGenderTranslation(gender: Gender|null): GenderTranslation
{
if (null === gender) {
return "unknown";
}
return gender.genderTranslation;
}

View File

@@ -1,64 +1,14 @@
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc"; import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
import { CreatableEntityType } from "ChillPersonAssets/types";
export interface DateTime { export interface DateTime {
datetime: string; datetime: string;
datetime8601: 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 { 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;
}
/**
* Gender translation.
*
* Match the GenderEnum in PHP code.
*/
export type GenderTranslation = "male" | "female" | "neutral" | "unknown";
/**
* A gender
*
* See also
*/
export interface Gender {
type: "chill_main_gender";
id: number;
label: string;
genderTranslation: GenderTranslation;
}
/**
* Lightweight reference to a Gender, used in POST / PUT requests.
*/
export interface SetGender {
type: "chill_main_gender";
id: number; id: number;
// TODO
} }
export interface Household { export interface Household {
@@ -78,18 +28,6 @@ export interface Center {
id: number; id: number;
type: "center"; type: "center";
name: string; 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 { export interface Scope {
@@ -288,63 +226,13 @@ export interface TransportExceptionInterface {
name: string; name: string;
} }
type IndexedKey<Base extends string> = `${Base}[${number}]`; export interface ValidationExceptionInterface
type BaseKeys<M> = Extract<keyof M, string>; extends TransportExceptionInterface {
export type DynamicKeys<M extends Record<string, Record<string, unknown>>> =
| BaseKeys<M>
| { [K in BaseKeys<M> as IndexedKey<K>]: K }[IndexedKey<BaseKeys<M>>];
type NormalizeKey<K extends string> = K extends `${infer B}[${number}]` ? B : K;
export type ViolationFromMap<M extends Record<string, Record<string, unknown>>> = {
[K in DynamicKeys<M> & string]: { // <- note le "& string" ici
propertyPath: K;
title: string;
parameters?: M[NormalizeKey<K>];
type?: string;
}
}[DynamicKeys<M> & string];
export type ValidationProblemFromMap<
M extends Record<string, Record<string, string|number>>,
> = {
type: string;
title: string;
detail?: string;
violations: ViolationFromMap<M>[];
} & Record<string, unknown>;
export interface ValidationExceptionInterface<
M extends Record<string, Record<string, string|number>> = Record<
string,
Record<string, string|number>
>,
> extends Error {
name: "ValidationException"; name: "ValidationException";
/** Full server payload copy */ error: object;
problems: ValidationProblemFromMap<M>;
/** A list of all violations, with property key */
violationsList: ViolationFromMap<M>[];
/** Compact list "Title: path" */
violations: string[]; violations: string[];
/** Only titles */
titles: string[]; titles: string[];
/** Only property paths */ propertyPaths: string[];
propertyPaths: DynamicKeys<M> & string[];
/** Indexing by property (useful for display by field) */
byProperty: Record<Extract<keyof M, string>, string[]>;
violationsByNormalizedProperty(property: Extract<keyof M, string>): ViolationFromMap<M>[];
violationsByNormalizedPropertyAndParams<
P extends Extract<keyof M, string>,
K extends Extract<keyof M[P], string>
>(
property: P,
param: K,
param_value: M[P][K]
): ViolationFromMap<M>[];
} }
export interface AccessExceptionInterface extends TransportExceptionInterface { export interface AccessExceptionInterface extends TransportExceptionInterface {
@@ -412,12 +300,3 @@ export interface TabDefinition {
icon: string | null; icon: string | null;
counter: () => number; counter: () => number;
} }
/**
* Configuration for the CreateModal and Create component
*/
export interface CreateComponentConfig {
action?: string;
allowedTypes: CreatableEntityType[];
query?: string;
}

View File

@@ -24,7 +24,9 @@
{{ trans(getTextTitle) }} {{ trans(getTextTitle) }}
<span v-if="flag.loading" class="loading"> <span v-if="flag.loading" class="loading">
<i class="fa fa-circle-o-notch fa-spin fa-fw" /> <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> </span>
</h2> </h2>
</template> </template>
@@ -88,7 +90,9 @@
{{ trans(getTextTitle) }} {{ trans(getTextTitle) }}
<span v-if="flag.loading" class="loading"> <span v-if="flag.loading" class="loading">
<i class="fa fa-circle-o-notch fa-spin fa-fw" /> <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> </span>
</h2> </h2>
</template> </template>
@@ -171,7 +175,9 @@
{{ trans(getTextTitle) }} {{ trans(getTextTitle) }}
<span v-if="flag.loading" class="loading"> <span v-if="flag.loading" class="loading">
<i class="fa fa-circle-o-notch fa-spin fa-fw" /> <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> </span>
</h2> </h2>
</template> </template>
@@ -268,8 +274,7 @@ export default {
PREVIOUS, PREVIOUS,
NEXT, NEXT,
}; };
}, },props: ["context", "options", "addressChangedCallback"],
props: ["context", "options", "addressChangedCallback"],
components: { components: {
Modal, Modal,
ShowPane, ShowPane,

View File

@@ -55,7 +55,9 @@
:placeholder="trans(ADDRESS_BUILDING_NAME)" :placeholder="trans(ADDRESS_BUILDING_NAME)"
v-model="buildingName" v-model="buildingName"
/> />
<label for="buildingName">{{ trans(ADDRESS_BUILDING_NAME) }}</label> <label for="buildingName">{{
trans(ADDRESS_BUILDING_NAME)
}}</label>
</div> </div>
<div class="form-floating my-1"> <div class="form-floating my-1">
<input <input
@@ -77,7 +79,9 @@
:placeholder="trans(ADDRESS_DISTRIBUTION)" :placeholder="trans(ADDRESS_DISTRIBUTION)"
v-model="distribution" v-model="distribution"
/> />
<label for="distribution">{{ trans(ADDRESS_DISTRIBUTION) }}</label> <label for="distribution">{{
trans(ADDRESS_DISTRIBUTION)
}}</label>
</div> </div>
</div> </div>
</div> </div>
@@ -113,8 +117,7 @@ export default {
ADDRESS_FILL_AN_ADDRESS, ADDRESS_FILL_AN_ADDRESS,
trans, trans,
}; };
}, },props: ["entity", "isNoAddress"],
props: ["entity", "isNoAddress"],
computed: { computed: {
floor: { floor: {
set(value) { set(value) {

View File

@@ -57,7 +57,9 @@
:placeholder="trans(ADDRESS_STREET_NUMBER)" :placeholder="trans(ADDRESS_STREET_NUMBER)"
v-model="streetNumber" v-model="streetNumber"
/> />
<label for="streetNumber">{{ trans(ADDRESS_STREET_NUMBER) }}</label> <label for="streetNumber">{{
trans(ADDRESS_STREET_NUMBER)
}}</label>
</div> </div>
</div> </div>
</div> </div>
@@ -94,8 +96,7 @@ export default {
ADDRESS_CREATE_ADDRESS, ADDRESS_CREATE_ADDRESS,
trans, trans,
}; };
}, },props: ["entity", "context", "updateMapCenter", "flag", "checkErrors"],
props: ["entity", "context", "updateMapCenter", "flag", "checkErrors"],
data() { data() {
return { return {
value: this.context.edit ? this.entity.address.addressReference : null, value: this.context.edit ? this.entity.address.addressReference : null,

View File

@@ -85,8 +85,7 @@ export default {
ADDRESS_CREATE_POSTAL_CODE, ADDRESS_CREATE_POSTAL_CODE,
trans, trans,
}; };
}, },props: [
props: [
"entity", "entity",
"context", "context",
"focusOnAddress", "focusOnAddress",

View File

@@ -44,8 +44,7 @@ export default {
ADDRESS_SELECT_COUNTRY, ADDRESS_SELECT_COUNTRY,
trans, trans,
}; };
}, },props: ["context", "entity", "flag", "checkErrors"],
props: ["context", "entity", "flag", "checkErrors"],
emits: ["getCities"], emits: ["getCities"],
data() { data() {
return { return {

View File

@@ -129,8 +129,7 @@ export default {
ADDRESS_IS_CONFIDENTIAL, ADDRESS_IS_CONFIDENTIAL,
ADDRESS_IS_NO_ADDRESS, ADDRESS_IS_NO_ADDRESS,
}; };
}, },props: [
props: [
"context", "context",
"options", "options",
"defaultz", "defaultz",

View File

@@ -11,7 +11,9 @@
<div v-if="flag.success" class="alert alert-success"> <div v-if="flag.success" class="alert alert-success">
{{ trans(getSuccessText) }} {{ trans(getSuccessText) }}
<span v-if="forceRedirect">{{ trans(ADDRESS_WAIT_REDIRECTION) }}</span> <span v-if="forceRedirect">{{
trans(ADDRESS_WAIT_REDIRECTION)
}}</span>
</div> </div>
<div <div
@@ -111,8 +113,7 @@ import {
export default { export default {
name: "ShowPane", name: "ShowPane",
methods: {}, methods: {},components: {
components: {
AddressRenderBox, AddressRenderBox,
ActionButtons, ActionButtons,
}, },
@@ -127,8 +128,7 @@ export default {
ADDRESS_ADDRESS_NEW_SUCCESS, ADDRESS_ADDRESS_NEW_SUCCESS,
ADDRESS_ADDRESS_EDIT_SUCCESS, ADDRESS_ADDRESS_EDIT_SUCCESS,
}; };
}, },props: [
props: [
"context", "context",
"defaultz", "defaultz",
"options", "options",
@@ -179,9 +179,7 @@ export default {
: this.defaultz.button.text.create; : this.defaultz.button.text.create;
}, },
getSuccessText() { getSuccessText() {
return this.context.edit return this.context.edit ? ADDRESS_ADDRESS_EDIT_SUCCESS : ADDRESS_ADDRESS_NEW_SUCCESS;
? ADDRESS_ADDRESS_EDIT_SUCCESS
: ADDRESS_ADDRESS_NEW_SUCCESS;
}, },
onlyButton() { onlyButton() {
return typeof this.options.onlyButton !== "undefined" return typeof this.options.onlyButton !== "undefined"

View File

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

View File

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

View File

@@ -1,28 +1,28 @@
<template> <template>
<i :class="['bi', genderClass]"></i> <i :class="['fa', genderClass, 'px-1']" />
</template> </template>
<script setup lang="ts"> <script setup>
import { computed } from "vue"; import { computed } from "vue";
import type { Gender } from "ChillMainAssets/types"; const props = defineProps({
import {toGenderTranslation} from "ChillMainAssets/lib/api/genderHelper"; gender: {
type: Object,
required: true,
},
});
interface GenderIconRenderBoxProps { const genderClass = computed(() => {
gender: Gender; switch (props.gender.genderTranslation) {
} case "woman":
return "fa-venus";
const props = defineProps<GenderIconRenderBoxProps>(); case "man":
return "fa-mars";
const genderClass = computed<string>(() => { case "both":
switch (toGenderTranslation(props.gender)) { return "fa-neuter";
case "female":
return "bi-gender-female";
case "male":
return "bi-gender-male";
case "neutral":
case "unknown": case "unknown":
return "fa-genderless";
default: default:
return "bi-gender-neuter"; return "fa-genderless";
} }
}); });
</script> </script>

View File

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

View File

@@ -80,7 +80,9 @@ class ExtractPhonenumberFromPattern
} }
if (5 < $length) { if (5 < $length) {
return new SearchExtractionResult($subject, [\implode('', $phonenumber)]); $filtered = \trim(\strtr($subject, [$matches[0] => '']));
return new SearchExtractionResult($filtered, [\implode('', $phonenumber)]);
} }
} }

View File

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

View File

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

View File

@@ -43,20 +43,20 @@ final class ExtractPhonenumberFromPatternTest extends KernelTestCase
yield ['BE', 'Diallo 15/06/2021', 0, [], 'Diallo 15/06/2021', 'no phonenumber and a date']; yield ['BE', 'Diallo 15/06/2021', 0, [], 'Diallo 15/06/2021', 'no phonenumber and a date'];
yield ['BE', 'Diallo 0486 123 456', 1, ['+32486123456'], 'Diallo 0486 123 456', 'a phonenumber and a name']; yield ['BE', 'Diallo 0486 123 456', 1, ['+32486123456'], 'Diallo', 'a phonenumber and a name'];
yield ['BE', 'Diallo 123 456', 1, ['123456'], 'Diallo 123 456', 'a number and a name, without leadiing 0']; yield ['BE', 'Diallo 123 456', 1, ['123456'], 'Diallo', 'a number and a name, without leadiing 0'];
yield ['BE', '123 456', 1, ['123456'], '123 456', 'only phonenumber']; yield ['BE', '123 456', 1, ['123456'], '', 'only phonenumber'];
yield ['BE', '0123 456', 1, ['+32123456'], '0123 456', 'only phonenumber with a leading 0']; yield ['BE', '0123 456', 1, ['+32123456'], '', 'only phonenumber with a leading 0'];
yield ['FR', '123 456', 1, ['123456'], '123 456', 'only phonenumber']; yield ['FR', '123 456', 1, ['123456'], '', 'only phonenumber'];
yield ['FR', '0123 456', 1, ['+33123456'], '0123 456', 'only phonenumber with a leading 0']; yield ['FR', '0123 456', 1, ['+33123456'], '', 'only phonenumber with a leading 0'];
yield ['FR', 'Diallo 0486 123 456', 1, ['+33486123456'], 'Diallo 0486 123 456', 'a phonenumber and a name']; yield ['FR', 'Diallo 0486 123 456', 1, ['+33486123456'], 'Diallo', 'a phonenumber and a name'];
yield ['FR', 'Diallo +32486 123 456', 1, ['+32486123456'], 'Diallo +32486 123 456', 'a phonenumber and a name']; yield ['FR', 'Diallo +32486 123 456', 1, ['+32486123456'], 'Diallo', 'a phonenumber and a name'];
} }
} }

View File

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

View File

@@ -16,9 +16,6 @@ use Psr\Log\LoggerInterface;
use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator; 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 final class ValidPhonenumber extends ConstraintValidator
{ {
public function __construct(private readonly LoggerInterface $logger, private readonly PhoneNumberHelperInterface $phonenumberHelper) {} public function __construct(private readonly LoggerInterface $logger, private readonly PhoneNumberHelperInterface $phonenumberHelper) {}

View File

@@ -136,6 +136,34 @@ filter_order:
Search: Chercher dans la liste Search: Chercher dans la liste
By date: Filtrer par date By date: Filtrer par date
search_box: Filtrer par contenu search_box: Filtrer par contenu
renderbox:
person: "Usager"
birthday:
man: "Né le"
woman: "Née le"
neutral: "Né·e le"
unknown: "Né·e le"
deathdate: "Date de décès"
household_without_address: "Le ménage de l'usager est sans adresse"
no_data: "Aucune information renseignée"
type:
thirdparty: "Tiers"
person: "Usager"
holder: "Titulaire"
years_old: >-
{n, plural,
=0 {0 an}
one {1 an}
other {# ans}
}
residential_address: "Adresse de résidence"
located_at: "réside chez"
household_number: "Ménage n°{number}"
current_members: "Membres actuels"
no_current_address: "Sans adresse actuellement"
new_household: "Nouveau ménage"
no_members_yet: "Aucun membre actuellement"
pick_entity: pick_entity:
add: "Ajouter" add: "Ajouter"
modal_title: >- modal_title: >-

View File

@@ -279,7 +279,7 @@ final class PersonController extends AbstractController
private function lastPostDataBuildHash(Form $form, Request $request): string private function lastPostDataBuildHash(Form $form, Request $request): string
{ {
$fields = []; $fields = [];
$ignoredFields = ['form_status', '_token', 'identifiers']; $ignoredFields = ['form_status', '_token'];
foreach ($request->request->all()[$form->getName()] as $field => $value) { foreach ($request->request->all()[$form->getName()] as $field => $value) {
if (\in_array($field, $ignoredFields, true)) { if (\in_array($field, $ignoredFields, true)) {

View File

@@ -1,47 +0,0 @@
<?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,6 +96,7 @@ 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 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. // We should use the PersonRepository service instead of a custom service name.
$loader->load('services/repository.yaml'); $loader->load('services/repository.yaml');
$loader->load('services/serializer.yaml');
$loader->load('services/security.yaml'); $loader->load('services/security.yaml');
$loader->load('services/doctrineEventListener.yaml'); $loader->load('services/doctrineEventListener.yaml');
$loader->load('services/accompanyingPeriodConsistency.yaml'); $loader->load('services/accompanyingPeriodConsistency.yaml');

View File

@@ -1,42 +0,0 @@
<?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,16 +12,10 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Entity\Identifier; namespace Chill\PersonBundle\Entity\Identifier;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\PersonIdentifier\Validator\UniqueIdentifierConstraint;
use Chill\PersonBundle\PersonIdentifier\Validator\ValidIdentifierConstraint;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity] #[ORM\Entity]
#[ORM\Table(name: 'chill_person_identifier')] #[ORM\Table(name: 'chill_person_identifier')]
#[ORM\UniqueConstraint(name: 'chill_person_identifier_unique', columns: ['definition_id', 'canonical'])]
#[ORM\UniqueConstraint(name: 'chill_person_identifier_unique_person_definition', columns: ['definition_id', 'person_id'])]
#[UniqueIdentifierConstraint]
#[ValidIdentifierConstraint]
class PersonIdentifier class PersonIdentifier
{ {
#[ORM\Id] #[ORM\Id]
@@ -36,12 +30,12 @@ class PersonIdentifier
#[ORM\Column(name: 'value', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])] #[ORM\Column(name: 'value', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
private array $value = []; 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 = ''; private string $canonical = '';
public function __construct( public function __construct(
#[ORM\ManyToOne(targetEntity: PersonIdentifierDefinition::class)] #[ORM\ManyToOne(targetEntity: PersonIdentifierDefinition::class)]
#[ORM\JoinColumn(name: 'definition_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')] #[ORM\JoinColumn(name: 'definition_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private PersonIdentifierDefinition $definition, private PersonIdentifierDefinition $definition,
) {} ) {}

View File

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

View File

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

View File

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

View File

@@ -38,13 +38,8 @@ final readonly class PersonIdentifiersDataMapper implements DataMapperInterface
if (!$worker->getDefinition()->isEditableByUsers()) { if (!$worker->getDefinition()->isEditableByUsers()) {
continue; continue;
} }
$form = $formsByKey['identifier_'.$worker->getDefinition()->getId()];
$form = $formsByKey['identifier_'.$worker->getDefinition()->getId()] ?? null; $identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition()->getId() === $identifier->getId());
if (null === $form) {
continue;
}
$identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition() === $identifier->getDefinition());
if (null === $identifier) { if (null === $identifier) {
$identifier = new PersonIdentifier($worker->getDefinition()); $identifier = new PersonIdentifier($worker->getDefinition());
} }
@@ -60,7 +55,7 @@ final readonly class PersonIdentifiersDataMapper implements DataMapperInterface
foreach ($forms as $name => $form) { foreach ($forms as $name => $form) {
$identifierId = (int) substr((string) $name, 11); $identifierId = (int) substr((string) $name, 11);
$identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $identifier->getDefinition()->getId() === $identifierId); $identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $identifier->getId() === $identifierId);
$definition = $this->identifierDefinitionRepository->find($identifierId); $definition = $this->identifierDefinitionRepository->find($identifierId);
if (null === $identifier) { if (null === $identifier) {
$identifier = new PersonIdentifier($definition); $identifier = new PersonIdentifier($definition);

View File

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

View File

@@ -13,26 +13,20 @@ namespace Chill\PersonBundle\PersonIdentifier\Identifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Chill\PersonBundle\PersonIdentifier\IdentifierViolationDTO;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface; use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
final readonly class StringIdentifier implements PersonIdentifierEngineInterface final readonly class StringIdentifier implements PersonIdentifierEngineInterface
{ {
public const NAME = 'chill-person-bundle.string-identifier';
private const ONLY_NUMBERS = 'only_numbers';
private const FIXED_LENGTH = 'fixed_length';
public static function getName(): string public static function getName(): string
{ {
return self::NAME; return 'chill-person-bundle.string-identifier';
} }
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
{ {
return trim($value['content'] ?? ''); return $value['content'] ?? '';
} }
public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void
@@ -42,32 +36,6 @@ final readonly class StringIdentifier implements PersonIdentifierEngineInterface
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
{ {
return trim($identifier?->getValue()['content'] ?? ''); return $identifier?->getValue()['content'] ?? '';
}
public function isEmpty(PersonIdentifier $identifier): bool
{
return '' === trim($identifier->getValue()['content'] ?? '');
}
public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array
{
$config = $definition->getData();
$content = (string) ($identifier->getValue()['content'] ?? '');
$violations = [];
if (($config[self::ONLY_NUMBERS] ?? false) && !preg_match('/^[0-9]+$/', $content)) {
$violations[] = new IdentifierViolationDTO('person_identifier.only_number', '2a3352c0-a2b9-11f0-a767-b7a3f80e52f1');
}
if (null !== ($config[self::FIXED_LENGTH] ?? null) && strlen($content) !== $config[self::FIXED_LENGTH]) {
$violations[] = new IdentifierViolationDTO(
'person_identifier.fixed_length',
'2b02a8fe-a2b9-11f0-bfe5-033300972783',
['limit' => (string) $config[self::FIXED_LENGTH]]
);
}
return $violations;
} }
} }

View File

@@ -1,31 +0,0 @@
<?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;
/**
* Data Transfer Object to create a ConstraintViolationListInterface.
*/
class IdentifierViolationDTO
{
public function __construct(
public string $message,
/**
* @var string an UUID
*/
public string $code,
/**
* @var array<string, string>
*/
public array $parameters = [],
public string $messageDomain = 'validators',
) {}
}

View File

@@ -1,39 +0,0 @@
<?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,19 +24,4 @@ interface PersonIdentifierEngineInterface
public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void; public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void;
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string; 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;
/**
* Return a list of @see{IdentifierViolationDTO} to generatie violation errors.
*
* @return list<IdentifierViolationDTO>
*/
public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array;
} }

View File

@@ -13,7 +13,6 @@ namespace Chill\PersonBundle\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Chill\PersonBundle\PersonIdentifier\Exception\EngineNotFoundException; use Chill\PersonBundle\PersonIdentifier\Exception\EngineNotFoundException;
use Chill\PersonBundle\PersonIdentifier\Exception\PersonIdentifierDefinitionNotFoundException;
use Chill\PersonBundle\Repository\Identifier\PersonIdentifierDefinitionRepository; use Chill\PersonBundle\Repository\Identifier\PersonIdentifierDefinitionRepository;
final readonly class PersonIdentifierManager implements PersonIdentifierManagerInterface final readonly class PersonIdentifierManager implements PersonIdentifierManagerInterface
@@ -45,16 +44,8 @@ final readonly class PersonIdentifierManager implements PersonIdentifierManagerI
return $workers; return $workers;
} }
public function buildWorkerByPersonIdentifierDefinition(int|PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker public function buildWorkerByPersonIdentifierDefinition(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); return new PersonIdentifierWorker($this->getEngine($personIdentifierDefinition->getEngine()), $personIdentifierDefinition);
} }

View File

@@ -18,16 +18,9 @@ interface PersonIdentifierManagerInterface
/** /**
* Build PersonIdentifierWorker's for all active definition. * Build PersonIdentifierWorker's for all active definition.
* *
* Only active definition are returned.
*
* @return list<PersonIdentifierWorker> * @return list<PersonIdentifierWorker>
*/ */
public function getWorkers(): array; public function getWorkers(): array;
/** public function buildWorkerByPersonIdentifierDefinition(PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker;
* @param int|PersonIdentifierDefinition $personIdentifierDefinition an instance of PersonIdentifierDefinition, or his id
*
* @throw PersonIdentifierNotFoundException
*/
public function buildWorkerByPersonIdentifierDefinition(int|PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker;
} }

View File

@@ -15,7 +15,7 @@ use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
readonly class PersonIdentifierWorker final readonly class PersonIdentifierWorker
{ {
public function __construct( public function __construct(
private PersonIdentifierEngineInterface $identifierEngine, private PersonIdentifierEngineInterface $identifierEngine,
@@ -46,20 +46,4 @@ readonly class PersonIdentifierWorker
{ {
return $this->identifierEngine->renderAsString($identifier, $this->definition); 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);
}
/**
* @return list<IdentifierViolationDTO>
*/
public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array
{
return $this->identifierEngine->validate($identifier, $definition);
}
} }

View File

@@ -1,28 +0,0 @@
<?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

@@ -1,53 +0,0 @@
<?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

@@ -1,25 +0,0 @@
<?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

@@ -1,53 +0,0 @@
<?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->findByDefinitionAndCanonical($value->getDefinition(), $value->getValue());
if (count($identifiers) > 0) {
if (count($identifiers) > 1 || $identifiers[0]->getPerson() !== $value->getPerson()) {
$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

@@ -1,23 +0,0 @@
<?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 ValidIdentifierConstraint extends Constraint
{
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}

View File

@@ -1,47 +0,0 @@
<?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\PersonIdentifier\PersonIdentifierManagerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
final class ValidIdentifierConstraintValidator extends ConstraintValidator
{
public function __construct(private readonly PersonIdentifierManagerInterface $personIdentifierManager) {}
public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof ValidIdentifierConstraint) {
throw new UnexpectedTypeException($constraint, ValidIdentifierConstraint::class);
}
if (!$value instanceof PersonIdentifier) {
throw new UnexpectedValueException($value, PersonIdentifier::class);
}
$worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($value->getDefinition());
$violations = $worker->validate($value, $value->getDefinition());
foreach ($violations as $violation) {
$this->context->buildViolation($violation->message)
->setParameters($violation->parameters)
->setParameter('{{ code }}', $violation->code)
->setParameter('definition_id', (string) $value->getDefinition()->getId())
->addViolation();
}
}
}

View File

@@ -1,42 +0,0 @@
<?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 Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class PersonIdentifierRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry, private readonly PersonIdentifierManagerInterface $personIdentifierManager)
{
parent::__construct($registry, PersonIdentifier::class);
}
public function findByDefinitionAndCanonical(PersonIdentifierDefinition $definition, array|string $valueOrCanonical): array
{
return $this->createQueryBuilder('p')
->where('p.definition = :definition')
->andWhere('p.canonical = :canonical')
->setParameter('definition', $definition)
->setParameter(
'canonical',
is_string($valueOrCanonical) ?
$valueOrCanonical :
$this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($definition)->canonicalizeValue($valueOrCanonical),
)
->getQuery()
->getResult();
}
}

View File

@@ -17,8 +17,6 @@ use Chill\MainBundle\Search\ParsingException;
use Chill\MainBundle\Search\SearchApiQuery; use Chill\MainBundle\Search\SearchApiQuery;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
use Chill\PersonBundle\Security\Authorization\PersonVoter; use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NonUniqueResultException;
@@ -29,13 +27,7 @@ use Symfony\Component\Security\Core\Security;
final readonly class PersonACLAwareRepository implements PersonACLAwareRepositoryInterface final readonly class PersonACLAwareRepository implements PersonACLAwareRepositoryInterface
{ {
public function __construct( public function __construct(private Security $security, private EntityManagerInterface $em, private CountryRepository $countryRepository, private AuthorizationHelperInterface $authorizationHelper) {}
private Security $security,
private EntityManagerInterface $em,
private CountryRepository $countryRepository,
private AuthorizationHelperInterface $authorizationHelper,
private PersonIdentifierManagerInterface $personIdentifierManager,
) {}
public function buildAuthorizedQuery( public function buildAuthorizedQuery(
?string $default = null, ?string $default = null,
@@ -115,15 +107,6 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor
$query $query
->setFromClause('chill_person_person AS person'); ->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 = []; $pertinence = [];
$pertinenceArgs = []; $pertinenceArgs = [];
$andWhereSearchClause = []; $andWhereSearchClause = [];
@@ -141,53 +124,20 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor
'(starts_with(LOWER(UNACCENT(lastname)), UNACCENT(LOWER(?))))::int'; '(starts_with(LOWER(UNACCENT(lastname)), UNACCENT(LOWER(?))))::int';
\array_push($pertinenceArgs, $str, $str, $str, $str); \array_push($pertinenceArgs, $str, $str, $str, $str);
$q = [ $andWhereSearchClause[] =
'LOWER(UNACCENT(?)) <<% person.fullnamecanonical', '(LOWER(UNACCENT(?)) <<% person.fullnamecanonical OR '.
"person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' ", "person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' )";
]; \array_push($andWhereSearchClauseArgs, $str, $str);
$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 * 1000000";
$qArguments = [...$qArguments, $str, ...$idDefinitionWorkers];
$pertinenceArgs = [...$pertinenceArgs, $str, ...$idDefinitionWorkers];
}
$andWhereSearchClause[] = '('.implode(' OR ', $q).')';
$andWhereSearchClauseArgs = [...$andWhereSearchClauseArgs, ...$qArguments];
} }
$query->andWhereClause(
\implode(' AND ', $andWhereSearchClause),
$andWhereSearchClauseArgs
);
} else { } else {
$pertinence = ['1']; $pertinence = ['1'];
$pertinenceArgs = []; $pertinenceArgs = [];
} }
if (null !== $phonenumber) {
$personPhoneClause = "person.phonenumber LIKE '%' || ? || '%' OR person.mobilenumber LIKE '%' || ? || '%' OR EXISTS (SELECT 1 FROM chill_person_phone where person_id = person.id AND phonenumber LIKE '%' || ? || '%')";
if (count($andWhereSearchClauseArgs) > 0) {
$initialSearchClause = '(('.\implode(' AND ', $andWhereSearchClause).') OR '.$personPhoneClause.')';
}
$andWhereSearchClauseArgs = [...$andWhereSearchClauseArgs, $phonenumber, $phonenumber, $phonenumber];
// drastically increase pertinence
$pertinence[] = "(person.phonenumber LIKE '%' || ? || '%' OR person.mobilenumber LIKE '%' || ? || '%' OR EXISTS (SELECT 1 FROM chill_person_phone where person_id = person.id AND phonenumber LIKE '%' || ? || '%'))::int * 1000000";
$pertinenceArgs = [...$pertinenceArgs, $phonenumber, $phonenumber, $phonenumber];
} else {
$initialSearchClause = \implode(' AND ', $andWhereSearchClause);
}
if (isset($initialSearchClause)) {
$query->andWhereClause(
$initialSearchClause,
$andWhereSearchClauseArgs
);
}
$query $query
->setSelectPertinence(\implode(' + ', $pertinence), $pertinenceArgs); ->setSelectPertinence(\implode(' + ', $pertinence), $pertinenceArgs);
@@ -226,6 +176,14 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor
); );
} }
if (null !== $phonenumber) {
$query->andWhereClause(
"person.phonenumber LIKE '%' || ? || '%' OR person.mobilenumber LIKE '%' || ? || '%' OR pp.phonenumber LIKE '%' || ? || '%'",
[$phonenumber, $phonenumber, $phonenumber]
);
$query->setFromClause($query->getFromClause().' LEFT JOIN chill_person_phone pp ON pp.person_id = person.id');
}
if (null !== $city) { if (null !== $city) {
$query->setFromClause($query->getFromClause().' '. $query->setFromClause($query->getFromClause().' '.
'JOIN view_chill_person_current_address vcpca ON vcpca.person_id = person.id '. 'JOIN view_chill_person_current_address vcpca ON vcpca.person_id = person.id '.

View File

@@ -10,25 +10,16 @@ import {
Scope, Scope,
Job, Job,
PrivateCommentEmbeddable, PrivateCommentEmbeddable,
TranslatableString,
DateTimeWrite,
SetGender,
SetCenter,
SetCivility,
} from "ChillMainAssets/types"; } from "ChillMainAssets/types";
import { StoredObject } from "ChillDocStoreAssets/types"; import { StoredObject } from "ChillDocStoreAssets/types";
import { Thirdparty } from "../../../ChillThirdPartyBundle/Resources/public/types"; import { Thirdparty } from "../../../ChillThirdPartyBundle/Resources/public/types";
import { Calendar } from "../../../ChillCalendarBundle/Resources/public/types"; import { Calendar } from "../../../ChillCalendarBundle/Resources/public/types";
import Person from "./vuejs/_components/OnTheFly/Person.vue";
export interface AltName { export interface AltName {
labels: TranslatableString; label: string;
key: string; key: string;
} }
export interface AltNameWrite {
key: string;
value: string;
}
export interface Person { export interface Person {
id: number; id: number;
type: "person"; type: "person";
@@ -50,36 +41,6 @@ export interface Person {
civility: Civility | null; civility: Civility | null;
current_household_id: number; current_household_id: number;
current_residential_addresses: Address[]; current_residential_addresses: Address[];
/**
* The person id as configured by the user
*/
personId: string;
}
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 { export interface AccompanyingPeriod {
@@ -368,18 +329,11 @@ export interface AccompanyingPeriodWorkEvaluationDocument {
workflows: object[]; 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 = export type EntityType =
| CreatableEntityType
| "user_group" | "user_group"
| "user" | "user"
| "person"
| "thirdparty"
| "household"; | "household";
export type Entities = (UserGroup | User | Person | Thirdparty | Household) & { export type Entities = (UserGroup | User | Person | Thirdparty | Household) & {
@@ -417,8 +371,7 @@ export interface Search {
export interface SearchOptions { export interface SearchOptions {
uniq: boolean; uniq: boolean;
/** @deprecated */ type: string[];
type: EntityType[];
priority: number | null; priority: number | null;
button: { button: {
size: string; size: string;
@@ -428,17 +381,6 @@ 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 { export class MakeFetchException extends Error {
sta: number; sta: number;
txt: string; txt: string;

View File

@@ -0,0 +1,88 @@
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

@@ -1,87 +0,0 @@
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,126 +3,487 @@
class="btn" class="btn"
:class="getClassButton" :class="getClassButton"
:title="buttonTitle" :title="buttonTitle"
@click="openModalChoose" @click="openModal"
> >
<span v-if="displayTextButton">{{ buttonTitle }}</span> <span v-if="displayTextButton">{{ buttonTitle }}</span>
</a> </a>
<person-choose-modal <teleport to="body">
v-if="showModalChoose" <modal
:modal-title="modalTitle" v-if="showModal"
:options="options" @close="closeModal"
:suggested="suggested" :modal-dialog-class="modalDialogClass"
:selected="selected" :show="showModal"
:modal-dialog-class="'modal-dialog-scrollable modal-xl'" :hide-footer="false"
:allow-create="props.allowCreate" >
@close="closeModalChoose" <template #header>
@addNewPersons="(payload) => emit('addNewPersons', payload)" <h3 class="modal-title">
@onAskForCreate="onAskForCreate" {{ 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"
@save-form-on-the-fly="saveFormOnTheFly"
@new-prior-suggestion="newPriorSuggestion"
@update-selected="updateSelected"
/> />
<CreateModal <div class="create-button">
v-if="creatableEntityTypes.length > 0 && showModalCreate" <on-the-fly
:allowed-types="creatableEntityTypes" 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" :query="query"
@close="closeModalCreate" action="create"
@onPersonCreated="onPersonCreated" @save-form-on-the-fly="saveFormOnTheFly"
></CreateModal> 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>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue"; // eslint-disable-next-line @typescript-eslint/no-unused-vars
import PersonChooseModal from "./AddPersons/PersonChooseModal.vue"; import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import type { 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 {
Suggestion, Suggestion,
Search,
AddPersonResult as OriginalResult,
SearchOptions, SearchOptions,
CreatableEntityType,
EntityType,
Person,
} from "ChillPersonAssets/types"; } from "ChillPersonAssets/types";
import { marked } from "marked";
import options = marked.options;
import CreateModal from "ChillMainAssets/vuejs/OnTheFly/components/CreateModal.vue";
interface AddPersonsConfig { // Extend Result type to include optional addressId
suggested?: Suggestion[]; type Result = OriginalResult & { addressId?: number };
selected?: Suggestion[];
buttonTitle: string;
modalTitle: string;
options: SearchOptions;
allowCreate?: boolean;
types?: EntityType[] | undefined;
}
const props = withDefaults(defineProps<AddPersonsConfig>(), { const props = defineProps({
suggested: () => [], suggested: { type: Array as () => Suggestion[], default: () => [] },
selected: () => [], selected: { type: Array as () => Suggestion[], default: () => [] },
allowCreate: () => true, buttonTitle: { type: String, required: true },
types: () => ["person"], modalTitle: { type: String, required: true },
options: { type: Object as () => SearchOptions, required: true },
}); });
const emit = defineEmits(["addNewPersons"]);
defineEmits<
(e: "addNewPersons", payload: { selected: Suggestion[] }) => void
>();
const showModalChoose = ref(false); const showModal = ref(false);
const showModalCreate = ref(false); const modalDialogClass = ref("modal-dialog-scrollable modal-xl");
const query = ref("");
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 getClassButton = computed(() => { const getClassButton = computed(() => {
const size = props.options?.button?.size ?? ""; let size = props.options?.button?.size ?? "";
const type = props.options?.button?.type ?? "btn-create"; let type = props.options?.button?.type ?? "btn-create";
return size ? `${size} ${type}` : type; return size ? size + " " + type : type;
}); });
const displayTextButton = computed(() => const displayTextButton = computed(() =>
props.options?.button?.display !== undefined props.options?.button?.display !== undefined
? props.options.button.display ? props.options.button.display
: true, : true,
); );
const creatableEntityTypes = computed<CreatableEntityType[]>(() => { const checkUniq = computed(() =>
if (typeof props.options.type !== "undefined") { props.options.uniq === true ? "radio" : "checkbox",
return props.options.type.filter(
(e: EntityType) => e === "thirdparty" || e === "person",
); );
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);
} }
return props.types.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);
}); });
function onAskForCreate(payload: { query: string }) { function openModal() {
query.value = payload.query; showModal.value = true;
showModalChoose.value = false; nextTick(() => {
showModalCreate.value = true; if (searchRef.value) searchRef.value.focus();
});
}
function closeModal() {
showModal.value = false;
} }
function openModalChoose() { function setQuery(q: string) {
showModalChoose.value = true; search.query = q;
// Clear previous search if any
if (search.currentSearchQueryController) {
search.currentSearchQueryController.abort();
search.currentSearchQueryController = null;
} }
function closeModalChoose() { if (q === "") {
showModalChoose.value = false; loadSuggestions([]);
return;
} }
function closeModalCreate() { // Debounce delay based on query length
showModalCreate.value = false; 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 onPersonCreated(payload: { person: Person }) { function loadSuggestions(suggestedArr: Suggestion[]) {
console.log("onPersonCreated", payload); search.suggested = suggestedArr;
showModalCreate.value = false; search.suggested.forEach((item) => {
const suggestion = { item.key = itemKey(item);
result: payload.person, });
relevance: 999999, }
key: "person",
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) {
let suggestion = {
key: entity.type + entity.id,
relevance: 0.5,
result: entity,
}; };
emit("addNewPersons", { selected: [suggestion] }); 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> </script>
<style lang="scss" scoped> <style lang="scss">
/* Button styles can remain here if needed */ 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> </style>

View File

@@ -1,414 +0,0 @@
<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" class="btn btn-submit" @click="emit('onAskForCreate', { query })">
{{ trans(ONTHEFLY_CREATE_BUTTON, { q: query }) }}
</button>
</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

@@ -5,16 +5,46 @@
<div class="item-col"> <div class="item-col">
<div class="entity-label"> <div class="entity-label">
<div :class="'denomination h' + options.hLevel"> <div :class="'denomination h' + options.hLevel">
<template v-if="options.addLink === true">
<a v-if="options.addLink === true" :href="getUrl"> <a v-if="options.addLink === true" :href="getUrl">
<span>{{ person.text }}</span> <!-- use person-text here to avoid code duplication ? TODO -->
<span v-if="person.deathdate" class="deathdate"> ()</span> <span class="firstname">{{ person.firstName }}</span>
<span class="lastname">{{ person.lastName }}</span>
<span v-if="person.suffixText" class="suffixtext"
>&nbsp;{{ person.suffixText }}</span
>
<span
v-if="person.altNames && options.addAltNames == true"
class="altnames"
>
<span :class="'altname altname-' + altNameKey">{{
altNameLabel
}}</span>
</span>
</a> </a>
</template>
<template v-else> <!-- use person-text here to avoid code duplication ? TODO -->
<span>{{ person.text }}</span> <span class="firstname">{{ person.firstName + " " }}</span>
<span class="lastname">{{ person.lastName }}</span>
<span v-if="person.suffixText" class="suffixtext"
>&nbsp;{{ person.suffixText }}</span
>
<span v-if="person.deathdate" class="deathdate"> ()</span> <span v-if="person.deathdate" class="deathdate"> ()</span>
</template> <span
v-if="person.altNames && options.addAltNames == true"
class="altnames"
>
<span :class="'altname altname-' + altNameKey">{{
altNameLabel
}}</span>
</span>
<span
v-if="options.addId == true"
class="id-number"
:title="'n° ' + person.id"
>{{ person.id }}</span
>
<badge-entity <badge-entity
v-if="options.addEntity === true" v-if="options.addEntity === true"
:entity="person" :entity="person"
@@ -22,36 +52,61 @@
/> />
</div> </div>
<p>
<span
v-if="options.addId == true"
:title="person.personId"
><i class="bi bi-info-circle"></i> {{ person.personId }}</span
>
</p>
<p v-if="options.addInfo === true" class="moreinfo"> <p v-if="options.addInfo === true" class="moreinfo">
<gender-icon-render-box <gender-icon-render-box
v-if="person.gender" v-if="person.gender"
:gender="person.gender" :gender="person.gender"
/> <span />
v-if="person.birthdate" <time
v-if="person.birthdate && !person.deathdate"
:datetime="person.birthdate"
:title="birthdate"
> >
{{ trans(RENDERBOX_BIRTHDAY_STATEMENT, {gender: toGenderTranslation(person.gender), birthdate: ISOToDate(person.birthdate?.datetime)}) }} {{
</span> trans(birthdateTranslation) +
" " +
new Intl.DateTimeFormat("fr-FR", {
dateStyle: "long",
}).format(birthdate)
}}
</time>
<time
v-else-if="person.birthdate && person.deathdate"
:datetime="person.deathdate"
:title="person.deathdate"
>
{{
new Intl.DateTimeFormat("fr-FR", {
dateStyle: "long",
}).format(birthdate)
}}
-
{{
new Intl.DateTimeFormat("fr-FR", {
dateStyle: "long",
}).format(deathdate)
}}
</time>
<time
v-else-if="person.deathdate"
:datetime="person.deathdate"
:title="person.deathdate"
>
{{
trans(RENDERBOX_DEATHDATE) +
" " +
new Intl.DateTimeFormat("fr-FR", {
dateStyle: "long",
}).format(deathdate)
}}
</time>
<span v-if="options.addAge && person.birthdate" class="age"> <span v-if="options.addAge && person.birthdate" class="age">
({{ trans(RENDERBOX_YEARS_OLD, { n: person.age }) }}) ({{ trans(RENDERBOX_YEARS_OLD, { n: person.age }) }})
</span> </span>
</p> </p>
<p>
<span
v-if="person.deathdate"
>
{{ trans(RENDERBOX_DEATHDATE_STATEMENT, {gender: toGenderTranslation(person.gender), deathdate: ISOToDate(person.deathdate?.datetime)}) }}
</span>
</p>
</div> </div>
</div> </div>
@@ -75,6 +130,11 @@
<a <a
v-if="options.addHouseholdLink === true" v-if="options.addHouseholdLink === true"
:href="getCurrentHouseholdUrl" :href="getCurrentHouseholdUrl"
:title="
trans(PERSONS_ASSOCIATED_SHOW_HOUSEHOLD_NUMBER, {
id: person.current_household_id,
})
"
> >
<span class="badge rounded-pill bg-chill-beige"> <span class="badge rounded-pill bg-chill-beige">
<i <i
@@ -120,7 +180,6 @@
:person="addr.hostPerson" :person="addr.hostPerson"
/> />
</span> </span>
<address-render-box <address-render-box
v-if="addr.hostPerson.address" v-if="addr.hostPerson.address"
:address="addr.hostPerson.address" :address="addr.hostPerson.address"
@@ -240,7 +299,7 @@
</span> </span>
</template> </template>
<script setup lang="ts"> <script setup>
import { computed } from "vue"; import { computed } from "vue";
import { ISOToDate } from "ChillMainAssets/chill/js/date"; import { ISOToDate } from "ChillMainAssets/chill/js/date";
import AddressRenderBox from "ChillMainAssets/vuejs/_components/Entity/AddressRenderBox.vue"; import AddressRenderBox from "ChillMainAssets/vuejs/_components/Entity/AddressRenderBox.vue";
@@ -252,69 +311,108 @@ import {
trans, trans,
RENDERBOX_HOLDER, RENDERBOX_HOLDER,
RENDERBOX_NO_DATA, RENDERBOX_NO_DATA,
RENDERBOX_DEATHDATE_STATEMENT, RENDERBOX_DEATHDATE,
RENDERBOX_HOUSEHOLD_WITHOUT_ADDRESS, RENDERBOX_HOUSEHOLD_WITHOUT_ADDRESS,
RENDERBOX_RESIDENTIAL_ADDRESS, RENDERBOX_RESIDENTIAL_ADDRESS,
RENDERBOX_LOCATED_AT, RENDERBOX_LOCATED_AT,
RENDERBOX_BIRTHDAY_STATEMENT, RENDERBOX_BIRTHDAY_MAN,
// PERSONS_ASSOCIATED_SHOW_HOUSEHOLD_NUMBER, RENDERBOX_BIRTHDAY_WOMAN,
RENDERBOX_BIRTHDAY_UNKNOWN,
RENDERBOX_BIRTHDAY_NEUTRAL,
PERSONS_ASSOCIATED_SHOW_HOUSEHOLD_NUMBER,
RENDERBOX_YEARS_OLD, RENDERBOX_YEARS_OLD,
} from "translator"; } from "translator";
import {Person} from "ChillPersonAssets/types";
import {toGenderTranslation} from "ChillMainAssets/lib/api/genderHelper";
interface RenderOptions { const props = defineProps({
addInfo?: boolean; person: {
addEntity?: boolean; required: true,
addAltNames?: boolean; },
addAge?: boolean; options: {
addId?: boolean; type: Object,
addLink?: boolean; required: false,
hLevel?: number; },
entityDisplayLong?: boolean; render: {
addCenter?: boolean; type: String,
addNoData?: boolean; },
isMultiline?: boolean; returnPath: {
isHolder?: boolean; type: String,
addHouseholdLink?: boolean; },
showResidentialAddresses: {
type: Boolean,
default: false,
},
});
const birthdateTranslation = computed(() => {
if (props.person.gender) {
const { genderTranslation } = props.person.gender;
switch (genderTranslation) {
case "man":
return RENDERBOX_BIRTHDAY_MAN;
case "woman":
return RENDERBOX_BIRTHDAY_WOMAN;
case "neutral":
return RENDERBOX_BIRTHDAY_NEUTRAL;
case "unknown":
return RENDERBOX_BIRTHDAY_UNKNOWN;
default:
return RENDERBOX_BIRTHDAY_UNKNOWN;
} }
} else {
interface Props { return RENDERBOX_BIRTHDAY_UNKNOWN;
person: Person;
options?: RenderOptions;
render?: "bloc" | "badge";
returnPath?: string;
showResidentialAddresses?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
render: "bloc", options: {
addInfo: true,
addEntity: false,
addAltNames: true,
addAge: true,
addId: true,
addLink: false,
hLevel: 3,
entityDisplayingLong: true,
addCenter: true,
addNoData: true,
isMultiline: true,
isHolder: false,
addHouseholdLink: true
} }
}); });
const isMultiline = computed<boolean>(() => { const isMultiline = computed(() => {
return props.options?.isMultiline || false; return props.options?.isMultiline || false;
}); });
const getUrl = computed<string>(() => { const birthdate = computed(() => {
if (
props.person.birthdate !== null &&
props.person.birthdate !== undefined &&
props.person.birthdate.datetime
) {
return ISOToDate(props.person.birthdate.datetime);
} else {
return "";
}
});
const deathdate = computed(() => {
if (
props.person.deathdate !== null &&
props.person.deathdate !== undefined &&
props.person.deathdate.datetime
) {
return new Date(props.person.deathdate.datetime);
} else {
return "";
}
});
const altNameLabel = computed(() => {
let altNameLabel = "";
(props.person.altNames || []).forEach(
(altName) => (altNameLabel += altName.label),
);
return altNameLabel;
});
const altNameKey = computed(() => {
let altNameKey = "";
(props.person.altNames || []).forEach(
(altName) => (altNameKey += altName.key),
);
return altNameKey;
});
const getUrl = computed(() => {
return `/fr/person/${props.person.id}/general`; return `/fr/person/${props.person.id}/general`;
}); });
const getCurrentHouseholdUrl = computed<string>(() => { const getCurrentHouseholdUrl = computed(() => {
const returnPath = props.returnPath ? `?returnPath=${props.returnPath}` : ``; let returnPath = props.returnPath ? `?returnPath=${props.returnPath}` : ``;
return `/fr/person/household/${props.person.current_household_id}/summary${returnPath}`; return `/fr/person/household/${props.person.current_household_id}/summary${returnPath}`;
}); });
</script> </script>

View File

@@ -1,7 +1,13 @@
<template> <template>
<span v-if="isCut">{{ cutText }}</span> <span v-if="isCut">{{ cutText }}</span>
<span v-else class="person-text"> <span v-else class="person-text">
<span>{{ person.text }}</span> <span class="firstname">{{ person.firstName }}</span>
<span class="lastname">&nbsp;{{ person.lastName }}</span>
<span v-if="person.altNames && person.altNames.length > 0" class="altnames">
<span :class="'altname altname-' + altNameKey"
>&nbsp;({{ altNameLabel }})</span
>
</span>
<span v-if="person.suffixText" class="suffixtext" <span v-if="person.suffixText" class="suffixtext"
>&nbsp;{{ person.suffixText }}</span >&nbsp;{{ person.suffixText }}</span
> >
@@ -27,6 +33,16 @@ const props = defineProps<{
const { person, isCut = false, addAge = true } = toRefs(props); 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("");
});
const altNameKey = computed(() => {
if (!person.value.altNames) return "";
return person.value.altNames.map((a: AltName) => a.key).join("");
});
const cutText = computed(() => { const cutText = computed(() => {
if (!person.value.text) return ""; if (!person.value.text) return "";
const more = person.value.text.length > 15 ? "…" : ""; const more = person.value.text.length > 15 ? "…" : "";

View File

@@ -1,5 +1,5 @@
<template> <template>
<div v-if="action === 'show' && person !== null"> <div v-if="action === 'show'">
<div class="flex-table"> <div class="flex-table">
<person-render-box <person-render-box
render="bloc" render="bloc"
@@ -22,48 +22,445 @@
</div> </div>
<div v-else-if="action === 'edit' || action === 'create'"> <div v-else-if="action === 'edit' || action === 'create'">
<PersonEdit <div class="form-floating mb-3">
:id="props.id" <input
:type="props.type" class="form-control form-control-lg"
:action="props.action" id="lastname"
:query="props.query" 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>
<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>
</div>
</template> </template>
<script setup lang="ts"> <script setup>
import { ref, onMounted } from "vue"; import { ref, reactive, computed, onMounted } from "vue";
import { getPerson } from "../../_api/OnTheFly"; import {
getCentersForPersonCreation,
getCivilities,
getGenders,
getPerson,
getPersonAltNames,
} from "../../_api/OnTheFly";
import PersonRenderBox from "../Entity/PersonRenderBox.vue"; import PersonRenderBox from "../Entity/PersonRenderBox.vue";
import PersonEdit from "./PersonEdit.vue"; import AddAddress from "ChillMainAssets/vuejs/Address/components/AddAddress.vue";
import type { Person } from "ChillPersonAssets/types"; 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";
interface Props { const props = defineProps({
id: string | number; id: [String, Number],
type?: string; type: String,
action: "show" | "edit" | "create"; action: String,
query?: string; query: String,
}
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;
}); });
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é");
}
}
function loadData() {
getPerson(props.id).then((p) => {
Object.assign(person, 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(() => { onMounted(() => {
if (props.action !== "create") { getPersonAltNames().then((altNames) => {
loadData(); 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> </script>

View File

@@ -1,707 +0,0 @@
<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,15 +88,6 @@
<div data-suggest-container="{{ altName.vars.full_name|e('html_attr') }}" class="col-sm-8" style="margin-left: auto;"></div> <div data-suggest-container="{{ altName.vars.full_name|e('html_attr') }}" class="col-sm-8" style="margin-left: auto;"></div>
{% endfor %} {% endfor %}
{% endif %} {% 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 }) }} {{ form_row(form.gender, { 'label' : 'Gender'|trans }) }}

View File

@@ -1,155 +0,0 @@
<?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,33 +11,169 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Serializer\Normalizer; 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\Phonenumber\PhoneNumberHelperInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderExtension; use Chill\MainBundle\Templating\Entity\ChillEntityRenderExtension;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonAltName; use Chill\PersonBundle\Entity\PersonAltName;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Repository\ResidentialAddressRepository; use Chill\PersonBundle\Repository\ResidentialAddressRepository;
use Doctrine\Common\Collections\Collection; 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\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait;
/** /**
* Serialize a Person entity. * Serialize a Person entity.
*/ */
class PersonJsonNormalizer implements NormalizerAwareInterface, NormalizerInterface class PersonJsonNormalizer implements DenormalizerAwareInterface, NormalizerAwareInterface, PersonJsonNormalizerInterface
{ {
use DenormalizerAwareTrait;
use NormalizerAwareTrait; use NormalizerAwareTrait;
use ObjectToPopulateTrait;
public function __construct( public function __construct(
private readonly ChillEntityRenderExtension $render, 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 CenterResolverManagerInterface $centerResolverManager,
private readonly ResidentialAddressRepository $residentialAddressRepository, private readonly ResidentialAddressRepository $residentialAddressRepository,
private readonly PhoneNumberHelperInterface $phoneNumberHelper, private readonly PhoneNumberHelperInterface $phoneNumberHelper,
private readonly \Chill\PersonBundle\PersonIdentifier\Rendering\PersonIdRenderingInterface $personIdRendering,
) {} ) {}
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 Person $person
* @param string|null $format * @param string|null $format
@@ -68,7 +204,6 @@ class PersonJsonNormalizer implements NormalizerAwareInterface, NormalizerInterf
'email' => $person->getEmail(), 'email' => $person->getEmail(),
'gender' => $this->normalizer->normalize($person->getGender(), $format, $context), 'gender' => $this->normalizer->normalize($person->getGender(), $format, $context),
'civility' => $this->normalizer->normalize($person->getCivility(), $format, $context), 'civility' => $this->normalizer->normalize($person->getCivility(), $format, $context),
'personId' => $this->personIdRendering->renderPersonId($person),
]; ];
if (\in_array('minimal', $groups, true) && 1 === \count($groups)) { if (\in_array('minimal', $groups, true) && 1 === \count($groups)) {
@@ -80,6 +215,11 @@ class PersonJsonNormalizer implements NormalizerAwareInterface, NormalizerInterf
null]; null];
} }
public function supportsDenormalization($data, $type, $format = null)
{
return Person::class === $type && 'person' === ($data['type'] ?? null);
}
public function supportsNormalization($data, $format = null): bool public function supportsNormalization($data, $format = null): bool
{ {
return $data instanceof Person && 'json' === $format; return $data instanceof Person && 'json' === $format;

View File

@@ -1,51 +0,0 @@
<?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

@@ -1,145 +0,0 @@
<?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\PersonIdentifier;
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;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* @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(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
{
return '';
}
public function validate(ExecutionContextInterface $context, PersonIdentifier $identifier, PersonIdentifierDefinition $definition): void {}
};
$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

@@ -1,111 +0,0 @@
<?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\Identifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Chill\PersonBundle\PersonIdentifier\Identifier\StringIdentifier;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @coversNothing
*/
class StringIdentifierValidationTest extends TestCase
{
private function makeDefinition(array $data = []): PersonIdentifierDefinition
{
$definition = new PersonIdentifierDefinition(label: ['en' => 'Test'], engine: StringIdentifier::NAME);
if ([] !== $data) {
$definition->setData($data);
}
return $definition;
}
private function makeIdentifier(PersonIdentifierDefinition $definition, ?string $content): PersonIdentifier
{
$identifier = new PersonIdentifier($definition);
$identifier->setValue(['content' => $content]);
return $identifier;
}
public function testValidateWithoutOptionsHasNoViolations(): void
{
$definition = $this->makeDefinition();
$identifier = $this->makeIdentifier($definition, 'AB-123');
$engine = new StringIdentifier();
$violations = $engine->validate($identifier, $definition);
self::assertIsArray($violations);
self::assertCount(0, $violations);
}
public function testValidateOnlyNumbersOption(): void
{
$definition = $this->makeDefinition(['only_numbers' => true]);
$engine = new StringIdentifier();
// valid numeric content
$identifierOk = $this->makeIdentifier($definition, '123456');
$violationsOk = $engine->validate($identifierOk, $definition);
self::assertCount(0, $violationsOk);
// invalid alphanumeric content
$identifierBad = $this->makeIdentifier($definition, '12AB');
$violationsBad = $engine->validate($identifierBad, $definition);
self::assertCount(1, $violationsBad);
self::assertSame('person_identifier.only_number', $violationsBad[0]->message);
self::assertSame('2a3352c0-a2b9-11f0-a767-b7a3f80e52f1', $violationsBad[0]->code);
}
public function testValidateFixedLengthOption(): void
{
$definition = $this->makeDefinition(['fixed_length' => 5]);
$engine = new StringIdentifier();
// valid exact length
$identifierOk = $this->makeIdentifier($definition, 'ABCDE');
$violationsOk = $engine->validate($identifierOk, $definition);
self::assertCount(0, $violationsOk);
// invalid length (too short)
$identifierBad = $this->makeIdentifier($definition, 'AB');
$violationsBad = $engine->validate($identifierBad, $definition);
self::assertCount(1, $violationsBad);
self::assertSame('person_identifier.fixed_length', $violationsBad[0]->message);
self::assertSame('2b02a8fe-a2b9-11f0-bfe5-033300972783', $violationsBad[0]->code);
self::assertSame(['limit' => '5'], $violationsBad[0]->parameters);
}
public function testValidateOnlyNumbersAndFixedLengthTogether(): void
{
$definition = $this->makeDefinition(['only_numbers' => true, 'fixed_length' => 4]);
$engine = new StringIdentifier();
// valid: numeric and correct length
$identifierOk = $this->makeIdentifier($definition, '1234');
$violationsOk = $engine->validate($identifierOk, $definition);
self::assertCount(0, $violationsOk);
// invalid: non-numeric and wrong length -> two violations expected
$identifierBad = $this->makeIdentifier($definition, 'AB');
$violationsBad = $engine->validate($identifierBad, $definition);
self::assertCount(2, $violationsBad);
// Order is defined by implementation: numbers check first, then length
self::assertSame('person_identifier.only_number', $violationsBad[0]->message);
self::assertSame('person_identifier.fixed_length', $violationsBad[1]->message);
}
}

View File

@@ -1,105 +0,0 @@
<?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\PersonIdentifier;
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;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* @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(?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(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
{
return '';
}
public function validate(ExecutionContextInterface $context, PersonIdentifier $identifier, PersonIdentifierDefinition $definition): void {}
};
$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

@@ -24,7 +24,6 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/** /**
* @internal * @internal
@@ -72,8 +71,6 @@ class PersonIdRenderingTest extends TestCase
// same behavior as StringIdentifier::renderAsString // same behavior as StringIdentifier::renderAsString
return $identifier?->getValue()['content'] ?? ''; return $identifier?->getValue()['content'] ?? '';
} }
public function validate(ExecutionContextInterface $context, PersonIdentifier $identifier, PersonIdentifierDefinition $definition): void {}
}; };
return new PersonIdentifierWorker($engine, $definition); return new PersonIdentifierWorker($engine, $definition);

View File

@@ -1,131 +0,0 @@
<?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

@@ -1,158 +0,0 @@
<?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->findByDefinitionAndCanonical($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->findByDefinitionAndCanonical($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();
}
public function testViolationWhenDuplicateFoundButForSamePerson(): 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');
$dup1 = new PersonIdentifier($definition);
$dup1->setPerson($personA);
$dup1->setValue(['value' => '123']);
// Repository returns duplicates
$this->repository->findByDefinitionAndCanonical($definition, ['value' => '123'])->willReturn([$dup1]);
$identifier = new PersonIdentifier($definition);
$identifier->setPerson($personA);
$identifier->setValue(['value' => '123']);
$constraint = new UniqueIdentifierConstraint();
$this->validator->validate($identifier, $constraint);
$this->assertNoViolation();
}
}

View File

@@ -1,110 +0,0 @@
<?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\PersonIdentifier\IdentifierViolationDTO;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
use Chill\PersonBundle\PersonIdentifier\Validator\ValidIdentifierConstraint;
use Chill\PersonBundle\PersonIdentifier\Validator\ValidIdentifierConstraintValidator;
use PHPUnit\Framework\Attributes\CoversClass;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* @internal
*/
#[CoversClass(ValidIdentifierConstraintValidator::class)]
final class ValidIdentifierConstraintValidatorTest extends ConstraintValidatorTestCase
{
use ProphecyTrait;
/**
* @var ObjectProphecy|PersonIdentifierManagerInterface
*/
private ObjectProphecy $manager;
protected function setUp(): void
{
$this->manager = $this->prophesize(PersonIdentifierManagerInterface::class);
parent::setUp();
}
protected function createValidator(): ValidIdentifierConstraintValidator
{
return new ValidIdentifierConstraintValidator($this->manager->reveal());
}
public function testAddsViolationFromWorker(): void
{
$definition = new PersonIdentifierDefinition(['en' => 'SSN'], 'string');
// set definition id via reflection for definition_id parameter
$ref = new \ReflectionClass($definition);
$prop = $ref->getProperty('id');
$prop->setValue($definition, 1);
$identifier = new PersonIdentifier($definition);
$identifier->setValue(['value' => 'bad']);
$violation = new IdentifierViolationDTO('Invalid Identifier', '0000-1111-2222-3333', ['{{ foo }}' => 'bar']);
// engine that returns one violation
$engine = new class ([$violation]) implements PersonIdentifierEngineInterface {
public function __construct(private array $violations) {}
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(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
{
return '';
}
public function isEmpty(PersonIdentifier $identifier): bool
{
return false;
}
public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array
{
return $this->violations;
}
};
$worker = new PersonIdentifierWorker($engine, $definition);
$this->manager
->buildWorkerByPersonIdentifierDefinition($definition)
->willReturn($worker);
$constraint = new ValidIdentifierConstraint();
$this->validator->validate($identifier, $constraint);
$this->buildViolation('Invalid Identifier')
->setParameters(['{{ foo }}' => 'bar'])
->setParameter('{{ code }}', '0000-1111-2222-3333')
->setParameter('definition_id', '1')
->assertRaised();
}
}

View File

@@ -1,76 +0,0 @@
<?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\PersonIdentifier\Identifier\StringIdentifier;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Chill\PersonBundle\Repository\Identifier\PersonIdentifierRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class PersonIdentifierRepositoryTest extends KernelTestCase
{
public function testFindByDefinitionAndCanonical(): void
{
self::bootKernel();
$container = self::getContainer();
/** @var PersonIdentifierManagerInterface $personIdentifierManager */
$personIdentifierManager = $container->get(PersonIdentifierManagerInterface::class);
/** @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'], StringIdentifier::NAME);
$em->persist($definition);
$em->flush();
// Create an identifier attached to the person
$value = ['content' => 'ABC-'.bin2hex(random_bytes(4))];
$identifier = new PersonIdentifier($definition);
$identifier->setPerson($person);
$identifier->setValue($value);
$identifier->setCanonical($personIdentifierManager->buildWorkerByPersonIdentifierDefinition($definition)->canonicalizeValue($identifier->getValue()));
$em->persist($identifier);
$em->flush();
// Use the repository to find by definition and value
/** @var PersonIdentifierRepository $repo */
$repo = $container->get(PersonIdentifierRepository::class);
$results = $repo->findByDefinitionAndCanonical($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,7 +18,6 @@ use Chill\MainBundle\Repository\CountryRepository;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonPhone; use Chill\PersonBundle\Entity\PersonPhone;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Chill\PersonBundle\Repository\PersonACLAwareRepository; use Chill\PersonBundle\Repository\PersonACLAwareRepository;
use Chill\PersonBundle\Security\Authorization\PersonVoter; use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -43,8 +42,6 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase
private EntityManagerInterface $entityManager; private EntityManagerInterface $entityManager;
private PersonIdentifierManagerInterface $personIdentifierManager;
protected function setUp(): void protected function setUp(): void
{ {
self::bootKernel(); self::bootKernel();
@@ -52,8 +49,6 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class); $this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
$this->countryRepository = self::getContainer()->get(CountryRepository::class); $this->countryRepository = self::getContainer()->get(CountryRepository::class);
$this->centerRepository = self::getContainer()->get(CenterRepositoryInterface::class); $this->centerRepository = self::getContainer()->get(CenterRepositoryInterface::class);
$this->personIdentifierManager = self::getContainer()->get(PersonIdentifierManagerInterface::class);
} }
public function testCountByCriteria() public function testCountByCriteria()
@@ -71,8 +66,7 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase
$security->reveal(), $security->reveal(),
$this->entityManager, $this->entityManager,
$this->countryRepository, $this->countryRepository,
$authorizationHelper->reveal(), $authorizationHelper->reveal()
$this->personIdentifierManager,
); );
$number = $repository->countBySearchCriteria('diallo'); $number = $repository->countBySearchCriteria('diallo');
@@ -95,8 +89,7 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase
$security->reveal(), $security->reveal(),
$this->entityManager, $this->entityManager,
$this->countryRepository, $this->countryRepository,
$authorizationHelper->reveal(), $authorizationHelper->reveal()
$this->personIdentifierManager,
); );
$results = $repository->findBySearchCriteria(0, 5, false, 'diallo'); $results = $repository->findBySearchCriteria(0, 5, false, 'diallo');
@@ -127,8 +120,7 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase
$security->reveal(), $security->reveal(),
$this->entityManager, $this->entityManager,
$this->countryRepository, $this->countryRepository,
$authorizationHelper->reveal(), $authorizationHelper->reveal()
$this->personIdentifierManager,
); );
$actual = $repository->findByPhone($phoneNumber, 0, 10); $actual = $repository->findByPhone($phoneNumber, 0, 10);

View File

@@ -1,305 +0,0 @@
<?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;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* @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;
}
public function validate(ExecutionContextInterface $context, PersonIdentifier $identifier, PersonIdentifierDefinition $definition): void {}
};
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

@@ -1,63 +0,0 @@
<?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 Serializer\Normalizer;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\PersonRepository;
use PHPUnit\Framework\Assert;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Serializer\SerializerInterface;
/**
* @internal
*
* @coversNothing
*/
final class PersonJsonNormalizerIntegrationTest extends KernelTestCase
{
public function testNormalizeExistingPersonFromDatabase(): void
{
self::bootKernel();
$container = self::getContainer();
/** @var PersonRepository $repo */
$repo = $container->get(PersonRepository::class);
$person = $repo->findOneBy([]);
if (!$person instanceof Person) {
self::markTestSkipped('No person found in test database. Load fixtures to enable this test.');
}
/** @var SerializerInterface $serializer */
$serializer = $container->get(SerializerInterface::class);
// Should not throw
$data = $serializer->normalize($person, 'json');
Assert::assertIsArray($data);
// Spot check some expected keys exist
foreach ([
'type', 'id', 'text', 'textAge', 'firstName', 'lastName', 'birthdate', 'age', 'gender', 'civility',
] as $key) {
Assert::assertArrayHasKey($key, $data, sprintf('Expected key %s in normalized payload', $key));
}
// Minimal group should also work
$minimal = $serializer->normalize($person, 'json', ['groups' => 'minimal']);
Assert::assertIsArray($minimal);
foreach ([
'type', 'id', 'text', 'textAge', 'firstName', 'lastName',
] as $key) {
Assert::assertArrayHasKey($key, $minimal, sprintf('Expected key %s in minimal normalized payload', $key));
}
}
}

View File

@@ -11,186 +11,74 @@ declare(strict_types=1);
namespace Serializer\Normalizer; namespace Serializer\Normalizer;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Phonenumber\PhoneNumberHelperInterface; use Chill\MainBundle\Phonenumber\PhoneNumberHelperInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderExtension; use Chill\MainBundle\Templating\Entity\ChillEntityRenderExtension;
use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonAltName; use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Repository\ResidentialAddressRepository; use Chill\PersonBundle\Repository\ResidentialAddressRepository;
use Chill\PersonBundle\Serializer\Normalizer\PersonJsonNormalizer; use Chill\PersonBundle\Serializer\Normalizer\PersonJsonNormalizer;
use Chill\PersonBundle\PersonIdentifier\Rendering\PersonIdRenderingInterface;
use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/** /**
* @internal * @internal
* *
* @covers \Chill\PersonBundle\Serializer\Normalizer\PersonJsonNormalizer * @coversNothing
*/ */
final class PersonJsonNormalizerTest extends TestCase final class PersonJsonNormalizerTest extends KernelTestCase
{ {
use ProphecyTrait; use ProphecyTrait;
public function testSupportsNormalization(): void private PersonJsonNormalizer $normalizer;
protected function setUp(): void
{ {
$normalizer = $this->createNormalizer(); self::bootKernel();
self::assertTrue($normalizer->supportsNormalization(new Person(), 'json')); $residentialAddressRepository = $this->prophesize(ResidentialAddressRepository::class);
self::assertFalse($normalizer->supportsNormalization(new \stdClass(), 'json')); $residentialAddressRepository
self::assertFalse($normalizer->supportsNormalization(new Person(), 'xml')); ->findCurrentResidentialAddressByPerson(Argument::type(Person::class), Argument::any())
} ->willReturn([]);
public function testNormalizeWithMinimalGroupReturnsOnlyBaseKeys(): void $this->normalizer = $this->buildPersonJsonNormalizer(
{ self::getContainer()->get(ChillEntityRenderExtension::class),
$person = $this->createSamplePerson(); self::getContainer()->get(PersonRepository::class),
self::getContainer()->get(CenterResolverManagerInterface::class),
$normalizer = $this->createNormalizer(); $residentialAddressRepository->reveal(),
$data = $normalizer->normalize($person, 'json', [AbstractNormalizer::GROUPS => 'minimal']); self::getContainer()->get(PhoneNumberHelperInterface::class),
self::getContainer()->get(NormalizerInterface::class)
// Expected base keys
$expectedKeys = [
'type',
'id',
'text',
'textAge',
'firstName',
'lastName',
'current_household_address',
'birthdate',
'deathdate',
'age',
'phonenumber',
'mobilenumber',
'email',
'gender',
'civility',
'personId',
];
foreach ($expectedKeys as $key) {
self::assertArrayHasKey($key, $data, sprintf('Key %s should be present', $key));
}
self::assertSame('PERSON-ID-RENDER', $data['personId']);
// Ensure extended keys are not present in minimal mode
foreach (['centers', 'altNames', 'current_household_id', 'current_residential_addresses'] as $key) {
self::assertArrayNotHasKey($key, $data, sprintf('Key %s should NOT be present in minimal group', $key));
}
}
public function testNormalizeWithoutGroupsIncludesExtendedKeys(): void
{
$person = $this->createSamplePerson(withAltNames: true);
$center1 = (new Center())->setName('c1');
$center2 = (new Center())->setName('c2');
$normalizer = $this->createNormalizer(
centers: [$center1, $center2],
currentResidentialAddresses: [['addr' => 1]],
); );
$data = $normalizer->normalize($person, 'json');
// Base keys
$baseKeys = [
'type', 'id', 'text', 'textAge', 'firstName', 'lastName', 'current_household_address', 'birthdate', 'deathdate', 'age', 'phonenumber', 'mobilenumber', 'email', 'gender', 'civility', 'personId',
];
foreach ($baseKeys as $key) {
self::assertArrayHasKey($key, $data, sprintf('Key %s should be present', $key));
} }
// Extended keys public function testNormalization()
foreach (['centers', 'altNames', 'current_household_id', 'current_residential_addresses'] as $key) {
self::assertArrayHasKey($key, $data, sprintf('Key %s should be present', $key));
}
self::assertSame(['c1', 'c2'], $data['centers']);
self::assertIsArray($data['altNames']);
self::assertSame([['key' => 'aka', 'label' => 'Johnny']], $data['altNames']);
self::assertNull($data['current_household_id'], 'No household set so id should be null');
self::assertSame([['addr' => 1]], $data['current_residential_addresses']);
}
private function createNormalizer(array $centers = [], array $currentResidentialAddresses = []): PersonJsonNormalizer
{ {
$render = $this->prophesize(ChillEntityRenderExtension::class); $person = new Person();
$render->renderString(Argument::type(Person::class), ['addAge' => false])->willReturn('John Doe'); $result = $this->normalizer->normalize($person, 'json', [AbstractNormalizer::GROUPS => ['read']]);
$render->renderString(Argument::type(Person::class), ['addAge' => true])->willReturn('John Doe (25)');
$centerResolver = $this->prophesize(CenterResolverManagerInterface::class); $this->assertIsArray($result);
$centerResolver->resolveCenters(Argument::type(Person::class))->willReturn($centers); }
$raRepo = $this->prophesize(ResidentialAddressRepository::class); private function buildPersonJsonNormalizer(
$raRepo->findCurrentResidentialAddressByPerson(Argument::type(Person::class))->willReturn($currentResidentialAddresses); ChillEntityRenderExtension $render,
PersonRepository $repository,
$phoneHelper = $this->prophesize(PhoneNumberHelperInterface::class); CenterResolverManagerInterface $centerResolverManager,
ResidentialAddressRepository $residentialAddressRepository,
$personIdRendering = $this->prophesize(PersonIdRenderingInterface::class); PhoneNumberHelperInterface $phoneNumberHelper,
$personIdRendering->renderPersonId(Argument::type(Person::class))->willReturn('PERSON-ID-RENDER'); NormalizerInterface $normalizer,
): PersonJsonNormalizer {
$normalizer = new PersonJsonNormalizer( $personJsonNormalizer = new PersonJsonNormalizer(
$render->reveal(), $render,
$centerResolver->reveal(), $repository,
$raRepo->reveal(), $centerResolverManager,
$phoneHelper->reveal(), $residentialAddressRepository,
$personIdRendering->reveal(), $phoneNumberHelper
); );
$personJsonNormalizer->setNormalizer($normalizer);
// Inner normalizer that echoes values or simple conversions return $personJsonNormalizer;
$inner = new class () implements NormalizerInterface {
public function supportsNormalization($data, $format = null): bool
{
return true;
}
public function normalize($object, $format = null, array $context = [])
{
// For scalars and arrays, return as-is; for objects, return string or id when possible
if (\is_scalar($object) || null === $object) {
return $object;
}
if ($object instanceof \DateTimeInterface) {
return $object->format('Y-m-d');
}
if ($object instanceof Center) {
return $object->getName();
}
if (is_array($object)) {
return array_map(fn ($o) => $this->normalize($o, $format, $context), $object);
}
// default stub
return (string) (method_exists($object, 'getId') ? $object->getId() : 'normalized');
}
};
$normalizer->setNormalizer($inner);
return $normalizer;
}
private function createSamplePerson(bool $withAltNames = false): Person
{
$p = new Person();
$p->setFirstName('John');
$p->setLastName('Doe');
$p->setBirthdate(new \DateTime('2000-01-01'));
$p->setEmail('john@example.test');
if ($withAltNames) {
$alt = new PersonAltName();
$alt->setKey('aka');
$alt->setLabel('Johnny');
$p->setAltNames(new ArrayCollection([$alt]));
}
return $p;
} }
} }

View File

@@ -1,86 +0,0 @@
<?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);
}
}

View File

@@ -1993,16 +1993,3 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
/1.0/person/identifiers/workers:
get:
tags:
- person
summary: List the person identifiers
responses:
200:
description: "OK"
content:
application/json:
schema:
type: object

View File

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

View File

@@ -0,0 +1,11 @@
---
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

@@ -1,37 +0,0 @@
<?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

@@ -1,33 +0,0 @@
<?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, canonical)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX chill_person_identifier_unique');
}
}

View File

@@ -1,53 +0,0 @@
<?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;
/**
* Restrict the deletion of identifier_definition to avoid risk of error.
*
* An identifier definition can only be removed if there aren't any identifier defined.
*/
final class Version20250924101621 extends AbstractMigration
{
public function getDescription(): string
{
return 'Restrict the deletion of identifier_definition';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE chill_person_identifier DROP CONSTRAINT fk_bca5a36bd11ea911
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE chill_person_identifier ADD CONSTRAINT fk_bca5a36bd11ea911
FOREIGN KEY (definition_id) REFERENCES chill_person_identifier_definition (id)
on delete restrict
SQL);
}
public function down(Schema $schema): void
{
$this->addSql(<<<'SQL'
ALTER TABLE chill_person_identifier DROP CONSTRAINT fk_bca5a36bd11ea911
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE chill_person_identifier ADD CONSTRAINT fk_bca5a36bd11ea911
FOREIGN KEY (definition_id) REFERENCES chill_person_identifier_definition (id)
on delete cascade
SQL);
}
}

View File

@@ -1,33 +0,0 @@
<?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 Version20250926124024 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add unique constraint on person_identifier: only one identifier of each kind by person';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE UNIQUE INDEX chill_person_identifier_unique_person_definition ON chill_person_identifier (definition_id, person_id)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX chill_person_identifier_unique_person_definition');
}
}

View File

@@ -265,38 +265,3 @@ add_persons:
title: "Centre" title: "Centre"
error_only_one_person: "Une seule personne peut être sélectionnée !" error_only_one_person: "Une seule personne peut être sélectionnée !"
renderbox:
person: "Usager"
birthday_statement: >-
{gender, select,
man {Né le {birthdate, date}}
woman {Née le {birthdate, date}}
other {Né·e le {birthdate, date}}
}
deathdate_statement: >-
{gender, select,
man {Décédé le {deathdate, date}}
woman {Décédée le {deathdate, date}}
other {Décédé·e le {deathdate, date}}
}
household_without_address: "Le ménage de l'usager est sans adresse"
no_data: "Aucune information renseignée"
type:
thirdparty: "Tiers"
person: "Usager"
holder: "Titulaire"
years_old: >-
{n, plural,
=0 {0 an}
one {1 an}
other {# ans}
}
residential_address: "Adresse de résidence"
located_at: "réside chez"
household_number: "Ménage n°{number}"
current_members: "Membres actuels"
no_current_address: "Sans adresse actuellement"
new_household: "Nouveau ménage"
no_members_yet: "Aucun membre actuellement"

View File

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

View File

@@ -1,7 +0,0 @@
person_identifier:
fixed_length: >-
{limit, plural,
=1 {L'identifier doit contenir exactement 1 caractère}
other {L'identifiant doit contenir exactement # caractères}
}
only_number: "L'identifiant ne doit contenir que des chiffres"

View File

@@ -73,9 +73,5 @@ relationship:
person_creation: 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 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: accompanying_course_work:
The endDate should be greater or equal than the start date: La date de fin doit être égale ou supérieure à la date de début The endDate should be greater or equal than the start date: La date de fin doit être égale ou supérieure à la date de début

View File

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

View File

@@ -0,0 +1,29 @@
<?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\TicketBundle\Controller\Admin;
use Chill\MainBundle\CRUD\Controller\CRUDController;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class MotiveController extends CRUDController
{
protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator)
{
/* @var QueryBuilder $query */
$query->addOrderBy('e.id', 'ASC');
return parent::orderQuery($action, $query, $request, $paginator);
}
}

View File

@@ -0,0 +1,31 @@
<?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\TicketBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
/**
* Class AdminController
* Controller for the ticket configuration section (in admin section).
*/
class AdminController extends AbstractController
{
/**
* Ticket admin.
*/
#[Route(path: '/{_locale}/admin/ticket', name: 'chill_ticket_admin_index')]
public function indexAdminAction()
{
return $this->render('@ChillTicket/Admin/index.html.twig');
}
}

View File

@@ -11,8 +11,10 @@ declare(strict_types=1);
namespace Chill\TicketBundle\DependencyInjection; namespace Chill\TicketBundle\DependencyInjection;
use Chill\TicketBundle\Controller\Admin\MotiveController;
use Chill\TicketBundle\Controller\MotiveApiController; use Chill\TicketBundle\Controller\MotiveApiController;
use Chill\TicketBundle\Entity\Motive; use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Form\MotiveType;
use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
@@ -36,6 +38,7 @@ class ChillTicketExtension extends Extension implements PrependExtensionInterfac
public function prepend(ContainerBuilder $container) public function prepend(ContainerBuilder $container)
{ {
$this->prependApi($container); $this->prependApi($container);
$this->prependCruds($container);
} }
private function prependApi(ContainerBuilder $container): void private function prependApi(ContainerBuilder $container): void
@@ -66,4 +69,37 @@ class ChillTicketExtension extends Extension implements PrependExtensionInterfac
], ],
]); ]);
} }
protected function prependCruds(ContainerBuilder $container): void
{
$container->prependExtensionConfig('chill_main', [
'cruds' => [
[
'class' => Motive::class,
'name' => 'motive',
'base_path' => '/admin/ticket/motive',
'form_class' => MotiveType::class,
'controller' => MotiveController::class,
'actions' => [
'index' => [
'template' => '@ChillTicket/Admin/Motive/index.html.twig',
'role' => 'ROLE_ADMIN',
],
'new' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillTicket/Admin/Motive/new.html.twig',
],
'view' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillTicket/Admin/Motive/view.html.twig',
],
'edit' => [
'role' => 'ROLE_ADMIN',
'template' => '@ChillTicket/Admin/Motive/edit.html.twig',
],
],
],
],
]);
}
} }

View File

@@ -0,0 +1,67 @@
<?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\TicketBundle\Form;
use Chill\MainBundle\Form\Type\ChillCollectionType;
use Chill\MainBundle\Form\Type\CommentType;
use Chill\MainBundle\Form\Type\TranslatableStringFormType;
use Chill\TicketBundle\Entity\EmergencyStatusEnum;
use Chill\TicketBundle\Entity\Motive;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class MotiveType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('label', TranslatableStringFormType::class, [
'label' => 'Label',
'required' => true,
])
->add('active', CheckboxType::class, [
'label' => 'Active',
'required' => false,
])
->add('makeTicketEmergency', EnumType::class, [
'class' => EmergencyStatusEnum::class,
'label' => 'emergency?',
'required' => false,
'placeholder' => 'Choose an option...',
])
->add('supplementaryComments', ChillCollectionType::class, [
'entry_type' => TextareaType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => true,
'label' => 'Supplementary comments',
'required' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Motive::class,
]);
}
public function getBlockPrefix(): string
{
return 'chill_ticketbundle_motive';
}
}

View File

@@ -0,0 +1,45 @@
<?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\TicketBundle\Menu;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Knp\Menu\MenuItem;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
class AdminMenuBuilder implements LocalMenuBuilderInterface
{
public function __construct(protected AuthorizationCheckerInterface $authorizationChecker) {}
public function buildMenu($menuId, MenuItem $menu, array $parameters): void
{
if (!$this->authorizationChecker->isGranted('ROLE_ADMIN')) {
return;
}
$menu->addChild('Tickets', [
'route' => 'chill_ticket_admin_index',
])
->setAttribute('class', 'list-group-item-header')
->setExtras([
'order' => 7500,
]);
$menu->addChild('admin.ticket.motive.menu', [
'route' => 'chill_crud_motive_index',
])->setExtras(['order' => 7510]);
}
public static function getMenuIds(): array
{
return ['admin_section', 'admin_ticket'];
}
}

View File

@@ -0,0 +1,15 @@
{% extends '@ChillMain/CRUD/Admin/index.html.twig' %}
{% block title %}
{% include('@ChillMain/CRUD/_edit_title.html.twig') %}
{% endblock %}
{% block js %}
{{ parent() }}
{% endblock %}
{% block admin_content %}
{% embed '@ChillMain/CRUD/_edit_content.html.twig' %}
{% block content_form_actions_save_and_view %}{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -0,0 +1,64 @@
{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %}
{% block title %}{{ 'admin.motive.list.title'|trans }}{% endblock title %}
{% block admin_content %}
<h1>{{ 'admin.motive.list.title'|trans }}</h1>
<table class="records_list table table-bordered border-dark">
<thead>
<tr>
<th>{{ 'Label'|trans }}</th>
<th>{{ 'Active'|trans }}</th>
<th>{{ 'emergency?'|trans }}</th>
<th>{{ 'Supplementary comments'|trans }}</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
{% for entity in entities %}
<tr>
<td>{{ entity.label|localize_translatable_string }}</td>
<td style="text-align:center;">
{%- if entity.isActive -%}
<i class="fa fa-check-square-o"></i>
{%- else -%}
<i class="fa fa-square-o"></i>
{%- endif -%}
</td>
<td style="text-align:center;">
{%- if entity.makeTicketEmergency -%}
{{ entity.makeTicketEmergency.value|trans }}
{%- else -%}
-
{%- endif -%}
</td>
<td style="text-align:center;">
{{ entity.supplementaryComments|length }}
</td>
<td>
<ul class="record_actions">
<li>
<a href="{{ path('chill_crud_motive_view', { 'id': entity.id }) }}" class="btn btn-show" title="{{ 'show'|trans }}"></a>
</li>
<li>
<a href="{{ path('chill_crud_motive_edit', { 'id': entity.id }) }}" class="btn btn-edit" title="{{ 'edit'|trans }}"></a>
</li>
</ul>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ chill_pagination(paginator) }}
<ul class="record_actions sticky-form-buttons">
<li>
<a href="{{ path('chill_crud_motive_new') }}" class="btn btn-create">
{{ 'admin.motive.new.title'|trans }}
</a>
</li>
</ul>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More