mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-09-24 15:44:59 +00:00
Compare commits
31 Commits
ticket-app
...
ticket/64-
Author | SHA1 | Date | |
---|---|---|---|
0fd76d3fa8
|
|||
34af53130b
|
|||
a1fd395868
|
|||
b8a7cbb321
|
|||
6124eb9e34
|
|||
a5b06de92a
|
|||
52404956d2
|
|||
4207efd6bf
|
|||
840fde4ad4
|
|||
3611ea2518
|
|||
bbd4292cb9
|
|||
54f8c92240
|
|||
5330befc8f
|
|||
c19206be0c
|
|||
5ff374d2fa
|
|||
4a73aaae94
|
|||
ff2c567d05
|
|||
a734e84f28
|
|||
4367ed086e
|
|||
3227bfcd3a
|
|||
8d29fb260a
|
|||
bda0743c63
|
|||
d9b730627f
|
|||
27548ad654
|
|||
bec7297039
|
|||
852523e644
|
|||
c05d0aad47
|
|||
1c0ed9abc8
|
|||
9aed5cc216
|
|||
e4fe5bff68
|
|||
4c73c4d9d0
|
@@ -236,12 +236,14 @@ This must be a decision made by a human, not by an AI. Every AI task must abort
|
||||
|
||||
The tests are run from the project's root (not from the bundle's root: so, do not change the directory to any bundle directory before running tests).
|
||||
|
||||
Tests must be run using the `symfony` command:
|
||||
|
||||
```bash
|
||||
# Run a specific test file
|
||||
vendor/bin/phpunit path/to/TestFile.php
|
||||
symfony composer exec phpunit -- path/to/TestFile.php
|
||||
|
||||
# Run a specific test method
|
||||
vendor/bin/phpunit --filter methodName path/to/TestFile.php
|
||||
symfony composer exec phpunit -- --filter methodName path/to/TestFile.php
|
||||
```
|
||||
|
||||
#### Test Structure
|
||||
|
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<location />
|
||||
<location />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Location from "ChillActivityAssets/vuejs/Activity/components/Location.vue";
|
||||
|
||||
export default {
|
||||
name: "App",
|
||||
components: {
|
||||
Location,
|
||||
},
|
||||
name: "App",
|
||||
components: {
|
||||
Location,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@@ -19,7 +19,6 @@ use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Entity\Address;
|
||||
use libphonenumber\PhoneNumber;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
|
||||
|
||||
/**
|
||||
* Immersion.
|
||||
@@ -86,14 +85,14 @@ class Immersion implements \Stringable
|
||||
* @Assert\NotBlank()
|
||||
*/
|
||||
#[ORM\Column(name: 'tuteurPhoneNumber', type: 'phone_number', nullable: true)]
|
||||
#[PhonenumberConstraint(type: 'any')]
|
||||
#[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber]
|
||||
private ?PhoneNumber $tuteurPhoneNumber = null;
|
||||
|
||||
#[ORM\Column(name: 'structureAccName', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)]
|
||||
private ?string $structureAccName = null;
|
||||
|
||||
#[ORM\Column(name: 'structureAccPhonenumber', type: 'phone_number', nullable: true)]
|
||||
#[PhonenumberConstraint(type: 'any')]
|
||||
#[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber]
|
||||
private ?PhoneNumber $structureAccPhonenumber = null;
|
||||
|
||||
#[ORM\Column(name: 'structureAccEmail', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)]
|
||||
|
@@ -14,9 +14,9 @@ namespace Chill\MainBundle\Entity;
|
||||
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
|
||||
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
|
||||
use Chill\MainBundle\Repository\LocationRepository;
|
||||
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use libphonenumber\PhoneNumber;
|
||||
use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as MisdPhoneNumberConstraint;
|
||||
use Symfony\Component\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
|
||||
|
||||
@@ -67,12 +67,12 @@ class Location implements TrackCreationInterface, TrackUpdateInterface
|
||||
|
||||
#[Serializer\Groups(['read', 'write', 'docgen:read'])]
|
||||
#[ORM\Column(type: 'phone_number', nullable: true)]
|
||||
#[PhonenumberConstraint(type: 'any')]
|
||||
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::ANY])]
|
||||
private ?PhoneNumber $phonenumber1 = null;
|
||||
|
||||
#[Serializer\Groups(['read', 'write', 'docgen:read'])]
|
||||
#[ORM\Column(type: 'phone_number', nullable: true)]
|
||||
#[PhonenumberConstraint(type: 'any')]
|
||||
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::ANY])]
|
||||
private ?PhoneNumber $phonenumber2 = null;
|
||||
|
||||
#[Serializer\Groups(['read'])]
|
||||
|
@@ -23,7 +23,6 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Serializer\Annotation as Serializer;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
|
||||
|
||||
/**
|
||||
* User.
|
||||
@@ -116,7 +115,7 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
||||
* The user's mobile phone number.
|
||||
*/
|
||||
#[ORM\Column(type: 'phone_number', nullable: true)]
|
||||
#[PhonenumberConstraint]
|
||||
#[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber]
|
||||
private ?PhoneNumber $phonenumber = null;
|
||||
|
||||
/**
|
||||
|
@@ -31,6 +31,8 @@ interface PhoneNumberHelperInterface
|
||||
|
||||
/**
|
||||
* Return true if the validation is configured and available.
|
||||
*
|
||||
* @deprecated this is an internal behaviour of the helper and should not be taken into account outside of the implementation
|
||||
*/
|
||||
public function isPhonenumberValidationConfigured(): bool;
|
||||
|
||||
|
@@ -122,7 +122,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface
|
||||
*/
|
||||
public function isValidPhonenumberAny($phonenumber): bool
|
||||
{
|
||||
if (false === $this->isPhonenumberValidationConfigured()) {
|
||||
if (false === $this->isConfigured) {
|
||||
return true;
|
||||
}
|
||||
$validation = $this->performTwilioLookup($phonenumber);
|
||||
@@ -142,7 +142,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface
|
||||
*/
|
||||
public function isValidPhonenumberLandOrVoip($phonenumber): bool
|
||||
{
|
||||
if (false === $this->isPhonenumberValidationConfigured()) {
|
||||
if (false === $this->isConfigured) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface
|
||||
*/
|
||||
public function isValidPhonenumberMobile($phonenumber): bool
|
||||
{
|
||||
if (false === $this->isPhonenumberValidationConfigured()) {
|
||||
if (false === $this->isConfigured) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface
|
||||
|
||||
private function performTwilioLookup($phonenumber)
|
||||
{
|
||||
if (false === $this->isPhonenumberValidationConfigured()) {
|
||||
if (false === $this->isConfigured) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@@ -158,3 +158,18 @@ export const intervalISOToDays = (str: string | null): number | null => {
|
||||
|
||||
return days;
|
||||
};
|
||||
|
||||
export function getTimezoneOffsetString(date: Date, timeZone: string): string {
|
||||
const utcDate = new Date(date.toLocaleString("en-US", { timeZone: "UTC" }));
|
||||
const tzDate = new Date(date.toLocaleString("en-US", { timeZone }));
|
||||
const offsetMinutes = (utcDate.getTime() - tzDate.getTime()) / (60 * 1000);
|
||||
|
||||
// Inverser le signe pour avoir la convention ±HH:MM
|
||||
const sign = offsetMinutes <= 0 ? "+" : "-";
|
||||
const absMinutes = Math.abs(offsetMinutes);
|
||||
const hours = String(Math.floor(absMinutes / 60)).padStart(2, "0");
|
||||
const minutes = String(absMinutes % 60).padStart(2, "0");
|
||||
|
||||
return `${sign}${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
|
@@ -1,8 +1,14 @@
|
||||
import { Scope } from "../../types";
|
||||
import {
|
||||
DynamicKeys,
|
||||
Scope,
|
||||
ValidationExceptionInterface,
|
||||
ValidationProblemFromMap,
|
||||
ViolationFromMap
|
||||
} from "../../types";
|
||||
|
||||
export type body = Record<string, boolean | string | number | null>;
|
||||
export type fetchOption = Record<string, boolean | string | number | null>;
|
||||
|
||||
export type Primitive = string | number | boolean | null;
|
||||
export type Params = Record<string, number | string>;
|
||||
|
||||
export interface Pagination {
|
||||
@@ -25,20 +31,115 @@ export interface TransportExceptionInterface {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ValidationExceptionInterface
|
||||
extends TransportExceptionInterface {
|
||||
name: "ValidationException";
|
||||
error: object;
|
||||
violations: string[];
|
||||
titles: string[];
|
||||
propertyPaths: string[];
|
||||
export class ValidationException<
|
||||
M extends Record<string, Record<string, string|number>> = Record<
|
||||
string,
|
||||
Record<string, string|number>
|
||||
>,
|
||||
>
|
||||
extends Error
|
||||
implements ValidationExceptionInterface<M>
|
||||
{
|
||||
public readonly name = "ValidationException" as const;
|
||||
public readonly problems: ValidationProblemFromMap<M>;
|
||||
public readonly violations: string[];
|
||||
public readonly violationsList: ViolationFromMap<M>[];
|
||||
public readonly titles: string[];
|
||||
public readonly propertyPaths: DynamicKeys<M> & string[];
|
||||
public readonly byProperty: Record<Extract<keyof M, string>, string[]>;
|
||||
|
||||
constructor(problem: ValidationProblemFromMap<M>) {
|
||||
const message = [problem.title, problem.detail].filter(Boolean).join(" — ");
|
||||
super(message);
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
|
||||
this.problems = problem;
|
||||
|
||||
this.violationsList = problem.violations;
|
||||
this.violations = problem.violations.map(
|
||||
(v) => `${v.title}: ${v.propertyPath}`,
|
||||
);
|
||||
|
||||
this.titles = problem.violations.map((v) => v.title);
|
||||
|
||||
this.propertyPaths = problem.violations.map(
|
||||
(v) => v.propertyPath,
|
||||
) as DynamicKeys<M> & string[];
|
||||
|
||||
this.byProperty = problem.violations.reduce(
|
||||
(acc, v) => {
|
||||
const key = v.propertyPath.replace('/\[\d+\]$/', "") as Extract<keyof M, string>;
|
||||
(acc[key] ||= []).push(v.title);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<Extract<keyof M, string>, string[]>,
|
||||
);
|
||||
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, ValidationException);
|
||||
}
|
||||
}
|
||||
|
||||
violationsByNormalizedProperty(property: Extract<keyof M, string>): ViolationFromMap<M>[] {
|
||||
return this.violationsList.filter((v) => v.propertyPath.replace(/\[\d+\]$/, "") === property);
|
||||
}
|
||||
|
||||
violationsByNormalizedPropertyAndParams<
|
||||
P extends Extract<keyof M, string>,
|
||||
K extends Extract<keyof M[P], string>
|
||||
>(
|
||||
property: P,
|
||||
param: K,
|
||||
param_value: M[P][K]
|
||||
): ViolationFromMap<M>[]
|
||||
{
|
||||
const list = this.violationsByNormalizedProperty(property);
|
||||
|
||||
return list.filter(
|
||||
(v): boolean =>
|
||||
!!v.parameters &&
|
||||
// `with_parameter in v.parameters` check indexing
|
||||
param in v.parameters &&
|
||||
// the cast is safe, because we have overloading that bind the types
|
||||
(v.parameters as M[P])[param] === param_value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ValidationErrorResponse extends TransportExceptionInterface {
|
||||
violations: {
|
||||
title: string;
|
||||
propertyPath: string;
|
||||
}[];
|
||||
/**
|
||||
* Check that the exception is a ValidationExceptionInterface
|
||||
* @param x
|
||||
*/
|
||||
export function isValidationException<M extends Record<string, Record<string, string|number>>>(
|
||||
x: unknown,
|
||||
): x is ValidationExceptionInterface<M> {
|
||||
return (
|
||||
x instanceof ValidationException ||
|
||||
(typeof x === "object" &&
|
||||
x !== null &&
|
||||
(x as any).name === "ValidationException")
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidationProblem(x: unknown): x is {
|
||||
type: string;
|
||||
title: string;
|
||||
violations: { propertyPath: string; title: string }[];
|
||||
} {
|
||||
if (!x || typeof x !== "object") return false;
|
||||
const o = x as any;
|
||||
return (
|
||||
typeof o.type === "string" &&
|
||||
typeof o.title === "string" &&
|
||||
Array.isArray(o.violations) &&
|
||||
o.violations.every(
|
||||
(v: any) =>
|
||||
v &&
|
||||
typeof v === "object" &&
|
||||
typeof v.propertyPath === "string" &&
|
||||
typeof v.title === "string",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export interface AccessExceptionInterface extends TransportExceptionInterface {
|
||||
@@ -65,12 +166,151 @@ export interface ConflictHttpExceptionInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic api method that can be adapted to any fetch request
|
||||
* Generic api method that can be adapted to any fetch request.
|
||||
*
|
||||
* This method is suitable make a single fetch. When performing a GET to fetch a list of elements, always consider pagination
|
||||
* and use of the @link{fetchResults} method.
|
||||
* What this does
|
||||
* - Performs a single HTTP request using fetch and returns the parsed JSON as Output.
|
||||
* - Interprets common API errors and throws typed exceptions you can catch in your UI.
|
||||
* - When the server returns a Symfony validation problem (HTTP 422), the error is
|
||||
* rethrown as a typed ValidationException that is aware of your Violation Map (see below).
|
||||
*
|
||||
* Important: For GET endpoints that return lists, prefer using fetchResults, which
|
||||
* handles pagination and aggregation for you.
|
||||
*
|
||||
* Violation Map (M): make your 422 errors strongly typed
|
||||
* ------------------------------------------------------
|
||||
* Symfony’s 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 you’ll 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 don’t care about parameter typing, you can omit M entirely and rely on the
|
||||
* default loose typing (Record<string, Primitive>), but you’ll lose safety.
|
||||
*
|
||||
* Error taxonomy thrown by makeFetch
|
||||
* - ValidationException<M> when status = 422 and payload is a validation problem.
|
||||
* - AccessException when status = 403.
|
||||
* - ConflictHttpException when status = 409.
|
||||
* - A generic error object for other non-ok statuses.
|
||||
*
|
||||
* @typeParam Input - Shape of the request body you send (if any)
|
||||
* @typeParam Output - Shape of the successful JSON response you expect
|
||||
* @typeParam M - Violation Map describing the per-field parameters you expect
|
||||
* in Symfony validation violations. See examples above.
|
||||
*
|
||||
* @param method The HTTP method to use (POST, GET, PUT, PATCH, DELETE)
|
||||
* @param url The absolute or relative URL to call
|
||||
* @param body The request payload. If null/undefined, no body is sent
|
||||
* @param options Extra fetch options/headers merged into the request
|
||||
*
|
||||
* @returns The parsed JSON response typed as Output. For 204 No Content, resolves
|
||||
* with undefined (void).
|
||||
*/
|
||||
export const makeFetch = <Input, Output>(
|
||||
export const makeFetch = async <
|
||||
Input,
|
||||
Output,
|
||||
M extends Record<string, Record<string, string|number>> = Record<
|
||||
string,
|
||||
Record<string, string|number>
|
||||
>,
|
||||
>(
|
||||
method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE",
|
||||
url: string,
|
||||
body?: body | Input | null,
|
||||
@@ -90,7 +330,8 @@ export const makeFetch = <Input, Output>(
|
||||
if (typeof options !== "undefined") {
|
||||
opts = Object.assign(opts, options);
|
||||
}
|
||||
return fetch(url, opts).then((response) => {
|
||||
|
||||
return fetch(url, opts).then(async (response) => {
|
||||
if (response.status === 204) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -100,9 +341,20 @@ export const makeFetch = <Input, Output>(
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
return response.json().then((response) => {
|
||||
throw ValidationException(response);
|
||||
});
|
||||
// Unprocessable Entity -> payload de validation Symfony
|
||||
const json = await response.json().catch(() => undefined);
|
||||
|
||||
if (isValidationProblem(json)) {
|
||||
// On ré-interprète le payload selon M (ParamMap) pour typer les violations
|
||||
const problem = json as unknown as ValidationProblemFromMap<M>;
|
||||
throw new ValidationException<M>(problem);
|
||||
}
|
||||
|
||||
const err = new Error(
|
||||
"Validation failed but payload is not a ValidationProblem",
|
||||
);
|
||||
(err as any).raw = json;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
@@ -167,12 +419,6 @@ function _fetchAction<T>(
|
||||
throw NotFoundException(response);
|
||||
}
|
||||
|
||||
if (response.status === 422) {
|
||||
return response.json().then((response) => {
|
||||
throw ValidationException(response);
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
throw AccessException(response);
|
||||
}
|
||||
@@ -231,24 +477,6 @@ export const fetchScopes = (): Promise<Scope[]> => {
|
||||
return fetchResults("/api/1.0/main/scope.json");
|
||||
};
|
||||
|
||||
/**
|
||||
* Error objects to be thrown
|
||||
*/
|
||||
const ValidationException = (
|
||||
response: ValidationErrorResponse,
|
||||
): ValidationExceptionInterface => {
|
||||
const error = {} as ValidationExceptionInterface;
|
||||
error.name = "ValidationException";
|
||||
error.violations = response.violations.map(
|
||||
(violation) => `${violation.title}: ${violation.propertyPath}`,
|
||||
);
|
||||
error.titles = response.violations.map((violation) => violation.title);
|
||||
error.propertyPaths = response.violations.map(
|
||||
(violation) => violation.propertyPath,
|
||||
);
|
||||
return error;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const AccessException = (response: Response): AccessExceptionInterface => {
|
||||
const error = {} as AccessExceptionInterface;
|
||||
|
@@ -1,14 +1,51 @@
|
||||
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
|
||||
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
|
||||
import { CreatableEntityType } from "ChillPersonAssets/types";
|
||||
|
||||
export interface DateTime {
|
||||
datetime: string;
|
||||
datetime8601: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A date representation to use when we create or update a date
|
||||
*/
|
||||
export interface DateTimeWrite {
|
||||
/**
|
||||
* Must be a string in format Y-m-d\TH:i:sO
|
||||
*/
|
||||
datetime: string;
|
||||
}
|
||||
|
||||
export interface Civility {
|
||||
type: "chill_main_civility";
|
||||
id: number;
|
||||
abbreviation: TranslatableString;
|
||||
active: boolean;
|
||||
name: TranslatableString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight reference to Civility, to use in POST or PUT requests.
|
||||
*/
|
||||
export interface SetCivility {
|
||||
type: "chill_main_civility";
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface Gender {
|
||||
type: "chill_main_gender";
|
||||
id: number;
|
||||
label: string;
|
||||
genderTranslation: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight reference to a Gender, used in POST / PUT requests.
|
||||
*/
|
||||
export interface SetGender {
|
||||
type: "chill_main_gender";
|
||||
id: number;
|
||||
// TODO
|
||||
}
|
||||
|
||||
export interface Household {
|
||||
@@ -28,6 +65,18 @@ export interface Center {
|
||||
id: number;
|
||||
type: "center";
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SetCenter is a lightweight reference used in POST/PUT requests to associate an existing center with a resource.
|
||||
* It links by id only and does not create or modify centers.
|
||||
* Expected shape: { type: "center", id: number }.
|
||||
* Requests will fail if the id is invalid, the center doesn't exist, or permissions are insufficient.
|
||||
*/
|
||||
export interface SetCenter {
|
||||
id: number;
|
||||
type: "center";
|
||||
}
|
||||
|
||||
export interface Scope {
|
||||
@@ -226,13 +275,63 @@ export interface TransportExceptionInterface {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ValidationExceptionInterface
|
||||
extends TransportExceptionInterface {
|
||||
type IndexedKey<Base extends string> = `${Base}[${number}]`;
|
||||
type BaseKeys<M> = Extract<keyof M, string>;
|
||||
|
||||
export type DynamicKeys<M extends Record<string, Record<string, unknown>>> =
|
||||
| BaseKeys<M>
|
||||
| { [K in BaseKeys<M> as IndexedKey<K>]: K }[IndexedKey<BaseKeys<M>>];
|
||||
|
||||
type NormalizeKey<K extends string> = K extends `${infer B}[${number}]` ? B : K;
|
||||
|
||||
export type ViolationFromMap<M extends Record<string, Record<string, unknown>>> = {
|
||||
[K in DynamicKeys<M> & string]: { // <- note le "& string" ici
|
||||
propertyPath: K;
|
||||
title: string;
|
||||
parameters?: M[NormalizeKey<K>];
|
||||
type?: string;
|
||||
}
|
||||
}[DynamicKeys<M> & string];
|
||||
|
||||
export type ValidationProblemFromMap<
|
||||
M extends Record<string, Record<string, string|number>>,
|
||||
> = {
|
||||
type: string;
|
||||
title: string;
|
||||
detail?: string;
|
||||
violations: ViolationFromMap<M>[];
|
||||
} & Record<string, unknown>;
|
||||
|
||||
export interface ValidationExceptionInterface<
|
||||
M extends Record<string, Record<string, string|number>> = Record<
|
||||
string,
|
||||
Record<string, string|number>
|
||||
>,
|
||||
> extends Error {
|
||||
name: "ValidationException";
|
||||
error: object;
|
||||
/** Full server payload copy */
|
||||
problems: ValidationProblemFromMap<M>;
|
||||
/** A list of all violations, with property key */
|
||||
violationsList: ViolationFromMap<M>[];
|
||||
/** Compact list "Title: path" */
|
||||
violations: string[];
|
||||
/** Only titles */
|
||||
titles: string[];
|
||||
propertyPaths: string[];
|
||||
/** Only property paths */
|
||||
propertyPaths: DynamicKeys<M> & string[];
|
||||
/** Indexing by property (useful for display by field) */
|
||||
byProperty: Record<Extract<keyof M, string>, string[]>;
|
||||
|
||||
violationsByNormalizedProperty(property: Extract<keyof M, string>): ViolationFromMap<M>[];
|
||||
|
||||
violationsByNormalizedPropertyAndParams<
|
||||
P extends Extract<keyof M, string>,
|
||||
K extends Extract<keyof M[P], string>
|
||||
>(
|
||||
property: P,
|
||||
param: K,
|
||||
param_value: M[P][K]
|
||||
): ViolationFromMap<M>[];
|
||||
}
|
||||
|
||||
export interface AccessExceptionInterface extends TransportExceptionInterface {
|
||||
@@ -300,3 +399,12 @@ export interface TabDefinition {
|
||||
icon: string | null;
|
||||
counter: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the CreateModal and Create component
|
||||
*/
|
||||
export interface CreateComponentConfig {
|
||||
action?: string;
|
||||
allowedTypes: CreatableEntityType[];
|
||||
query?: string;
|
||||
}
|
||||
|
@@ -12,24 +12,22 @@
|
||||
ref="showAddress"
|
||||
/>
|
||||
|
||||
<!-- step 1 -->
|
||||
<teleport to="body" v-if="inModal">
|
||||
<modal
|
||||
v-if="flag.suggestPane"
|
||||
modal-dialog-class="modal-dialog-scrollable modal-xl"
|
||||
@close="resetPane"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="modal-title">
|
||||
{{ trans(getTextTitle) }}
|
||||
<span v-if="flag.loading" class="loading">
|
||||
<i class="fa fa-circle-o-notch fa-spin fa-fw" />
|
||||
<span class="sr-only">{{
|
||||
trans(ADDRESS_LOADING)
|
||||
}}</span>
|
||||
</span>
|
||||
</h2>
|
||||
</template>
|
||||
<!-- step 1 -->
|
||||
<teleport to="body" v-if="inModal">
|
||||
<modal
|
||||
v-if="flag.suggestPane"
|
||||
modal-dialog-class="modal-dialog-scrollable modal-xl"
|
||||
@close="resetPane"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="modal-title">
|
||||
{{ trans(getTextTitle) }}
|
||||
<span v-if="flag.loading" class="loading">
|
||||
<i class="fa fa-circle-o-notch fa-spin fa-fw" />
|
||||
<span class="sr-only">{{ trans(ADDRESS_LOADING) }}</span>
|
||||
</span>
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<suggest-pane
|
||||
@@ -90,9 +88,7 @@
|
||||
{{ trans(getTextTitle) }}
|
||||
<span v-if="flag.loading" class="loading">
|
||||
<i class="fa fa-circle-o-notch fa-spin fa-fw" />
|
||||
<span class="sr-only">{{
|
||||
trans(ADDRESS_LOADING)
|
||||
}}</span>
|
||||
<span class="sr-only">{{ trans(ADDRESS_LOADING) }}</span>
|
||||
</span>
|
||||
</h2>
|
||||
</template>
|
||||
@@ -175,9 +171,7 @@
|
||||
{{ trans(getTextTitle) }}
|
||||
<span v-if="flag.loading" class="loading">
|
||||
<i class="fa fa-circle-o-notch fa-spin fa-fw" />
|
||||
<span class="sr-only">{{
|
||||
trans(ADDRESS_LOADING)
|
||||
}}</span>
|
||||
<span class="sr-only">{{ trans(ADDRESS_LOADING) }}</span>
|
||||
</span>
|
||||
</h2>
|
||||
</template>
|
||||
@@ -248,14 +242,14 @@ import {
|
||||
} from "../api";
|
||||
import {
|
||||
CREATE_A_NEW_ADDRESS,
|
||||
ADDRESS_LOADING,
|
||||
ACTIVITY_CREATE_ADDRESS,
|
||||
ACTIVITY_EDIT_ADDRESS,
|
||||
ADDRESS_LOADING,
|
||||
ACTIVITY_CREATE_ADDRESS,
|
||||
ACTIVITY_EDIT_ADDRESS,
|
||||
CANCEL,
|
||||
SAVE,
|
||||
PREVIOUS,
|
||||
NEXT,
|
||||
trans,
|
||||
SAVE,
|
||||
PREVIOUS,
|
||||
NEXT,
|
||||
trans,
|
||||
} from "translator";
|
||||
import ShowPane from "./ShowPane.vue";
|
||||
import SuggestPane from "./SuggestPane.vue";
|
||||
@@ -265,16 +259,17 @@ import DatePane from "./DatePane.vue";
|
||||
export default {
|
||||
name: "AddAddress",
|
||||
setup() {
|
||||
return {
|
||||
trans,
|
||||
CREATE_A_NEW_ADDRESS,
|
||||
ADDRESS_LOADING,
|
||||
CANCEL,
|
||||
SAVE,
|
||||
PREVIOUS,
|
||||
NEXT,
|
||||
};
|
||||
},props: ["context", "options", "addressChangedCallback"],
|
||||
return {
|
||||
trans,
|
||||
CREATE_A_NEW_ADDRESS,
|
||||
ADDRESS_LOADING,
|
||||
CANCEL,
|
||||
SAVE,
|
||||
PREVIOUS,
|
||||
NEXT,
|
||||
};
|
||||
},
|
||||
props: ["context", "options", "addressChangedCallback"],
|
||||
components: {
|
||||
Modal,
|
||||
ShowPane,
|
||||
@@ -394,9 +389,9 @@ export default {
|
||||
) {
|
||||
console.log("this.options.title", this.options.title);
|
||||
|
||||
return this.context.edit
|
||||
? ACTIVITY_EDIT_ADDRESS
|
||||
: ACTIVITY_CREATE_ADDRESS;
|
||||
return this.context.edit
|
||||
? ACTIVITY_EDIT_ADDRESS
|
||||
: ACTIVITY_CREATE_ADDRESS;
|
||||
}
|
||||
return this.context.edit
|
||||
? this.defaultz.title.edit
|
||||
|
@@ -55,9 +55,7 @@
|
||||
:placeholder="trans(ADDRESS_BUILDING_NAME)"
|
||||
v-model="buildingName"
|
||||
/>
|
||||
<label for="buildingName">{{
|
||||
trans(ADDRESS_BUILDING_NAME)
|
||||
}}</label>
|
||||
<label for="buildingName">{{ trans(ADDRESS_BUILDING_NAME) }}</label>
|
||||
</div>
|
||||
<div class="form-floating my-1">
|
||||
<input
|
||||
@@ -79,9 +77,7 @@
|
||||
:placeholder="trans(ADDRESS_DISTRIBUTION)"
|
||||
v-model="distribution"
|
||||
/>
|
||||
<label for="distribution">{{
|
||||
trans(ADDRESS_DISTRIBUTION)
|
||||
}}</label>
|
||||
<label for="distribution">{{ trans(ADDRESS_DISTRIBUTION) }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,35 +85,36 @@
|
||||
|
||||
<script>
|
||||
import {
|
||||
ADDRESS_STREET,
|
||||
ADDRESS_STREET_NUMBER,
|
||||
ADDRESS_FLOOR,
|
||||
ADDRESS_CORRIDOR,
|
||||
ADDRESS_STEPS,
|
||||
ADDRESS_FLAT,
|
||||
ADDRESS_BUILDING_NAME,
|
||||
ADDRESS_DISTRIBUTION,
|
||||
ADDRESS_EXTRA,
|
||||
ADDRESS_FILL_AN_ADDRESS,
|
||||
trans,
|
||||
ADDRESS_STREET,
|
||||
ADDRESS_STREET_NUMBER,
|
||||
ADDRESS_FLOOR,
|
||||
ADDRESS_CORRIDOR,
|
||||
ADDRESS_STEPS,
|
||||
ADDRESS_FLAT,
|
||||
ADDRESS_BUILDING_NAME,
|
||||
ADDRESS_DISTRIBUTION,
|
||||
ADDRESS_EXTRA,
|
||||
ADDRESS_FILL_AN_ADDRESS,
|
||||
trans,
|
||||
} from "translator";
|
||||
export default {
|
||||
name: "AddressMore",
|
||||
setup() {
|
||||
return {
|
||||
ADDRESS_STREET,
|
||||
ADDRESS_STREET_NUMBER,
|
||||
ADDRESS_FLOOR,
|
||||
ADDRESS_CORRIDOR,
|
||||
ADDRESS_STEPS,
|
||||
ADDRESS_FLAT,
|
||||
ADDRESS_BUILDING_NAME,
|
||||
ADDRESS_DISTRIBUTION,
|
||||
ADDRESS_EXTRA,
|
||||
ADDRESS_FILL_AN_ADDRESS,
|
||||
trans,
|
||||
};
|
||||
},props: ["entity", "isNoAddress"],
|
||||
return {
|
||||
ADDRESS_STREET,
|
||||
ADDRESS_STREET_NUMBER,
|
||||
ADDRESS_FLOOR,
|
||||
ADDRESS_CORRIDOR,
|
||||
ADDRESS_STEPS,
|
||||
ADDRESS_FLAT,
|
||||
ADDRESS_BUILDING_NAME,
|
||||
ADDRESS_DISTRIBUTION,
|
||||
ADDRESS_EXTRA,
|
||||
ADDRESS_FILL_AN_ADDRESS,
|
||||
trans,
|
||||
};
|
||||
},
|
||||
props: ["entity", "isNoAddress"],
|
||||
computed: {
|
||||
floor: {
|
||||
set(value) {
|
||||
|
@@ -57,9 +57,7 @@
|
||||
:placeholder="trans(ADDRESS_STREET_NUMBER)"
|
||||
v-model="streetNumber"
|
||||
/>
|
||||
<label for="streetNumber">{{
|
||||
trans(ADDRESS_STREET_NUMBER)
|
||||
}}</label>
|
||||
<label for="streetNumber">{{ trans(ADDRESS_STREET_NUMBER) }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,31 +70,32 @@ import {
|
||||
fetchReferenceAddresses,
|
||||
} from "../../api.js";
|
||||
import {
|
||||
ADDRESS_STREET,
|
||||
ADDRESS_STREET_NUMBER,
|
||||
ADDRESS_ADDRESS,
|
||||
MULTISELECT_SELECTED_LABEL,
|
||||
MULTISELECT_SELECT_LABEL,
|
||||
ADDRESS_SELECT_ADDRESS,
|
||||
ADDRESS_CREATE_ADDRESS,
|
||||
trans,
|
||||
ADDRESS_STREET,
|
||||
ADDRESS_STREET_NUMBER,
|
||||
ADDRESS_ADDRESS,
|
||||
MULTISELECT_SELECTED_LABEL,
|
||||
MULTISELECT_SELECT_LABEL,
|
||||
ADDRESS_SELECT_ADDRESS,
|
||||
ADDRESS_CREATE_ADDRESS,
|
||||
trans,
|
||||
} from "translator";
|
||||
|
||||
export default {
|
||||
name: "AddressSelection",
|
||||
components: { VueMultiselect },
|
||||
setup() {
|
||||
return {
|
||||
ADDRESS_STREET,
|
||||
ADDRESS_STREET_NUMBER,
|
||||
ADDRESS_ADDRESS,
|
||||
MULTISELECT_SELECTED_LABEL,
|
||||
MULTISELECT_SELECT_LABEL,
|
||||
ADDRESS_SELECT_ADDRESS,
|
||||
ADDRESS_CREATE_ADDRESS,
|
||||
trans,
|
||||
};
|
||||
},props: ["entity", "context", "updateMapCenter", "flag", "checkErrors"],
|
||||
return {
|
||||
ADDRESS_STREET,
|
||||
ADDRESS_STREET_NUMBER,
|
||||
ADDRESS_ADDRESS,
|
||||
MULTISELECT_SELECTED_LABEL,
|
||||
MULTISELECT_SELECT_LABEL,
|
||||
ADDRESS_SELECT_ADDRESS,
|
||||
ADDRESS_CREATE_ADDRESS,
|
||||
trans,
|
||||
};
|
||||
},
|
||||
props: ["entity", "context", "updateMapCenter", "flag", "checkErrors"],
|
||||
data() {
|
||||
return {
|
||||
value: this.context.edit ? this.entity.address.addressReference : null,
|
||||
|
@@ -61,31 +61,32 @@
|
||||
import VueMultiselect from "vue-multiselect";
|
||||
import { searchCities, fetchCities } from "../../api.js";
|
||||
import {
|
||||
MULTISELECT_SELECTED_LABEL,
|
||||
MULTISELECT_SELECT_LABEL,
|
||||
ADDRESS_POSTAL_CODE_CODE,
|
||||
ADDRESS_POSTAL_CODE_NAME,
|
||||
ADDRESS_CREATE_POSTAL_CODE,
|
||||
ADDRESS_CITY,
|
||||
ADDRESS_SELECT_CITY,
|
||||
trans,
|
||||
MULTISELECT_SELECTED_LABEL,
|
||||
MULTISELECT_SELECT_LABEL,
|
||||
ADDRESS_POSTAL_CODE_CODE,
|
||||
ADDRESS_POSTAL_CODE_NAME,
|
||||
ADDRESS_CREATE_POSTAL_CODE,
|
||||
ADDRESS_CITY,
|
||||
ADDRESS_SELECT_CITY,
|
||||
trans,
|
||||
} from "translator";
|
||||
|
||||
export default {
|
||||
name: "CitySelection",
|
||||
components: { VueMultiselect },
|
||||
setup() {
|
||||
return {
|
||||
MULTISELECT_SELECTED_LABEL,
|
||||
MULTISELECT_SELECT_LABEL,
|
||||
ADDRESS_CITY,
|
||||
ADDRESS_SELECT_CITY,
|
||||
ADDRESS_POSTAL_CODE_CODE,
|
||||
ADDRESS_POSTAL_CODE_NAME,
|
||||
ADDRESS_CREATE_POSTAL_CODE,
|
||||
trans,
|
||||
};
|
||||
},props: [
|
||||
return {
|
||||
MULTISELECT_SELECTED_LABEL,
|
||||
MULTISELECT_SELECT_LABEL,
|
||||
ADDRESS_CITY,
|
||||
ADDRESS_SELECT_CITY,
|
||||
ADDRESS_POSTAL_CODE_CODE,
|
||||
ADDRESS_POSTAL_CODE_NAME,
|
||||
ADDRESS_CREATE_POSTAL_CODE,
|
||||
trans,
|
||||
};
|
||||
},
|
||||
props: [
|
||||
"entity",
|
||||
"context",
|
||||
"focusOnAddress",
|
||||
|
@@ -24,27 +24,28 @@
|
||||
import VueMultiselect from "vue-multiselect";
|
||||
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
|
||||
import {
|
||||
MULTISELECT_SELECTED_LABEL,
|
||||
MULTISELECT_SELECT_LABEL,
|
||||
MULTISELECT_DESELECT_LABEL,
|
||||
ADDRESS_COUNTRY,
|
||||
ADDRESS_SELECT_COUNTRY,
|
||||
trans,
|
||||
MULTISELECT_SELECTED_LABEL,
|
||||
MULTISELECT_SELECT_LABEL,
|
||||
MULTISELECT_DESELECT_LABEL,
|
||||
ADDRESS_COUNTRY,
|
||||
ADDRESS_SELECT_COUNTRY,
|
||||
trans,
|
||||
} from "translator";
|
||||
|
||||
export default {
|
||||
name: "CountrySelection",
|
||||
components: { VueMultiselect },
|
||||
setup() {
|
||||
return {
|
||||
MULTISELECT_SELECTED_LABEL,
|
||||
MULTISELECT_SELECT_LABEL,
|
||||
MULTISELECT_DESELECT_LABEL,
|
||||
ADDRESS_COUNTRY,
|
||||
ADDRESS_SELECT_COUNTRY,
|
||||
trans,
|
||||
};
|
||||
},props: ["context", "entity", "flag", "checkErrors"],
|
||||
return {
|
||||
MULTISELECT_SELECTED_LABEL,
|
||||
MULTISELECT_SELECT_LABEL,
|
||||
MULTISELECT_DESELECT_LABEL,
|
||||
ADDRESS_COUNTRY,
|
||||
ADDRESS_SELECT_COUNTRY,
|
||||
trans,
|
||||
};
|
||||
},
|
||||
props: ["context", "entity", "flag", "checkErrors"],
|
||||
emits: ["getCities"],
|
||||
data() {
|
||||
return {
|
||||
|
@@ -106,10 +106,10 @@ import AddressMap from "./AddAddress/AddressMap";
|
||||
import AddressMore from "./AddAddress/AddressMore";
|
||||
import ActionButtons from "./ActionButtons.vue";
|
||||
import {
|
||||
ADDRESS_SELECT_AN_ADDRESS_TITLE,
|
||||
ADDRESS_IS_CONFIDENTIAL,
|
||||
ADDRESS_IS_NO_ADDRESS,
|
||||
trans,
|
||||
ADDRESS_SELECT_AN_ADDRESS_TITLE,
|
||||
ADDRESS_IS_CONFIDENTIAL,
|
||||
ADDRESS_IS_NO_ADDRESS,
|
||||
trans,
|
||||
} from "translator";
|
||||
|
||||
export default {
|
||||
@@ -123,13 +123,14 @@ export default {
|
||||
ActionButtons,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
trans,
|
||||
ADDRESS_SELECT_AN_ADDRESS_TITLE,
|
||||
ADDRESS_IS_CONFIDENTIAL,
|
||||
ADDRESS_IS_NO_ADDRESS,
|
||||
};
|
||||
},props: [
|
||||
return {
|
||||
trans,
|
||||
ADDRESS_SELECT_AN_ADDRESS_TITLE,
|
||||
ADDRESS_IS_CONFIDENTIAL,
|
||||
ADDRESS_IS_NO_ADDRESS,
|
||||
};
|
||||
},
|
||||
props: [
|
||||
"context",
|
||||
"options",
|
||||
"defaultz",
|
||||
|
@@ -11,9 +11,7 @@
|
||||
|
||||
<div v-if="flag.success" class="alert alert-success">
|
||||
{{ trans(getSuccessText) }}
|
||||
<span v-if="forceRedirect">{{
|
||||
trans(ADDRESS_WAIT_REDIRECTION)
|
||||
}}</span>
|
||||
<span v-if="forceRedirect">{{ trans(ADDRESS_WAIT_REDIRECTION) }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -101,34 +99,36 @@
|
||||
import AddressRenderBox from "ChillMainAssets/vuejs/_components/Entity/AddressRenderBox.vue";
|
||||
import ActionButtons from "./ActionButtons.vue";
|
||||
import {
|
||||
ACTIVITY_CREATE_ADDRESS,
|
||||
ACTIVITY_EDIT_ADDRESS,
|
||||
ADDRESS_NOT_YET_ADDRESS,
|
||||
ADDRESS_WAIT_REDIRECTION,
|
||||
ADDRESS_LOADING,
|
||||
ADDRESS_ADDRESS_EDIT_SUCCESS,
|
||||
ADDRESS_ADDRESS_NEW_SUCCESS,
|
||||
trans,
|
||||
ACTIVITY_CREATE_ADDRESS,
|
||||
ACTIVITY_EDIT_ADDRESS,
|
||||
ADDRESS_NOT_YET_ADDRESS,
|
||||
ADDRESS_WAIT_REDIRECTION,
|
||||
ADDRESS_LOADING,
|
||||
ADDRESS_ADDRESS_EDIT_SUCCESS,
|
||||
ADDRESS_ADDRESS_NEW_SUCCESS,
|
||||
trans,
|
||||
} from "translator";
|
||||
|
||||
export default {
|
||||
name: "ShowPane",
|
||||
methods: {},components: {
|
||||
methods: {},
|
||||
components: {
|
||||
AddressRenderBox,
|
||||
ActionButtons,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
trans,
|
||||
ACTIVITY_CREATE_ADDRESS,
|
||||
ACTIVITY_EDIT_ADDRESS,
|
||||
ADDRESS_NOT_YET_ADDRESS,
|
||||
ADDRESS_WAIT_REDIRECTION,
|
||||
ADDRESS_LOADING,
|
||||
ADDRESS_ADDRESS_NEW_SUCCESS,
|
||||
ADDRESS_ADDRESS_EDIT_SUCCESS,
|
||||
};
|
||||
},props: [
|
||||
return {
|
||||
trans,
|
||||
ACTIVITY_CREATE_ADDRESS,
|
||||
ACTIVITY_EDIT_ADDRESS,
|
||||
ADDRESS_NOT_YET_ADDRESS,
|
||||
ADDRESS_WAIT_REDIRECTION,
|
||||
ADDRESS_LOADING,
|
||||
ADDRESS_ADDRESS_NEW_SUCCESS,
|
||||
ADDRESS_ADDRESS_EDIT_SUCCESS,
|
||||
};
|
||||
},
|
||||
props: [
|
||||
"context",
|
||||
"defaultz",
|
||||
"options",
|
||||
@@ -169,17 +169,19 @@ export default {
|
||||
this.options.button.text.create !== null)
|
||||
) {
|
||||
// console.log('this.options.button.text', this.options.button.text)
|
||||
return this.context.edit
|
||||
? ACTIVITY_CREATE_ADDRESS
|
||||
: ACTIVITY_EDIT_ADDRESS;
|
||||
}
|
||||
console.log("defaultz", this.defaultz);
|
||||
return this.context.edit
|
||||
? ACTIVITY_CREATE_ADDRESS
|
||||
: ACTIVITY_EDIT_ADDRESS;
|
||||
}
|
||||
console.log("defaultz", this.defaultz);
|
||||
return this.context.edit
|
||||
? this.defaultz.button.text.edit
|
||||
: this.defaultz.button.text.create;
|
||||
},
|
||||
getSuccessText() {
|
||||
return this.context.edit ? ADDRESS_ADDRESS_EDIT_SUCCESS : ADDRESS_ADDRESS_NEW_SUCCESS;
|
||||
return this.context.edit
|
||||
? ADDRESS_ADDRESS_EDIT_SUCCESS
|
||||
: ADDRESS_ADDRESS_NEW_SUCCESS;
|
||||
},
|
||||
onlyButton() {
|
||||
return typeof this.options.onlyButton !== "undefined"
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<ul class="nav nav-tabs">
|
||||
<li v-if="allowedTypes.includes('person')" class="nav-item">
|
||||
<li v-if="containsPerson" class="nav-item">
|
||||
<a class="nav-link" :class="{ active: isActive('person') }">
|
||||
<label for="person">
|
||||
<input
|
||||
@@ -14,7 +14,7 @@
|
||||
</label>
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="allowedTypes.includes('thirdparty')" class="nav-item">
|
||||
<li v-if="containsThirdParty" class="nav-item">
|
||||
<a class="nav-link" :class="{ active: isActive('thirdparty') }">
|
||||
<label for="thirdparty">
|
||||
<input
|
||||
@@ -31,11 +31,12 @@
|
||||
</ul>
|
||||
|
||||
<div class="my-4">
|
||||
<on-the-fly-person
|
||||
<PersonEdit
|
||||
v-if="type === 'person'"
|
||||
:action="action"
|
||||
action="create"
|
||||
:query="query"
|
||||
ref="castPerson"
|
||||
@onPersonCreated="(payload) => emit('onPersonCreated', payload)"
|
||||
/>
|
||||
|
||||
<on-the-fly-thirdparty
|
||||
@@ -46,34 +47,47 @@
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import OnTheFlyPerson from "ChillPersonAssets/vuejs/_components/OnTheFly/Person.vue";
|
||||
import OnTheFlyThirdparty from "ChillThirdPartyAssets/vuejs/_components/OnTheFly/ThirdParty.vue";
|
||||
import {
|
||||
trans,
|
||||
ONTHEFLY_CREATE_PERSON,
|
||||
ONTHEFLY_CREATE_THIRDPARTY,
|
||||
trans,
|
||||
} from "translator";
|
||||
import { CreatableEntityType, Person } from "ChillPersonAssets/types";
|
||||
import { CreateComponentConfig } from "ChillMainAssets/types";
|
||||
import PersonEdit from "ChillPersonAssets/vuejs/_components/OnTheFly/PersonEdit.vue";
|
||||
|
||||
const props = defineProps({
|
||||
action: String,
|
||||
allowedTypes: Array,
|
||||
query: String,
|
||||
const props = withDefaults(defineProps<CreateComponentConfig>(), {
|
||||
allowedTypes: ["person"],
|
||||
action: "create",
|
||||
query: "",
|
||||
});
|
||||
|
||||
const type = ref(null);
|
||||
const emit =
|
||||
defineEmits<(e: "onPersonCreated", payload: { person: Person }) => void>();
|
||||
|
||||
const radioType = computed({
|
||||
const type = ref<CreatableEntityType | null>(null);
|
||||
|
||||
const radioType = computed<CreatableEntityType | null>({
|
||||
get: () => type.value,
|
||||
set: (val) => {
|
||||
set: (val: CreatableEntityType | null) => {
|
||||
type.value = val;
|
||||
console.log("## type:", val, ", action:", props.action);
|
||||
},
|
||||
});
|
||||
|
||||
const castPerson = ref(null);
|
||||
const castThirdparty = ref(null);
|
||||
type PersonEditComponent = InstanceType<typeof PersonEdit>;
|
||||
|
||||
type AnyComponentInstance =
|
||||
| InstanceType<typeof OnTheFlyPerson>
|
||||
| InstanceType<typeof OnTheFlyThirdparty>
|
||||
| null;
|
||||
|
||||
const castPerson = ref<PersonEditComponent>(null);
|
||||
const castThirdparty = ref<AnyComponentInstance>(null);
|
||||
|
||||
onMounted(() => {
|
||||
type.value =
|
||||
@@ -82,30 +96,22 @@ onMounted(() => {
|
||||
: "person";
|
||||
});
|
||||
|
||||
function isActive(tab) {
|
||||
function isActive(tab: CreatableEntityType) {
|
||||
return type.value === tab;
|
||||
}
|
||||
|
||||
function castDataByType() {
|
||||
switch (radioType.value) {
|
||||
case "person":
|
||||
return castPerson.value.$data.person;
|
||||
case "thirdparty":
|
||||
let data = castThirdparty.value.$data.thirdparty;
|
||||
if (data.address !== undefined && data.address !== null) {
|
||||
data.address = { id: data.address.address_id };
|
||||
} else {
|
||||
data.address = null;
|
||||
}
|
||||
return data;
|
||||
default:
|
||||
throw Error("Invalid type of entity");
|
||||
}
|
||||
const containsThirdParty = computed<boolean>(() =>
|
||||
props.allowedTypes.includes("thirdparty"),
|
||||
);
|
||||
const containsPerson = computed<boolean>(() => {
|
||||
return props.allowedTypes.includes("person");
|
||||
});
|
||||
|
||||
function save(): void {
|
||||
castPerson.value.postPerson();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
castDataByType,
|
||||
});
|
||||
defineExpose({ save });
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
|
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
|
||||
import Create from "ChillMainAssets/vuejs/OnTheFly/components/Create.vue";
|
||||
import { CreateComponentConfig } from "ChillMainAssets/types";
|
||||
import { trans, SAVE } from "translator";
|
||||
import { useTemplateRef } from "vue";
|
||||
import { Person } from "ChillPersonAssets/types";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "onPersonCreated", payload: { person: Person }): void;
|
||||
(e: "close"): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<CreateComponentConfig>();
|
||||
const modalDialogClass = { "modal-xl": true, "modal-scrollable": true };
|
||||
|
||||
type CreateComponentType = InstanceType<typeof Create>;
|
||||
|
||||
const create = useTemplateRef<CreateComponentType>("create");
|
||||
|
||||
function save(): void {
|
||||
console.log("save from CreateModal");
|
||||
create.value?.save();
|
||||
}
|
||||
|
||||
defineExpose({ save });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<modal
|
||||
@close="() => emit('close')"
|
||||
:modal-dialog-class="modalDialogClass"
|
||||
:hide-footer="false"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="modal-title">{{ modalTitle }}</h3>
|
||||
</template>
|
||||
<template #body-head>
|
||||
<div class="modal-body">
|
||||
<Create
|
||||
ref="create"
|
||||
:allowedTypes="props.allowedTypes"
|
||||
:action="props.action"
|
||||
:query="props.query"
|
||||
@onPersonCreated="(payload) => emit('onPersonCreated', payload)"
|
||||
></Create>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button class="btn btn-save" type="button" @click.prevent="save">
|
||||
{{ trans(SAVE) }}
|
||||
</button>
|
||||
</template>
|
||||
</modal>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
@@ -40,6 +40,7 @@
|
||||
:key="uniqid"
|
||||
:buttonTitle="translatedListOfTypes"
|
||||
:modalTitle="translatedListOfTypes"
|
||||
:allowCreate="true"
|
||||
@addNewPersons="addNewEntity"
|
||||
>
|
||||
</add-persons>
|
||||
@@ -76,6 +77,7 @@ import {
|
||||
EntitiesOrMe,
|
||||
EntityType,
|
||||
SearchOptions,
|
||||
Suggestion,
|
||||
} from "ChillPersonAssets/types";
|
||||
import {
|
||||
PICK_ENTITY_MODAL_TITLE,
|
||||
@@ -182,7 +184,7 @@ function addNewSuggested(entity: EntitiesOrMe) {
|
||||
emits("addNewEntity", { entity });
|
||||
}
|
||||
|
||||
function addNewEntity({ selected }: addNewEntities) {
|
||||
function addNewEntity({ selected }: { selected: Suggestion[] }) {
|
||||
Object.values(selected).forEach((item) => {
|
||||
emits("addNewEntity", { entity: item.result });
|
||||
});
|
||||
|
@@ -52,23 +52,15 @@ import { trans, MODAL_ACTION_CLOSE } from "translator";
|
||||
import { defineProps } from "vue";
|
||||
|
||||
export interface ModalProps {
|
||||
modalDialogClass: string;
|
||||
hideFooter: boolean;
|
||||
modalDialogClass?: string | Record<string, boolean>;
|
||||
hideFooter?: boolean;
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
defineProps({
|
||||
modalDialogClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
hideFooter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
const props = withDefaults(defineProps<ModalProps>(), {
|
||||
modalDialogClass: "",
|
||||
hideFooter: false,
|
||||
show: true,
|
||||
});
|
||||
|
||||
const emits = defineEmits<{
|
||||
|
@@ -22,7 +22,7 @@ class DateNormalizer implements ContextAwareNormalizerInterface, DenormalizerInt
|
||||
{
|
||||
public function __construct(private readonly RequestStack $requestStack, private readonly ParameterBagInterface $parameterBag) {}
|
||||
|
||||
public function denormalize($data, $type, $format = null, array $context = [])
|
||||
public function denormalize($data, $type, $format = null, array $context = []): ?\DateTimeInterface
|
||||
{
|
||||
if (null === $data) {
|
||||
return null;
|
||||
@@ -51,7 +51,7 @@ class DateNormalizer implements ContextAwareNormalizerInterface, DenormalizerInt
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function normalize($date, $format = null, array $context = [])
|
||||
public function normalize($date, $format = null, array $context = []): array
|
||||
{
|
||||
/* @var DateTimeInterface $date */
|
||||
switch ($format) {
|
||||
|
@@ -46,7 +46,10 @@ class PhonenumberNormalizer implements ContextAwareNormalizerInterface, Denormal
|
||||
try {
|
||||
return $this->phoneNumberUtil->parse($data, $this->defaultCarrierCode);
|
||||
} catch (NumberParseException $e) {
|
||||
throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
|
||||
$phonenumber = new PhoneNumber();
|
||||
$phonenumber->setRawInput($data);
|
||||
|
||||
return $phonenumber;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -13,6 +13,9 @@ namespace Chill\MainBundle\Validation\Constraint;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
/**
|
||||
* @deprecated use odolbeau/phonenumber validator instead
|
||||
*/
|
||||
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)]
|
||||
class PhonenumberConstraint extends Constraint
|
||||
{
|
||||
|
@@ -16,6 +16,9 @@ use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
|
||||
/**
|
||||
* @deprecated use https://github.com/odolbeau/phone-number-bundle/blob/master/src/Validator/Constraints/PhoneNumberValidator.php instead
|
||||
*/
|
||||
final class ValidPhonenumber extends ConstraintValidator
|
||||
{
|
||||
public function __construct(private readonly LoggerInterface $logger, private readonly PhoneNumberHelperInterface $phonenumberHelper) {}
|
||||
|
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Controller;
|
||||
|
||||
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
|
||||
use Chill\MainBundle\Serializer\Model\Collection;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
final readonly class PersonIdentifierListApiController
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private SerializerInterface $serializer,
|
||||
private PersonIdentifierManagerInterface $personIdentifierManager,
|
||||
private PaginatorFactoryInterface $paginatorFactory,
|
||||
) {}
|
||||
|
||||
#[Route('/api/1.0/person/identifiers/workers', name: 'person_person_identifiers_worker_list')]
|
||||
public function list(): Response
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
|
||||
$workers = $this->personIdentifierManager->getWorkers();
|
||||
$paginator = $this->paginatorFactory->create(count($workers));
|
||||
$paginator->setItemsPerPage(count($workers));
|
||||
$collection = new Collection($workers, $paginator);
|
||||
|
||||
return new JsonResponse($this->serializer->serialize($collection, 'json'), json: true);
|
||||
}
|
||||
}
|
@@ -96,7 +96,6 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
|
||||
// We can get rid of this file when the service 'chill.person.repository.person' is no more used.
|
||||
// We should use the PersonRepository service instead of a custom service name.
|
||||
$loader->load('services/repository.yaml');
|
||||
$loader->load('services/serializer.yaml');
|
||||
$loader->load('services/security.yaml');
|
||||
$loader->load('services/doctrineEventListener.yaml');
|
||||
$loader->load('services/accompanyingPeriodConsistency.yaml');
|
||||
|
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Entity\Identifier;
|
||||
|
||||
enum IdentifierPresenceEnum: string
|
||||
{
|
||||
/**
|
||||
* The person identifier is not editable by any user.
|
||||
*
|
||||
* The identifier is intended to be added by an import script, for instance.
|
||||
*/
|
||||
case NOT_EDITABLE = 'NOT_EDITABLE';
|
||||
|
||||
/**
|
||||
* The person identifier is present on the edit form only.
|
||||
*/
|
||||
case ON_EDIT = 'ON_EDIT';
|
||||
|
||||
/**
|
||||
* The person identifier is present on both person's creation form, and edit form.
|
||||
*/
|
||||
case ON_CREATION = 'ON_CREATION';
|
||||
|
||||
/**
|
||||
* The person identifier is required to create the person. It should not be empty.
|
||||
*/
|
||||
case REQUIRED = 'REQUIRED';
|
||||
|
||||
public function isEditableByUser(): bool
|
||||
{
|
||||
return IdentifierPresenceEnum::NOT_EDITABLE !== $this;
|
||||
}
|
||||
}
|
@@ -12,10 +12,13 @@ declare(strict_types=1);
|
||||
namespace Chill\PersonBundle\Entity\Identifier;
|
||||
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Chill\PersonBundle\PersonIdentifier\Validator\UniqueIdentifierConstraint;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'chill_person_identifier')]
|
||||
#[ORM\UniqueConstraint(name: 'chill_person_identifier_unique', columns: ['definition_id', 'value'])]
|
||||
#[UniqueIdentifierConstraint]
|
||||
class PersonIdentifier
|
||||
{
|
||||
#[ORM\Id]
|
||||
@@ -30,7 +33,7 @@ class PersonIdentifier
|
||||
#[ORM\Column(name: 'value', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
|
||||
private array $value = [];
|
||||
|
||||
#[ORM\Column(name: 'canonical', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => '[]'])]
|
||||
#[ORM\Column(name: 'canonical', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
|
||||
private string $canonical = '';
|
||||
|
||||
public function __construct(
|
||||
|
@@ -11,6 +11,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\PersonBundle\Entity\Identifier;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
@@ -18,23 +19,23 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
class PersonIdentifierDefinition
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)]
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\GeneratedValue]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(name: 'active', type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => true])]
|
||||
#[ORM\Column(name: 'active', type: Types::BOOLEAN, nullable: false, options: ['default' => true])]
|
||||
private bool $active = true;
|
||||
|
||||
public function __construct(
|
||||
#[ORM\Column(name: 'label', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])]
|
||||
#[ORM\Column(name: 'label', type: Types::JSON, nullable: false, options: ['default' => '[]'])]
|
||||
private array $label,
|
||||
#[ORM\Column(name: 'engine', type: \Doctrine\DBAL\Types\Types::STRING, length: 100)]
|
||||
#[ORM\Column(name: 'engine', type: Types::STRING, length: 100)]
|
||||
private string $engine,
|
||||
#[ORM\Column(name: 'is_searchable', type: \Doctrine\DBAL\Types\Types::BOOLEAN, options: ['default' => false])]
|
||||
#[ORM\Column(name: 'is_searchable', type: Types::BOOLEAN, options: ['default' => false])]
|
||||
private bool $isSearchable = false,
|
||||
#[ORM\Column(name: 'is_editable_by_users', type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => false])]
|
||||
private bool $isEditableByUsers = false,
|
||||
#[ORM\Column(name: 'data', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
|
||||
#[ORM\Column(name: 'presence', type: Types::STRING, nullable: false, enumType: IdentifierPresenceEnum::class, options: ['default' => IdentifierPresenceEnum::ON_EDIT])]
|
||||
private IdentifierPresenceEnum $presence = IdentifierPresenceEnum::ON_EDIT,
|
||||
#[ORM\Column(name: 'data', type: Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
|
||||
private array $data = [],
|
||||
) {}
|
||||
|
||||
@@ -58,11 +59,6 @@ class PersonIdentifierDefinition
|
||||
return $this->engine;
|
||||
}
|
||||
|
||||
public function setEngine(string $engine): void
|
||||
{
|
||||
$this->engine = $engine;
|
||||
}
|
||||
|
||||
public function isSearchable(): bool
|
||||
{
|
||||
return $this->isSearchable;
|
||||
@@ -75,12 +71,7 @@ class PersonIdentifierDefinition
|
||||
|
||||
public function isEditableByUsers(): bool
|
||||
{
|
||||
return $this->isEditableByUsers;
|
||||
}
|
||||
|
||||
public function setIsEditableByUsers(bool $isEditableByUsers): void
|
||||
{
|
||||
$this->isEditableByUsers = $isEditableByUsers;
|
||||
return $this->presence->isEditableByUser();
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
@@ -104,4 +95,16 @@ class PersonIdentifierDefinition
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function getPresence(): IdentifierPresenceEnum
|
||||
{
|
||||
return $this->presence;
|
||||
}
|
||||
|
||||
public function setPresence(IdentifierPresenceEnum $presence): self
|
||||
{
|
||||
$this->presence = $presence;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
@@ -27,7 +27,6 @@ use Chill\MainBundle\Entity\Language;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature;
|
||||
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
|
||||
use Chill\PersonBundle\Entity\Household\Household;
|
||||
use Chill\PersonBundle\Entity\Household\HouseholdMember;
|
||||
use Chill\PersonBundle\Entity\Household\PersonHouseholdAddress;
|
||||
@@ -36,6 +35,7 @@ use Chill\PersonBundle\Entity\Person\PersonCenterCurrent;
|
||||
use Chill\PersonBundle\Entity\Person\PersonCenterHistory;
|
||||
use Chill\PersonBundle\Entity\Person\PersonCurrentAddress;
|
||||
use Chill\PersonBundle\Entity\Person\PersonResource;
|
||||
use Chill\PersonBundle\PersonIdentifier\Validator\RequiredIdentifierConstraint;
|
||||
use Chill\PersonBundle\Validator\Constraints\Household\HouseholdMembershipSequential;
|
||||
use Chill\PersonBundle\Validator\Constraints\Person\Birthdate;
|
||||
use Chill\PersonBundle\Validator\Constraints\Person\PersonHasCenter;
|
||||
@@ -47,6 +47,7 @@ use Doctrine\Common\Collections\ReadableCollection;
|
||||
use Doctrine\Common\Collections\Selectable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use libphonenumber\PhoneNumber;
|
||||
use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as MisdPhoneNumberConstraint;
|
||||
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
@@ -273,6 +274,8 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'person', targetEntity: PersonIdentifier::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[RequiredIdentifierConstraint]
|
||||
#[Assert\Valid]
|
||||
private Collection $identifiers;
|
||||
|
||||
/**
|
||||
@@ -319,7 +322,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
|
||||
* The person's mobile phone number.
|
||||
*/
|
||||
#[ORM\Column(type: 'phone_number', nullable: true)]
|
||||
#[PhonenumberConstraint(type: 'mobile')]
|
||||
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::MOBILE])]
|
||||
private ?PhoneNumber $mobilenumber = null;
|
||||
|
||||
/**
|
||||
@@ -359,7 +362,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
|
||||
* The person's phonenumber.
|
||||
*/
|
||||
#[ORM\Column(type: 'phone_number', nullable: true)]
|
||||
#[PhonenumberConstraint(type: 'landline')]
|
||||
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::FIXED_LINE, MisdPhoneNumberConstraint::VOIP, MisdPhoneNumberConstraint::PERSONAL_NUMBER])]
|
||||
private ?PhoneNumber $phonenumber = null;
|
||||
|
||||
/**
|
||||
|
@@ -89,6 +89,11 @@ final class CreationPersonType extends AbstractType
|
||||
'label' => false,
|
||||
]);
|
||||
|
||||
$builder->add('identifiers', PersonIdentifiersType::class, [
|
||||
'by_reference' => false,
|
||||
'step' => 'on_create',
|
||||
]);
|
||||
|
||||
if ($this->askCenters) {
|
||||
$builder
|
||||
->add('center', PickCenterType::class, [
|
||||
|
@@ -12,10 +12,12 @@ declare(strict_types=1);
|
||||
namespace Chill\PersonBundle\Form;
|
||||
|
||||
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
|
||||
use Chill\PersonBundle\Entity\Identifier\IdentifierPresenceEnum;
|
||||
use Chill\PersonBundle\Form\DataMapper\PersonIdentifiersDataMapper;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
final class PersonIdentifiersType extends AbstractType
|
||||
{
|
||||
@@ -32,6 +34,12 @@ final class PersonIdentifiersType extends AbstractType
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip some on creation
|
||||
if ('on_create' === $options['step']
|
||||
&& IdentifierPresenceEnum::ON_EDIT === $worker->getDefinition()->getPresence()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$subBuilder = $builder->create(
|
||||
'identifier_'.$worker->getDefinition()->getId(),
|
||||
options: [
|
||||
@@ -45,4 +53,10 @@ final class PersonIdentifiersType extends AbstractType
|
||||
|
||||
$builder->setDataMapper($this->identifiersDataMapper);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefault('step', 'on_edit')
|
||||
->setAllowedValues('step', ['on_edit', 'on_create']);
|
||||
}
|
||||
}
|
||||
|
@@ -26,7 +26,7 @@ final readonly class StringIdentifier implements PersonIdentifierEngineInterface
|
||||
|
||||
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
|
||||
{
|
||||
return $value['content'] ?? '';
|
||||
return trim($value['content'] ?? '');
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void
|
||||
@@ -36,6 +36,11 @@ final readonly class StringIdentifier implements PersonIdentifierEngineInterface
|
||||
|
||||
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
|
||||
{
|
||||
return $identifier?->getValue()['content'] ?? '';
|
||||
return trim($identifier?->getValue()['content'] ?? '');
|
||||
}
|
||||
|
||||
public function isEmpty(PersonIdentifier $identifier): bool
|
||||
{
|
||||
return '' === trim($identifier->getValue()['content'] ?? '');
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\PersonIdentifier\Normalizer;
|
||||
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
|
||||
final readonly class PersonIdentifierWorkerNormalizer implements NormalizerInterface
|
||||
{
|
||||
public function normalize($object, ?string $format = null, array $context = []): array
|
||||
{
|
||||
if (!$object instanceof PersonIdentifierWorker) {
|
||||
throw new \Symfony\Component\Serializer\Exception\UnexpectedValueException();
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'person_identifier_worker',
|
||||
'definition_id' => $object->getDefinition()->getId(),
|
||||
'engine' => $object->getDefinition()->getEngine(),
|
||||
'label' => $object->getDefinition()->getLabel(),
|
||||
'isActive' => $object->getDefinition()->isActive(),
|
||||
'presence' => $object->getDefinition()->getPresence()->value,
|
||||
];
|
||||
}
|
||||
|
||||
public function supportsNormalization($data, ?string $format = null): bool
|
||||
{
|
||||
return $data instanceof PersonIdentifierWorker;
|
||||
}
|
||||
}
|
@@ -24,4 +24,12 @@ interface PersonIdentifierEngineInterface
|
||||
public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void;
|
||||
|
||||
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string;
|
||||
|
||||
/**
|
||||
* Return true if the identifier must be considered as empty.
|
||||
*
|
||||
* This is in use when the identifier is validated and must be required. If the identifier is empty and is required
|
||||
* by the definition, the validation will fails.
|
||||
*/
|
||||
public function isEmpty(PersonIdentifier $identifier): bool;
|
||||
}
|
||||
|
@@ -13,6 +13,7 @@ namespace Chill\PersonBundle\PersonIdentifier;
|
||||
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Chill\PersonBundle\PersonIdentifier\Exception\EngineNotFoundException;
|
||||
use Chill\PersonBundle\PersonIdentifier\Exception\PersonIdentifierDefinitionNotFoundException;
|
||||
use Chill\PersonBundle\Repository\Identifier\PersonIdentifierDefinitionRepository;
|
||||
|
||||
final readonly class PersonIdentifierManager implements PersonIdentifierManagerInterface
|
||||
@@ -44,8 +45,16 @@ final readonly class PersonIdentifierManager implements PersonIdentifierManagerI
|
||||
return $workers;
|
||||
}
|
||||
|
||||
public function buildWorkerByPersonIdentifierDefinition(PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker
|
||||
public function buildWorkerByPersonIdentifierDefinition(int|PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker
|
||||
{
|
||||
if (is_int($personIdentifierDefinition)) {
|
||||
$id = $personIdentifierDefinition;
|
||||
$personIdentifierDefinition = $this->personIdentifierDefinitionRepository->find($id);
|
||||
if (null === $personIdentifierDefinition) {
|
||||
throw new PersonIdentifierDefinitionNotFoundException($id);
|
||||
}
|
||||
}
|
||||
|
||||
return new PersonIdentifierWorker($this->getEngine($personIdentifierDefinition->getEngine()), $personIdentifierDefinition);
|
||||
}
|
||||
|
||||
|
@@ -22,5 +22,8 @@ interface PersonIdentifierManagerInterface
|
||||
*/
|
||||
public function getWorkers(): array;
|
||||
|
||||
public function buildWorkerByPersonIdentifierDefinition(PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker;
|
||||
/**
|
||||
* @param int|PersonIdentifierDefinition $personIdentifierDefinition an instance of PersonIdentifierDefinition, or his id
|
||||
*/
|
||||
public function buildWorkerByPersonIdentifierDefinition(int|PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker;
|
||||
}
|
||||
|
@@ -46,4 +46,12 @@ final readonly class PersonIdentifierWorker
|
||||
{
|
||||
return $this->identifierEngine->renderAsString($identifier, $this->definition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the identifier must be considered as empty.
|
||||
*/
|
||||
public function isEmpty(PersonIdentifier $identifier): bool
|
||||
{
|
||||
return $this->identifierEngine->isEmpty($identifier);
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\PersonIdentifier\Validator;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
/**
|
||||
* Test that the required constraints are present.
|
||||
*/
|
||||
#[\Attribute]
|
||||
class RequiredIdentifierConstraint extends Constraint
|
||||
{
|
||||
public string $message = 'person_identifier.This identifier must be set';
|
||||
|
||||
public function getTargets(): string
|
||||
{
|
||||
return self::PROPERTY_CONSTRAINT;
|
||||
}
|
||||
}
|
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\PersonIdentifier\Validator;
|
||||
|
||||
use Chill\PersonBundle\Entity\Identifier\IdentifierPresenceEnum;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedValueException;
|
||||
|
||||
final class RequiredIdentifierConstraintValidator extends ConstraintValidator
|
||||
{
|
||||
public function __construct(private readonly PersonIdentifierManagerInterface $identifierManager) {}
|
||||
|
||||
public function validate($value, Constraint $constraint)
|
||||
{
|
||||
if (!$constraint instanceof RequiredIdentifierConstraint) {
|
||||
throw new UnexpectedTypeException($constraint, RequiredIdentifierConstraint::class);
|
||||
}
|
||||
|
||||
if (!$value instanceof Collection) {
|
||||
throw new UnexpectedValueException($value, Collection::class);
|
||||
}
|
||||
|
||||
foreach ($this->identifierManager->getWorkers() as $worker) {
|
||||
if (IdentifierPresenceEnum::REQUIRED !== $worker->getDefinition()->getPresence()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$identifier = $value->findFirst(fn (int $key, PersonIdentifier $identifier) => $identifier->getDefinition() === $worker->getDefinition());
|
||||
|
||||
if (null === $identifier || $worker->isEmpty($identifier)) {
|
||||
$this->context->buildViolation($constraint->message)
|
||||
->setParameter('{{ value }}', $worker->renderAsString($identifier))
|
||||
->setParameter('definition_id', (string) $worker->getDefinition()->getId())
|
||||
->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\PersonIdentifier\Validator;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
#[\Attribute]
|
||||
class UniqueIdentifierConstraint extends Constraint
|
||||
{
|
||||
public string $message = 'person_identifier.Identifier must be unique. The same identifier already exists for {{ persons }}';
|
||||
|
||||
public function getTargets(): string
|
||||
{
|
||||
return self::CLASS_CONSTRAINT;
|
||||
}
|
||||
}
|
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\PersonIdentifier\Validator;
|
||||
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Repository\Identifier\PersonIdentifierRepository;
|
||||
use Chill\PersonBundle\Templating\Entity\PersonRenderInterface;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedValueException;
|
||||
|
||||
class UniqueIdentifierConstraintValidator extends ConstraintValidator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PersonIdentifierRepository $personIdentifierRepository,
|
||||
private readonly PersonRenderInterface $personRender,
|
||||
) {}
|
||||
|
||||
public function validate($value, Constraint $constraint): void
|
||||
{
|
||||
if (!$constraint instanceof UniqueIdentifierConstraint) {
|
||||
throw new UnexpectedTypeException($constraint, UniqueIdentifierConstraint::class);
|
||||
}
|
||||
|
||||
if (!$value instanceof PersonIdentifier) {
|
||||
throw new UnexpectedValueException($value, PersonIdentifier::class);
|
||||
}
|
||||
|
||||
$identifiers = $this->personIdentifierRepository->findByDefinitionAndValue($value->getDefinition(), $value->getValue());
|
||||
|
||||
if (count($identifiers) > 0) {
|
||||
$persons = array_map(fn (PersonIdentifier $idf): string => $this->personRender->renderString($idf->getPerson(), []), $identifiers);
|
||||
|
||||
$this->context->buildViolation($constraint->message)
|
||||
->setParameter('{{ persons }}', implode(', ', $persons))
|
||||
->setParameter('definition_id', (string) $value->getDefinition()->getId())
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Repository\Identifier;
|
||||
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class PersonIdentifierRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, PersonIdentifier::class);
|
||||
}
|
||||
|
||||
public function findByDefinitionAndValue(PersonIdentifierDefinition $definition, array $value): array
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->where('p.definition = :definition')
|
||||
->andWhere('p.value = :value')
|
||||
->setParameter('definition', $definition)
|
||||
->setParameter('value', $value, Types::JSON)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
@@ -17,6 +17,8 @@ use Chill\MainBundle\Search\ParsingException;
|
||||
use Chill\MainBundle\Search\SearchApiQuery;
|
||||
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
|
||||
use Chill\PersonBundle\Security\Authorization\PersonVoter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\NonUniqueResultException;
|
||||
@@ -27,7 +29,13 @@ use Symfony\Component\Security\Core\Security;
|
||||
|
||||
final readonly class PersonACLAwareRepository implements PersonACLAwareRepositoryInterface
|
||||
{
|
||||
public function __construct(private Security $security, private EntityManagerInterface $em, private CountryRepository $countryRepository, private AuthorizationHelperInterface $authorizationHelper) {}
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private EntityManagerInterface $em,
|
||||
private CountryRepository $countryRepository,
|
||||
private AuthorizationHelperInterface $authorizationHelper,
|
||||
private PersonIdentifierManagerInterface $personIdentifierManager,
|
||||
) {}
|
||||
|
||||
public function buildAuthorizedQuery(
|
||||
?string $default = null,
|
||||
@@ -107,6 +115,15 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor
|
||||
$query
|
||||
->setFromClause('chill_person_person AS person');
|
||||
|
||||
$idDefinitionWorkers = array_map(
|
||||
fn (PersonIdentifierWorker $worker) => $worker->getDefinition()->getId(),
|
||||
array_filter(
|
||||
$this->personIdentifierManager->getWorkers(),
|
||||
fn (PersonIdentifierWorker $worker) => $worker->getDefinition()->isSearchable()
|
||||
)
|
||||
);
|
||||
$idDefinitionWorkerQuestionMarks = implode(', ', array_fill(0, count($idDefinitionWorkers), '?'));
|
||||
|
||||
$pertinence = [];
|
||||
$pertinenceArgs = [];
|
||||
$andWhereSearchClause = [];
|
||||
@@ -124,10 +141,25 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor
|
||||
'(starts_with(LOWER(UNACCENT(lastname)), UNACCENT(LOWER(?))))::int';
|
||||
\array_push($pertinenceArgs, $str, $str, $str, $str);
|
||||
|
||||
$andWhereSearchClause[] =
|
||||
'(LOWER(UNACCENT(?)) <<% person.fullnamecanonical OR '.
|
||||
"person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' )";
|
||||
\array_push($andWhereSearchClauseArgs, $str, $str);
|
||||
$q = [
|
||||
'LOWER(UNACCENT(?)) <<% person.fullnamecanonical',
|
||||
"person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' ",
|
||||
];
|
||||
$qArguments = [$str, $str];
|
||||
|
||||
if (count($idDefinitionWorkers) > 0) {
|
||||
$q[] = $mq = "EXISTS (
|
||||
SELECT 1 FROM chill_person_identifier AS identifier
|
||||
WHERE identifier.canonical LIKE LOWER(UNACCENT(?)) || '%' AND identifier.definition_id IN ({$idDefinitionWorkerQuestionMarks})
|
||||
AND person.id = identifier.person_id
|
||||
)";
|
||||
$pertinence[] = "({$mq})::int * 1_000_000";
|
||||
$qArguments = [...$qArguments, $str, ...$idDefinitionWorkers];
|
||||
$pertinenceArgs = [...$pertinenceArgs, $str, ...$idDefinitionWorkers];
|
||||
}
|
||||
|
||||
$andWhereSearchClause[] = '('.implode(' OR ', $q).')';
|
||||
$andWhereSearchClauseArgs = [...$andWhereSearchClauseArgs, ...$qArguments];
|
||||
}
|
||||
|
||||
$query->andWhereClause(
|
||||
|
@@ -10,16 +10,25 @@ import {
|
||||
Scope,
|
||||
Job,
|
||||
PrivateCommentEmbeddable,
|
||||
TranslatableString,
|
||||
DateTimeWrite,
|
||||
SetGender,
|
||||
SetCenter,
|
||||
SetCivility,
|
||||
} from "ChillMainAssets/types";
|
||||
import { StoredObject } from "ChillDocStoreAssets/types";
|
||||
import { Thirdparty } from "../../../ChillThirdPartyBundle/Resources/public/types";
|
||||
import { Calendar } from "../../../ChillCalendarBundle/Resources/public/types";
|
||||
import Person from "./vuejs/_components/OnTheFly/Person.vue";
|
||||
|
||||
export interface AltName {
|
||||
label: string;
|
||||
labels: TranslatableString;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface AltNameWrite {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
export interface Person {
|
||||
id: number;
|
||||
type: "person";
|
||||
@@ -43,6 +52,32 @@ export interface Person {
|
||||
current_residential_addresses: Address[];
|
||||
}
|
||||
|
||||
export interface PersonIdentifierWrite {
|
||||
type: "person_identifier";
|
||||
definition_id: number;
|
||||
value: object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Person representation to create or update a Person
|
||||
*/
|
||||
export interface PersonWrite {
|
||||
type: "person";
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
altNames: AltNameWrite[];
|
||||
// address: number | null;
|
||||
birthdate: DateTimeWrite | null;
|
||||
deathdate: DateTimeWrite | null;
|
||||
phonenumber: string;
|
||||
mobilenumber: string;
|
||||
email: string;
|
||||
gender: SetGender | null;
|
||||
center: SetCenter | null;
|
||||
civility: SetCivility | null;
|
||||
identifiers: PersonIdentifierWrite[];
|
||||
}
|
||||
|
||||
export interface AccompanyingPeriod {
|
||||
id: number;
|
||||
type: "accompanying_period";
|
||||
@@ -328,11 +363,18 @@ export interface AccompanyingPeriodWorkEvaluationDocument {
|
||||
workflows: object[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity types that a user can create
|
||||
*/
|
||||
export type CreatableEntityType = "person" | "thirdparty";
|
||||
|
||||
/**
|
||||
* Entities that can be search and selected by a user
|
||||
*/
|
||||
export type EntityType =
|
||||
| CreatableEntityType
|
||||
| "user_group"
|
||||
| "user"
|
||||
| "person"
|
||||
| "thirdparty"
|
||||
| "household";
|
||||
|
||||
export type Entities = (UserGroup | User | Person | Thirdparty | Household) & {
|
||||
@@ -370,7 +412,8 @@ export interface Search {
|
||||
|
||||
export interface SearchOptions {
|
||||
uniq: boolean;
|
||||
type: string[];
|
||||
/** @deprecated */
|
||||
type: EntityType[];
|
||||
priority: number | null;
|
||||
button: {
|
||||
size: string;
|
||||
@@ -380,6 +423,17 @@ export interface SearchOptions {
|
||||
};
|
||||
}
|
||||
|
||||
type PersonIdentifierPresence = 'NOT_EDITABLE' | 'ON_EDIT' | 'ON_CREATION' | 'REQUIRED';
|
||||
|
||||
export interface PersonIdentifierWorker {
|
||||
type: "person_identifier_worker";
|
||||
definition_id: number;
|
||||
engine: string;
|
||||
label: TranslatableString;
|
||||
isActive: boolean;
|
||||
presence: PersonIdentifierPresence;
|
||||
}
|
||||
|
||||
export class MakeFetchException extends Error {
|
||||
sta: number;
|
||||
txt: string;
|
||||
|
@@ -1,88 +0,0 @@
|
||||
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
|
||||
|
||||
/*
|
||||
* GET a person by id
|
||||
*/
|
||||
const getPerson = (id) => {
|
||||
const url = `/api/1.0/person/person/${id}.json`;
|
||||
return fetch(url).then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
throw Error("Error with request resource response");
|
||||
});
|
||||
};
|
||||
|
||||
const getPersonAltNames = () =>
|
||||
fetch("/api/1.0/person/config/alt_names.json").then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
throw Error("Error with request resource response");
|
||||
});
|
||||
|
||||
const getCivilities = () =>
|
||||
fetch("/api/1.0/main/civility.json").then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
throw Error("Error with request resource response");
|
||||
});
|
||||
|
||||
const getGenders = () => makeFetch("GET", "/api/1.0/main/gender.json");
|
||||
// .then(response => {
|
||||
// console.log(response)
|
||||
// if (response.ok) { return response.json(); }
|
||||
// throw Error('Error with request resource response');
|
||||
// });
|
||||
|
||||
const getCentersForPersonCreation = () =>
|
||||
makeFetch("GET", "/api/1.0/person/creation/authorized-centers", null);
|
||||
|
||||
/*
|
||||
* POST a new person
|
||||
*/
|
||||
const postPerson = (body) => {
|
||||
const url = `/api/1.0/person/person.json`;
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json;charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}).then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
throw Error("Error with request resource response");
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
* PATCH an existing person
|
||||
*/
|
||||
const patchPerson = (id, body) => {
|
||||
const url = `/api/1.0/person/person/${id}.json`;
|
||||
return fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json;charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}).then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
throw Error("Error with request resource response");
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
getCentersForPersonCreation,
|
||||
getPerson,
|
||||
getPersonAltNames,
|
||||
getCivilities,
|
||||
getGenders,
|
||||
postPerson,
|
||||
patchPerson,
|
||||
};
|
@@ -0,0 +1,87 @@
|
||||
import { fetchResults, makeFetch } from "ChillMainAssets/lib/api/apiMethods";
|
||||
import { Center, Civility, Gender } from "ChillMainAssets/types";
|
||||
import {
|
||||
AltName,
|
||||
Person,
|
||||
PersonIdentifierWorker,
|
||||
PersonWrite,
|
||||
} from "ChillPersonAssets/types";
|
||||
import person from "ChillPersonAssets/vuejs/_components/OnTheFly/Person.vue";
|
||||
|
||||
/*
|
||||
* GET a person by id
|
||||
*/
|
||||
export const getPerson = async (id: number): Promise<Person> => {
|
||||
const url = `/api/1.0/person/person/${id}.json`;
|
||||
return fetch(url).then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
throw Error("Error with request resource response");
|
||||
});
|
||||
};
|
||||
|
||||
export const getPersonAltNames = async (): Promise<AltName[]> =>
|
||||
fetch("/api/1.0/person/config/alt_names.json").then((response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
throw Error("Error with request resource response");
|
||||
});
|
||||
|
||||
export const getCivilities = async (): Promise<Civility[]> =>
|
||||
fetchResults("/api/1.0/main/civility.json");
|
||||
|
||||
export const getGenders = async (): Promise<Gender[]> =>
|
||||
fetchResults("/api/1.0/main/gender.json");
|
||||
|
||||
export const getCentersForPersonCreation = async (): Promise<{
|
||||
showCenters: boolean;
|
||||
centers: Center[];
|
||||
}> => makeFetch("GET", "/api/1.0/person/creation/authorized-centers", null);
|
||||
|
||||
export const getPersonIdentifiers = async (): Promise<
|
||||
PersonIdentifierWorker[]
|
||||
> => fetchResults("/api/1.0/person/identifiers/workers");
|
||||
|
||||
export interface WritePersonViolationMap
|
||||
extends Record<string, Record<string, string>> {
|
||||
firstName: {
|
||||
"{{ value }}": string
|
||||
};
|
||||
lastName: {
|
||||
"{{ value }}": string;
|
||||
};
|
||||
gender: {
|
||||
"{{ value }}": string;
|
||||
};
|
||||
mobilenumber: {
|
||||
"{{ types }}": string; // ex: "mobile number"
|
||||
"{{ value }}": string; // ex: "+33 1 02 03 04 05"
|
||||
};
|
||||
phonenumber: {
|
||||
"{{ types }}": string; // ex: "mobile number"
|
||||
"{{ value }}": string; // ex: "+33 1 02 03 04 05"
|
||||
};
|
||||
email: {
|
||||
"{{ value }}": string;
|
||||
};
|
||||
center: {
|
||||
"{{ value }}": string;
|
||||
};
|
||||
civility: {
|
||||
"{{ value }}": string;
|
||||
};
|
||||
birthdate: {};
|
||||
identifiers: {
|
||||
"{{ value }}": string;
|
||||
"definition_id": string;
|
||||
};
|
||||
}
|
||||
export const createPerson = async (person: PersonWrite): Promise<Person> => {
|
||||
return makeFetch<PersonWrite, Person, WritePersonViolationMap>(
|
||||
"POST",
|
||||
"/api/1.0/person/person.json",
|
||||
person,
|
||||
);
|
||||
};
|
@@ -3,487 +3,126 @@
|
||||
class="btn"
|
||||
:class="getClassButton"
|
||||
:title="buttonTitle"
|
||||
@click="openModal"
|
||||
@click="openModalChoose"
|
||||
>
|
||||
<span v-if="displayTextButton">{{ buttonTitle }}</span>
|
||||
</a>
|
||||
|
||||
<teleport to="body">
|
||||
<modal
|
||||
v-if="showModal"
|
||||
@close="closeModal"
|
||||
:modal-dialog-class="modalDialogClass"
|
||||
:show="showModal"
|
||||
:hide-footer="false"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="modal-title">
|
||||
{{ modalTitle }}
|
||||
</h3>
|
||||
</template>
|
||||
<person-choose-modal
|
||||
v-if="showModalChoose"
|
||||
:modal-title="modalTitle"
|
||||
:options="options"
|
||||
:suggested="suggested"
|
||||
:selected="selected"
|
||||
:modal-dialog-class="'modal-dialog-scrollable modal-xl'"
|
||||
:allow-create="props.allowCreate"
|
||||
@close="closeModalChoose"
|
||||
@addNewPersons="(payload) => emit('addNewPersons', payload)"
|
||||
@onAskForCreate="onAskForCreate"
|
||||
/>
|
||||
|
||||
<template #body-head>
|
||||
<div class="modal-body">
|
||||
<div class="search">
|
||||
<label class="col-form-label" style="float: right">
|
||||
{{
|
||||
trans(ADD_PERSONS_SUGGESTED_COUNTER, {
|
||||
count: suggestedCounter,
|
||||
})
|
||||
}}
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="search-persons"
|
||||
name="query"
|
||||
v-model="query"
|
||||
:placeholder="trans(ADD_PERSONS_SEARCH_SOME_PERSONS)"
|
||||
ref="searchRef"
|
||||
/>
|
||||
<i class="fa fa-search fa-lg" />
|
||||
<i
|
||||
class="fa fa-times"
|
||||
v-if="queryLength >= 3"
|
||||
@click="resetSuggestion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body" v-if="checkUniq === 'checkbox'">
|
||||
<div class="count">
|
||||
<span>
|
||||
<a v-if="suggestedCounter > 2" @click="selectAll">
|
||||
{{ trans(ACTION_CHECK_ALL) }}
|
||||
</a>
|
||||
<a v-if="selectedCounter > 0" @click="resetSelection">
|
||||
<i v-if="suggestedCounter > 2"> • </i>
|
||||
{{ trans(ACTION_RESET) }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="selectedCounter > 0">
|
||||
{{
|
||||
trans(ADD_PERSONS_SELECTED_COUNTER, {
|
||||
count: selectedCounter,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="results">
|
||||
<person-suggestion
|
||||
v-for="item in selectedAndSuggested.slice().reverse()"
|
||||
:key="itemKey(item)"
|
||||
:item="item"
|
||||
:search="search"
|
||||
:type="checkUniq"
|
||||
@save-form-on-the-fly="saveFormOnTheFly"
|
||||
@new-prior-suggestion="newPriorSuggestion"
|
||||
@update-selected="updateSelected"
|
||||
/>
|
||||
|
||||
<div class="create-button">
|
||||
<on-the-fly
|
||||
v-if="
|
||||
queryLength >= 3 &&
|
||||
(options.type.includes('person') ||
|
||||
options.type.includes('thirdparty'))
|
||||
"
|
||||
:button-text="trans(ONTHEFLY_CREATE_BUTTON, { q: query })"
|
||||
:allowed-types="options.type"
|
||||
:query="query"
|
||||
action="create"
|
||||
@save-form-on-the-fly="saveFormOnTheFly"
|
||||
ref="onTheFly"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<button
|
||||
class="btn btn-create"
|
||||
@click.prevent="
|
||||
() => {
|
||||
$emit('addNewPersons', {
|
||||
selected: selectedComputed,
|
||||
});
|
||||
query = '';
|
||||
closeModal();
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ trans(ACTION_ADD) }}
|
||||
</button>
|
||||
</template>
|
||||
</modal>
|
||||
</teleport>
|
||||
<CreateModal
|
||||
v-if="creatableEntityTypes.length > 0 && showModalCreate"
|
||||
:allowed-types="creatableEntityTypes"
|
||||
:query="query"
|
||||
@close="closeModalCreate"
|
||||
@onPersonCreated="onPersonCreated"
|
||||
></CreateModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
|
||||
import { ref, reactive, computed, nextTick, watch, shallowRef } from "vue";
|
||||
import PersonSuggestion from "./AddPersons/PersonSuggestion.vue";
|
||||
import { searchEntities } from "ChillPersonAssets/vuejs/_api/AddPersons";
|
||||
import OnTheFly from "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue";
|
||||
|
||||
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
|
||||
|
||||
import {
|
||||
trans,
|
||||
ADD_PERSONS_SUGGESTED_COUNTER,
|
||||
ADD_PERSONS_SEARCH_SOME_PERSONS,
|
||||
ADD_PERSONS_SELECTED_COUNTER,
|
||||
ONTHEFLY_CREATE_BUTTON,
|
||||
ACTION_CHECK_ALL,
|
||||
ACTION_RESET,
|
||||
ACTION_ADD,
|
||||
} from "translator";
|
||||
import {
|
||||
import { ref, computed } from "vue";
|
||||
import PersonChooseModal from "./AddPersons/PersonChooseModal.vue";
|
||||
import type {
|
||||
Suggestion,
|
||||
Search,
|
||||
AddPersonResult as OriginalResult,
|
||||
SearchOptions,
|
||||
CreatableEntityType,
|
||||
EntityType,
|
||||
Person,
|
||||
} from "ChillPersonAssets/types";
|
||||
import { marked } from "marked";
|
||||
import options = marked.options;
|
||||
import CreateModal from "ChillMainAssets/vuejs/OnTheFly/components/CreateModal.vue";
|
||||
|
||||
// Extend Result type to include optional addressId
|
||||
type Result = OriginalResult & { addressId?: number };
|
||||
interface AddPersonsConfig {
|
||||
suggested?: Suggestion[];
|
||||
selected?: Suggestion[];
|
||||
buttonTitle: string;
|
||||
modalTitle: string;
|
||||
options: SearchOptions;
|
||||
allowCreate?: boolean;
|
||||
types?: EntityType[] | undefined;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
suggested: { type: Array as () => Suggestion[], default: () => [] },
|
||||
selected: { type: Array as () => Suggestion[], default: () => [] },
|
||||
buttonTitle: { type: String, required: true },
|
||||
modalTitle: { type: String, required: true },
|
||||
options: { type: Object as () => SearchOptions, required: true },
|
||||
const props = withDefaults(defineProps<AddPersonsConfig>(), {
|
||||
suggested: () => [],
|
||||
selected: () => [],
|
||||
allowCreate: () => true,
|
||||
types: () => ["person"],
|
||||
});
|
||||
|
||||
defineEmits(["addNewPersons"]);
|
||||
const emit =
|
||||
defineEmits<
|
||||
(e: "addNewPersons", payload: { selected: Suggestion[] }) => void
|
||||
>();
|
||||
|
||||
const showModal = ref(false);
|
||||
const modalDialogClass = ref("modal-dialog-scrollable modal-xl");
|
||||
|
||||
const modal = shallowRef({
|
||||
showModal: false,
|
||||
modalDialogClass: "modal-dialog-scrollable modal-xl",
|
||||
});
|
||||
|
||||
const search = reactive({
|
||||
query: "" as string,
|
||||
previousQuery: "" as string,
|
||||
currentSearchQueryController: null as AbortController | null,
|
||||
suggested: props.suggested as Suggestion[],
|
||||
selected: props.selected as Suggestion[],
|
||||
priorSuggestion: {} as Partial<Suggestion>,
|
||||
});
|
||||
|
||||
const searchRef = ref<HTMLInputElement | null>(null);
|
||||
const onTheFly = ref<InstanceType<typeof OnTheFly> | null>(null);
|
||||
|
||||
const query = computed({
|
||||
get: () => search.query,
|
||||
set: (val) => setQuery(val),
|
||||
});
|
||||
const queryLength = computed(() => search.query.length);
|
||||
const suggestedCounter = computed(() => search.suggested.length);
|
||||
const selectedComputed = computed(() => search.selected);
|
||||
const selectedCounter = computed(() => search.selected.length);
|
||||
const showModalChoose = ref(false);
|
||||
const showModalCreate = ref(false);
|
||||
const query = ref("");
|
||||
|
||||
const getClassButton = computed(() => {
|
||||
let size = props.options?.button?.size ?? "";
|
||||
let type = props.options?.button?.type ?? "btn-create";
|
||||
return size ? size + " " + type : type;
|
||||
const size = props.options?.button?.size ?? "";
|
||||
const type = props.options?.button?.type ?? "btn-create";
|
||||
return size ? `${size} ${type}` : type;
|
||||
});
|
||||
|
||||
const displayTextButton = computed(() =>
|
||||
props.options?.button?.display !== undefined
|
||||
? props.options.button.display
|
||||
: true,
|
||||
);
|
||||
|
||||
const checkUniq = computed(() =>
|
||||
props.options.uniq === true ? "radio" : "checkbox",
|
||||
);
|
||||
|
||||
const priorSuggestion = computed(() => search.priorSuggestion);
|
||||
const hasPriorSuggestion = computed(() => !!search.priorSuggestion.key);
|
||||
|
||||
const itemKey = (item: Suggestion) => item.result.type + item.result.id;
|
||||
|
||||
function addPriorSuggestion() {
|
||||
if (hasPriorSuggestion.value) {
|
||||
// Type assertion is safe here due to the checks above
|
||||
search.suggested.unshift(priorSuggestion.value as Suggestion);
|
||||
search.selected.unshift(priorSuggestion.value as Suggestion);
|
||||
newPriorSuggestion(null);
|
||||
const creatableEntityTypes = computed<CreatableEntityType[]>(() => {
|
||||
if (typeof props.options.type !== "undefined") {
|
||||
return props.options.type.filter(
|
||||
(e: EntityType) => e === "thirdparty" || e === "person",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedAndSuggested = computed(() => {
|
||||
addPriorSuggestion();
|
||||
const uniqBy = (a: Suggestion[], key: (item: Suggestion) => string) => [
|
||||
...new Map(a.map((x) => [key(x), x])).values(),
|
||||
];
|
||||
let union = [
|
||||
...new Set([
|
||||
...search.suggested.slice().reverse(),
|
||||
...search.selected.slice().reverse(),
|
||||
]),
|
||||
];
|
||||
return uniqBy(union, (k: Suggestion) => k.key);
|
||||
return props.types.filter(
|
||||
(e: EntityType) => e === "thirdparty" || e === "person",
|
||||
);
|
||||
});
|
||||
|
||||
function openModal() {
|
||||
showModal.value = true;
|
||||
nextTick(() => {
|
||||
if (searchRef.value) searchRef.value.focus();
|
||||
});
|
||||
}
|
||||
function closeModal() {
|
||||
showModal.value = false;
|
||||
function onAskForCreate(payload: { query: string }) {
|
||||
query.value = payload.query;
|
||||
showModalChoose.value = false;
|
||||
showModalCreate.value = true;
|
||||
}
|
||||
|
||||
function setQuery(q: string) {
|
||||
search.query = q;
|
||||
|
||||
// Clear previous search if any
|
||||
if (search.currentSearchQueryController) {
|
||||
search.currentSearchQueryController.abort();
|
||||
search.currentSearchQueryController = null;
|
||||
}
|
||||
|
||||
if (q === "") {
|
||||
loadSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce delay based on query length
|
||||
const delay = q.length > 3 ? 300 : 700;
|
||||
|
||||
setTimeout(() => {
|
||||
// Only search if query hasn't changed in the meantime
|
||||
if (q !== search.query) return;
|
||||
|
||||
search.currentSearchQueryController = new AbortController();
|
||||
|
||||
searchEntities(
|
||||
{ query: q, options: props.options },
|
||||
search.currentSearchQueryController.signal,
|
||||
)
|
||||
.then((suggested: Search) => {
|
||||
loadSuggestions(suggested.results);
|
||||
})
|
||||
.catch((error: DOMException) => {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
// Request was aborted, ignore
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}, delay);
|
||||
function openModalChoose() {
|
||||
showModalChoose.value = true;
|
||||
}
|
||||
|
||||
function loadSuggestions(suggestedArr: Suggestion[]) {
|
||||
search.suggested = suggestedArr;
|
||||
search.suggested.forEach((item) => {
|
||||
item.key = itemKey(item);
|
||||
});
|
||||
function closeModalChoose() {
|
||||
showModalChoose.value = false;
|
||||
}
|
||||
|
||||
function updateSelected(value: Suggestion[]) {
|
||||
search.selected = value;
|
||||
function closeModalCreate() {
|
||||
showModalCreate.value = false;
|
||||
}
|
||||
|
||||
function resetSuggestion() {
|
||||
search.query = "";
|
||||
search.suggested = [];
|
||||
function onPersonCreated(payload: { person: Person }) {
|
||||
console.log("onPersonCreated", payload);
|
||||
showModalCreate.value = false;
|
||||
const suggestion = {
|
||||
result: payload.person,
|
||||
relevance: 999999,
|
||||
key: "person",
|
||||
};
|
||||
emit("addNewPersons", { selected: [suggestion] });
|
||||
}
|
||||
|
||||
function resetSelection() {
|
||||
search.selected = [];
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
resetSelection();
|
||||
resetSuggestion();
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
search.suggested.forEach((item) => {
|
||||
search.selected.push(item);
|
||||
});
|
||||
}
|
||||
|
||||
function newPriorSuggestion(entity: Result | null) {
|
||||
if (entity !== null) {
|
||||
let suggestion = {
|
||||
key: entity.type + entity.id,
|
||||
relevance: 0.5,
|
||||
result: entity,
|
||||
};
|
||||
search.priorSuggestion = suggestion;
|
||||
} else {
|
||||
search.priorSuggestion = {};
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFormOnTheFly({
|
||||
type,
|
||||
data,
|
||||
}: {
|
||||
type: string;
|
||||
data: Result;
|
||||
}) {
|
||||
try {
|
||||
if (type === "person") {
|
||||
const responsePerson: Result = await makeFetch(
|
||||
"POST",
|
||||
"/api/1.0/person/person.json",
|
||||
data,
|
||||
);
|
||||
newPriorSuggestion(responsePerson);
|
||||
if (onTheFly.value) onTheFly.value.closeModal();
|
||||
|
||||
if (data.addressId != null) {
|
||||
const household = { type: "household" };
|
||||
const address = { id: data.addressId };
|
||||
try {
|
||||
const responseHousehold: Result = await makeFetch(
|
||||
"POST",
|
||||
"/api/1.0/person/household.json",
|
||||
household,
|
||||
);
|
||||
const member = {
|
||||
concerned: [
|
||||
{
|
||||
person: {
|
||||
type: "person",
|
||||
id: responsePerson.id,
|
||||
},
|
||||
start_date: {
|
||||
datetime: `${new Date().toISOString().split("T")[0]}T00:00:00+02:00`,
|
||||
},
|
||||
holder: false,
|
||||
comment: null,
|
||||
},
|
||||
],
|
||||
destination: {
|
||||
type: "household",
|
||||
id: responseHousehold.id,
|
||||
},
|
||||
composition: null,
|
||||
};
|
||||
await makeFetch(
|
||||
"POST",
|
||||
"/api/1.0/person/household/members/move.json",
|
||||
member,
|
||||
);
|
||||
try {
|
||||
const _response = await makeFetch(
|
||||
"POST",
|
||||
`/api/1.0/person/household/${responseHousehold.id}/address.json`,
|
||||
address,
|
||||
);
|
||||
console.log(_response);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
} else if (type === "thirdparty") {
|
||||
const response: Result = await makeFetch(
|
||||
"POST",
|
||||
"/api/1.0/thirdparty/thirdparty.json",
|
||||
data,
|
||||
);
|
||||
newPriorSuggestion(response);
|
||||
if (onTheFly.value) onTheFly.value.closeModal();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.selected,
|
||||
(newSelected) => {
|
||||
search.selected = newSelected;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.suggested,
|
||||
(newSuggested) => {
|
||||
search.suggested = newSuggested;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => modal,
|
||||
(val) => {
|
||||
showModal.value = val.value.showModal;
|
||||
modalDialogClass.value = val.value.modalDialogClass;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
resetSearch,
|
||||
showModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
li.add-persons {
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
div.body-head {
|
||||
overflow-y: unset;
|
||||
div.modal-body:first-child {
|
||||
margin: auto 4em;
|
||||
div.search {
|
||||
position: relative;
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 1.2em 1.5em 1.2em 2.5em;
|
||||
//margin: 1em 0;
|
||||
}
|
||||
i {
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
padding: 0.65em 0;
|
||||
top: 50%;
|
||||
}
|
||||
i.fa-search {
|
||||
left: 0.5em;
|
||||
}
|
||||
i.fa-times {
|
||||
right: 1em;
|
||||
padding: 0.75em 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
div.modal-body:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
div.count {
|
||||
margin: -0.5em 0 0.7em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
.create-button > a {
|
||||
margin-top: 0.5em;
|
||||
margin-left: 2.6em;
|
||||
}
|
||||
<style lang="scss" scoped>
|
||||
/* Button styles can remain here if needed */
|
||||
</style>
|
||||
|
@@ -0,0 +1,429 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<modal
|
||||
@close="() => emit('close')"
|
||||
:modal-dialog-class="modalDialogClass"
|
||||
:hide-footer="false"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="modal-title">{{ modalTitle }}</h3>
|
||||
</template>
|
||||
|
||||
<template #body-head>
|
||||
<div class="modal-body">
|
||||
<div class="search">
|
||||
<label class="col-form-label" style="float: right">
|
||||
{{
|
||||
trans(ADD_PERSONS_SUGGESTED_COUNTER, {
|
||||
count: suggestedCounter,
|
||||
})
|
||||
}}
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="search-persons"
|
||||
name="query"
|
||||
v-model="query"
|
||||
:placeholder="trans(ADD_PERSONS_SEARCH_SOME_PERSONS)"
|
||||
ref="searchRef"
|
||||
/>
|
||||
<i class="fa fa-search fa-lg" />
|
||||
<i
|
||||
class="fa fa-times"
|
||||
v-if="queryLength >= 3"
|
||||
@click="resetSuggestion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" v-if="checkUniq === 'checkbox'">
|
||||
<div class="count">
|
||||
<span>
|
||||
<a v-if="suggestedCounter > 2" @click="selectAll">
|
||||
{{ trans(ACTION_CHECK_ALL) }}
|
||||
</a>
|
||||
<a v-if="selectedCounter > 0" @click="resetSelection">
|
||||
<i v-if="suggestedCounter > 2"> • </i>
|
||||
{{ trans(ACTION_RESET) }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="selectedCounter > 0">
|
||||
{{
|
||||
trans(ADD_PERSONS_SELECTED_COUNTER, { count: selectedCounter })
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="results">
|
||||
<person-suggestion
|
||||
v-for="item in selectedAndSuggested.slice().reverse()"
|
||||
:key="itemKey(item)"
|
||||
:item="item"
|
||||
:search="search"
|
||||
:type="checkUniq"
|
||||
@new-prior-suggestion="newPriorSuggestion"
|
||||
@update-selected="updateSelected"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="props.allowCreate && query.length > 0"
|
||||
class="create-button"
|
||||
>
|
||||
<button type="button" @click="emit('onAskForCreate', { query })">
|
||||
{{ trans(ONTHEFLY_CREATE_BUTTON, { q: query }) }}
|
||||
</button>
|
||||
<!--
|
||||
TODO remove this
|
||||
<on-the-fly
|
||||
v-if="
|
||||
queryLength >= 3 &&
|
||||
(options.type.includes('person') || options.type.includes('thirdparty'))
|
||||
"
|
||||
:button-text="trans(ONTHEFLY_CREATE_BUTTON, { q: query })"
|
||||
:allowed-types="options.type"
|
||||
:query="query"
|
||||
action="create"
|
||||
@save-form-on-the-fly="saveFormOnTheFly"
|
||||
ref="onTheFly"
|
||||
/>
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-create"
|
||||
@click.prevent="pickEntities"
|
||||
>
|
||||
{{ trans(ACTION_ADD) }}
|
||||
</button>
|
||||
</template>
|
||||
</modal>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, nextTick, watch, onMounted } from "vue";
|
||||
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
|
||||
import PersonSuggestion from "./PersonSuggestion.vue";
|
||||
import OnTheFly from "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue";
|
||||
import { searchEntities } from "ChillPersonAssets/vuejs/_api/AddPersons";
|
||||
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
|
||||
|
||||
import {
|
||||
trans,
|
||||
ADD_PERSONS_SUGGESTED_COUNTER,
|
||||
ADD_PERSONS_SEARCH_SOME_PERSONS,
|
||||
ADD_PERSONS_SELECTED_COUNTER,
|
||||
ONTHEFLY_CREATE_BUTTON,
|
||||
ACTION_CHECK_ALL,
|
||||
ACTION_RESET,
|
||||
ACTION_ADD,
|
||||
} from "translator";
|
||||
|
||||
import type {
|
||||
Suggestion,
|
||||
Search,
|
||||
AddPersonResult as OriginalResult,
|
||||
SearchOptions,
|
||||
EntitiesOrMe,
|
||||
} from "ChillPersonAssets/types";
|
||||
|
||||
type Result = OriginalResult & { addressId?: number };
|
||||
|
||||
interface Props {
|
||||
modalTitle: string;
|
||||
options: SearchOptions;
|
||||
suggested?: Suggestion[];
|
||||
selected?: Suggestion[];
|
||||
modalDialogClass?: string;
|
||||
allowCreate?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
suggested: () => [],
|
||||
selected: () => [],
|
||||
modalDialogClass: "modal-dialog-scrollable modal-xl",
|
||||
allowCreate: () => true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
/** @deprecated use 'onPickEntities' */
|
||||
(e: "addNewPersons", payload: { selected: Suggestion[] }): void;
|
||||
(e: "onPickEntities", payload: { selected: EntitiesOrMe[] }): void;
|
||||
(e: "onAskForCreate", payload: { query: string }): void;
|
||||
}>();
|
||||
|
||||
const searchRef = ref<HTMLInputElement | null>(null);
|
||||
const onTheFly = ref<InstanceType<typeof OnTheFly> | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
// give the focus on the search bar
|
||||
searchRef.value?.focus();
|
||||
});
|
||||
|
||||
const search = reactive({
|
||||
query: "" as string,
|
||||
previousQuery: "" as string,
|
||||
currentSearchQueryController: null as AbortController | null,
|
||||
suggested: (props.suggested ?? []) as Suggestion[],
|
||||
selected: (props.selected ?? []) as Suggestion[],
|
||||
priorSuggestion: {} as Partial<Suggestion>,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.selected,
|
||||
(newSelected) => {
|
||||
search.selected = newSelected ? [...newSelected] : [];
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.suggested,
|
||||
(newSuggested) => {
|
||||
search.suggested = newSuggested ? [...newSuggested] : [];
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const query = computed({
|
||||
get: () => search.query,
|
||||
set: (val: string) => setQuery(val),
|
||||
});
|
||||
const queryLength = computed(() => search.query.length);
|
||||
const suggestedCounter = computed(() => search.suggested.length);
|
||||
const selectedComputed = computed<Suggestion[]>(() => search.selected);
|
||||
const selectedCounter = computed(() => search.selected.length);
|
||||
|
||||
const checkUniq = computed(() =>
|
||||
props.options.uniq === true ? "radio" : "checkbox",
|
||||
);
|
||||
|
||||
const priorSuggestion = computed(() => search.priorSuggestion);
|
||||
const hasPriorSuggestion = computed(() => !!search.priorSuggestion.key);
|
||||
|
||||
const itemKey = (item: Suggestion) => item.result.type + item.result.id;
|
||||
|
||||
function addPriorSuggestion() {
|
||||
if (hasPriorSuggestion.value) {
|
||||
search.suggested.unshift(priorSuggestion.value as Suggestion);
|
||||
search.selected.unshift(priorSuggestion.value as Suggestion);
|
||||
newPriorSuggestion(null);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedAndSuggested = computed(() => {
|
||||
addPriorSuggestion();
|
||||
const uniqBy = (a: Suggestion[], key: (item: Suggestion) => string) => [
|
||||
...new Map(a.map((x) => [key(x), x])).values(),
|
||||
];
|
||||
const union = [
|
||||
...new Set([
|
||||
...search.suggested.slice().reverse(),
|
||||
...search.selected.slice().reverse(),
|
||||
]),
|
||||
];
|
||||
return uniqBy(union, (k: Suggestion) => k.key);
|
||||
});
|
||||
|
||||
function setQuery(q: string) {
|
||||
search.query = q;
|
||||
|
||||
if (search.currentSearchQueryController) {
|
||||
search.currentSearchQueryController.abort();
|
||||
search.currentSearchQueryController = null;
|
||||
}
|
||||
|
||||
if (q === "") {
|
||||
loadSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = q.length > 3 ? 300 : 700;
|
||||
|
||||
setTimeout(() => {
|
||||
if (q !== search.query) return;
|
||||
|
||||
search.currentSearchQueryController = new AbortController();
|
||||
|
||||
searchEntities(
|
||||
{ query: q, options: props.options },
|
||||
search.currentSearchQueryController.signal,
|
||||
)
|
||||
.then((suggested: Search) => {
|
||||
loadSuggestions(suggested.results);
|
||||
})
|
||||
.catch((error: DOMException) => {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function loadSuggestions(suggestedArr: Suggestion[]) {
|
||||
search.suggested = suggestedArr;
|
||||
search.suggested.forEach((item) => {
|
||||
item.key = itemKey(item);
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelected(value: Suggestion[]) {
|
||||
search.selected = value;
|
||||
}
|
||||
|
||||
function resetSuggestion() {
|
||||
search.query = "";
|
||||
search.suggested = [];
|
||||
}
|
||||
|
||||
function resetSelection() {
|
||||
search.selected = [];
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
resetSelection();
|
||||
resetSuggestion();
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
search.suggested.forEach((item) => {
|
||||
search.selected.push(item);
|
||||
});
|
||||
}
|
||||
|
||||
function newPriorSuggestion(entity: Result | null) {
|
||||
if (entity !== null) {
|
||||
const suggestion: Suggestion = {
|
||||
key: entity.type + entity.id,
|
||||
relevance: 0.5,
|
||||
result: entity,
|
||||
} as Suggestion;
|
||||
search.priorSuggestion = suggestion;
|
||||
} else {
|
||||
search.priorSuggestion = {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when the user clicks on the "add" button.
|
||||
*/
|
||||
function pickEntities(): void {
|
||||
emit("addNewPersons", { selected: search.selected });
|
||||
emit("onPickEntities", {
|
||||
selected: search.selected.map((s: Suggestion) => s.result),
|
||||
});
|
||||
search.query = "";
|
||||
emit("close");
|
||||
}
|
||||
|
||||
/*
|
||||
TODO remove this
|
||||
async function saveFormOnTheFly({ type, data }: { type: string; data: Result }) {
|
||||
try {
|
||||
if (type === 'person') {
|
||||
const responsePerson: Result = await makeFetch('POST', '/api/1.0/person/person.json', data);
|
||||
newPriorSuggestion(responsePerson);
|
||||
onTheFly.value?.closeModal();
|
||||
|
||||
if (data.addressId != null) {
|
||||
const household = { type: 'household' };
|
||||
const address = { id: data.addressId };
|
||||
try {
|
||||
const responseHousehold: Result = await makeFetch('POST', '/api/1.0/person/household.json', household);
|
||||
const member = {
|
||||
concerned: [
|
||||
{
|
||||
person: { type: 'person', id: responsePerson.id },
|
||||
start_date: { datetime: `${new Date().toISOString().split('T')[0]}T00:00:00+02:00` },
|
||||
holder: false,
|
||||
comment: null,
|
||||
},
|
||||
],
|
||||
destination: { type: 'household', id: responseHousehold.id },
|
||||
composition: null,
|
||||
};
|
||||
await makeFetch('POST', '/api/1.0/person/household/members/move.json', member);
|
||||
try {
|
||||
await makeFetch('POST', `/api/1.0/person/household/${responseHousehold.id}/address.json`, address);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
} else if (type === 'thirdparty') {
|
||||
const response: Result = await makeFetch('POST', '/api/1.0/thirdparty/thirdparty.json', data);
|
||||
newPriorSuggestion(response);
|
||||
onTheFly.value?.closeModal();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
defineExpose({ resetSearch });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
li.add-persons {
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
div.body-head {
|
||||
overflow-y: unset;
|
||||
div.modal-body:first-child {
|
||||
margin: auto 4em;
|
||||
div.search {
|
||||
position: relative;
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 1.2em 1.5em 1.2em 2.5em;
|
||||
}
|
||||
i {
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
padding: 0.65em 0;
|
||||
top: 50%;
|
||||
}
|
||||
i.fa-search {
|
||||
left: 0.5em;
|
||||
}
|
||||
i.fa-times {
|
||||
right: 1em;
|
||||
padding: 0.75em 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
div.modal-body:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
div.count {
|
||||
margin: -0.5em 0 0.7em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.create-button > a {
|
||||
margin-top: 0.5em;
|
||||
margin-left: 2.6em;
|
||||
}
|
||||
</style>
|
@@ -35,7 +35,7 @@ const { person, isCut = false, addAge = true } = toRefs(props);
|
||||
|
||||
const altNameLabel = computed(() => {
|
||||
if (!person.value.altNames) return "";
|
||||
return person.value.altNames.map((a: AltName) => a.label).join("");
|
||||
return person.value.altNames.map((a: AltName) => a.labels).join("");
|
||||
});
|
||||
|
||||
const altNameKey = computed(() => {
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="action === 'show'">
|
||||
<div v-if="action === 'show' && person !== null">
|
||||
<div class="flex-table">
|
||||
<person-render-box
|
||||
render="bloc"
|
||||
@@ -22,445 +22,48 @@
|
||||
</div>
|
||||
|
||||
<div v-else-if="action === 'edit' || action === 'create'">
|
||||
<div class="form-floating mb-3">
|
||||
<input
|
||||
class="form-control form-control-lg"
|
||||
id="lastname"
|
||||
v-model="lastName"
|
||||
:placeholder="trans(PERSON_MESSAGES_PERSON_LASTNAME)"
|
||||
@change="checkErrors"
|
||||
/>
|
||||
<label for="lastname">{{ trans(PERSON_MESSAGES_PERSON_LASTNAME) }}</label>
|
||||
</div>
|
||||
|
||||
<div v-if="queryItems">
|
||||
<ul class="list-suggest add-items inline">
|
||||
<li
|
||||
v-for="(qi, i) in queryItems"
|
||||
:key="i"
|
||||
@click="addQueryItem('lastName', qi)"
|
||||
>
|
||||
<span class="person-text">{{ qi }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<input
|
||||
class="form-control form-control-lg"
|
||||
id="firstname"
|
||||
v-model="firstName"
|
||||
:placeholder="trans(PERSON_MESSAGES_PERSON_FIRSTNAME)"
|
||||
@change="checkErrors"
|
||||
/>
|
||||
<label for="firstname">{{
|
||||
trans(PERSON_MESSAGES_PERSON_FIRSTNAME)
|
||||
}}</label>
|
||||
</div>
|
||||
|
||||
<div v-if="queryItems">
|
||||
<ul class="list-suggest add-items inline">
|
||||
<li
|
||||
v-for="(qi, i) in queryItems"
|
||||
:key="i"
|
||||
@click="addQueryItem('firstName', qi)"
|
||||
>
|
||||
<span class="person-text">{{ qi }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(a, i) in config.altNames"
|
||||
:key="a.key"
|
||||
class="form-floating mb-3"
|
||||
>
|
||||
<input
|
||||
class="form-control form-control-lg"
|
||||
:id="a.key"
|
||||
:value="personAltNamesLabels[i]"
|
||||
@input="onAltNameInput"
|
||||
/>
|
||||
<label :for="a.key">{{ localizeString(a.labels) }}</label>
|
||||
</div>
|
||||
|
||||
<!-- TODO fix placeholder if undefined
|
||||
-->
|
||||
<div class="form-floating mb-3">
|
||||
<select class="form-select form-select-lg" id="gender" v-model="gender">
|
||||
<option selected disabled>
|
||||
{{ trans(PERSON_MESSAGES_PERSON_GENDER_PLACEHOLDER) }}
|
||||
</option>
|
||||
<option v-for="g in config.genders" :value="g.id" :key="g.id">
|
||||
{{ g.label }}
|
||||
</option>
|
||||
</select>
|
||||
<label>{{ trans(PERSON_MESSAGES_PERSON_GENDER_TITLE) }}</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="form-floating mb-3"
|
||||
v-if="showCenters && config.centers.length > 1"
|
||||
>
|
||||
<select class="form-select form-select-lg" id="center" v-model="center">
|
||||
<option selected disabled>
|
||||
{{ trans(PERSON_MESSAGES_PERSON_CENTER_PLACEHOLDER) }}
|
||||
</option>
|
||||
<option v-for="c in config.centers" :value="c" :key="c.id">
|
||||
{{ c.name }}
|
||||
</option>
|
||||
</select>
|
||||
<label>{{ trans(PERSON_MESSAGES_PERSON_CENTER_TITLE) }}</label>
|
||||
</div>
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<select
|
||||
class="form-select form-select-lg"
|
||||
id="civility"
|
||||
v-model="civility"
|
||||
>
|
||||
<option selected disabled>
|
||||
{{ trans(PERSON_MESSAGES_PERSON_CIVILITY_PLACEHOLDER) }}
|
||||
</option>
|
||||
<option v-for="c in config.civilities" :value="c.id" :key="c.id">
|
||||
{{ localizeString(c.name) }}
|
||||
</option>
|
||||
</select>
|
||||
<label>{{ trans(PERSON_MESSAGES_PERSON_CIVILITY_TITLE) }}</label>
|
||||
</div>
|
||||
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text" id="phonenumber">
|
||||
<i class="fa fa-fw fa-phone"></i>
|
||||
</span>
|
||||
<input
|
||||
class="form-control form-control-lg"
|
||||
v-model="phonenumber"
|
||||
:placeholder="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)"
|
||||
:aria-label="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)"
|
||||
aria-describedby="phonenumber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text" id="mobilenumber">
|
||||
<i class="fa fa-fw fa-mobile"></i>
|
||||
</span>
|
||||
<input
|
||||
class="form-control form-control-lg"
|
||||
v-model="mobilenumber"
|
||||
:placeholder="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)"
|
||||
:aria-label="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)"
|
||||
aria-describedby="mobilenumber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text" id="email">
|
||||
<i class="fa fa-fw fa-at"></i>
|
||||
</span>
|
||||
<input
|
||||
class="form-control form-control-lg"
|
||||
v-model="email"
|
||||
:placeholder="trans(PERSON_MESSAGES_PERSON_EMAIL)"
|
||||
:aria-label="trans(PERSON_MESSAGES_PERSON_EMAIL)"
|
||||
aria-describedby="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="action === 'create'" class="input-group mb-3 form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
v-model="showAddressForm"
|
||||
name="showAddressForm"
|
||||
/>
|
||||
<label class="form-check-label">
|
||||
{{ trans(PERSON_MESSAGES_PERSON_ADDRESS_SHOW_ADDRESS_FORM) }}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="action === 'create' && showAddressFormValue"
|
||||
class="form-floating mb-3"
|
||||
>
|
||||
<p>{{ trans(PERSON_MESSAGES_PERSON_ADDRESS_WARNING) }}</p>
|
||||
<AddAddress
|
||||
:context="addAddress.context"
|
||||
:options="addAddress.options"
|
||||
:addressChangedCallback="submitNewAddress"
|
||||
ref="addAddress"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" v-if="errors.length">
|
||||
<ul>
|
||||
<li v-for="(e, i) in errors" :key="i">{{ e }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<PersonEdit
|
||||
:id="props.id"
|
||||
:type="props.type"
|
||||
:action="props.action"
|
||||
:query="props.query"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from "vue";
|
||||
import {
|
||||
getCentersForPersonCreation,
|
||||
getCivilities,
|
||||
getGenders,
|
||||
getPerson,
|
||||
getPersonAltNames,
|
||||
} from "../../_api/OnTheFly";
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { getPerson } from "../../_api/OnTheFly";
|
||||
import PersonRenderBox from "../Entity/PersonRenderBox.vue";
|
||||
import AddAddress from "ChillMainAssets/vuejs/Address/components/AddAddress.vue";
|
||||
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
|
||||
import {
|
||||
trans,
|
||||
PERSON_MESSAGES_PERSON_LASTNAME,
|
||||
PERSON_MESSAGES_PERSON_FIRSTNAME,
|
||||
PERSON_MESSAGES_PERSON_GENDER_PLACEHOLDER,
|
||||
PERSON_MESSAGES_PERSON_GENDER_TITLE,
|
||||
PERSON_MESSAGES_PERSON_CENTER_PLACEHOLDER,
|
||||
PERSON_MESSAGES_PERSON_CENTER_TITLE,
|
||||
PERSON_MESSAGES_PERSON_CIVILITY_PLACEHOLDER,
|
||||
PERSON_MESSAGES_PERSON_CIVILITY_TITLE,
|
||||
PERSON_MESSAGES_PERSON_PHONENUMBER,
|
||||
PERSON_MESSAGES_PERSON_MOBILENUMBER,
|
||||
PERSON_MESSAGES_PERSON_EMAIL,
|
||||
PERSON_MESSAGES_PERSON_ADDRESS_SHOW_ADDRESS_FORM,
|
||||
PERSON_MESSAGES_PERSON_ADDRESS_WARNING,
|
||||
} from "translator";
|
||||
import PersonEdit from "./PersonEdit.vue";
|
||||
import type { Person } from "ChillPersonAssets/types";
|
||||
|
||||
const props = defineProps({
|
||||
id: [String, Number],
|
||||
type: String,
|
||||
action: String,
|
||||
query: String,
|
||||
});
|
||||
|
||||
const person = reactive({
|
||||
type: "person",
|
||||
lastName: "",
|
||||
firstName: "",
|
||||
altNames: [],
|
||||
addressId: null,
|
||||
center: null,
|
||||
gender: null,
|
||||
civility: null,
|
||||
birthdate: null,
|
||||
phonenumber: "",
|
||||
mobilenumber: "",
|
||||
email: "",
|
||||
});
|
||||
|
||||
const config = reactive({
|
||||
altNames: [],
|
||||
civilities: [],
|
||||
centers: [],
|
||||
genders: [],
|
||||
});
|
||||
|
||||
const showCenters = ref(false);
|
||||
const showAddressFormValue = ref(false);
|
||||
const errors = ref([]);
|
||||
|
||||
const addAddress = reactive({
|
||||
options: {
|
||||
button: {
|
||||
text: { create: "person.address.create_address" },
|
||||
size: "btn-sm",
|
||||
},
|
||||
title: { create: "person.address.create_address" },
|
||||
},
|
||||
context: {
|
||||
target: {},
|
||||
edit: false,
|
||||
addressId: null,
|
||||
defaults: window.addaddress,
|
||||
},
|
||||
});
|
||||
|
||||
const firstName = computed({
|
||||
get: () => person.firstName,
|
||||
set: (value) => {
|
||||
person.firstName = value;
|
||||
},
|
||||
});
|
||||
const lastName = computed({
|
||||
get: () => person.lastName,
|
||||
set: (value) => {
|
||||
person.lastName = value;
|
||||
},
|
||||
});
|
||||
const gender = computed({
|
||||
get: () => (person.gender ? person.gender.id : null),
|
||||
set: (value) => {
|
||||
person.gender = { id: value, type: "chill_main_gender" };
|
||||
},
|
||||
});
|
||||
const civility = computed({
|
||||
get: () => (person.civility ? person.civility.id : null),
|
||||
set: (value) => {
|
||||
person.civility = { id: value, type: "chill_main_civility" };
|
||||
},
|
||||
});
|
||||
const birthDate = computed({
|
||||
get: () => (person.birthdate ? person.birthdate.datetime.split("T")[0] : ""),
|
||||
set: (value) => {
|
||||
if (person.birthdate) {
|
||||
person.birthdate.datetime = value + "T00:00:00+0100";
|
||||
} else {
|
||||
person.birthdate = { datetime: value + "T00:00:00+0100" };
|
||||
}
|
||||
},
|
||||
});
|
||||
const phonenumber = computed({
|
||||
get: () => person.phonenumber,
|
||||
set: (value) => {
|
||||
person.phonenumber = value;
|
||||
},
|
||||
});
|
||||
const mobilenumber = computed({
|
||||
get: () => person.mobilenumber,
|
||||
set: (value) => {
|
||||
person.mobilenumber = value;
|
||||
},
|
||||
});
|
||||
const email = computed({
|
||||
get: () => person.email,
|
||||
set: (value) => {
|
||||
person.email = value;
|
||||
},
|
||||
});
|
||||
const showAddressForm = computed({
|
||||
get: () => showAddressFormValue.value,
|
||||
set: (value) => {
|
||||
showAddressFormValue.value = value;
|
||||
},
|
||||
});
|
||||
const center = computed({
|
||||
get: () => {
|
||||
const c = config.centers.find(
|
||||
(c) => person.center !== null && person.center.id === c.id,
|
||||
);
|
||||
return typeof c === "undefined" ? null : c;
|
||||
},
|
||||
set: (value) => {
|
||||
person.center = { id: value.id, type: value.type };
|
||||
},
|
||||
});
|
||||
|
||||
const genderClass = computed(() => {
|
||||
switch (person.gender && person.gender.id) {
|
||||
case "woman":
|
||||
return "fa-venus";
|
||||
case "man":
|
||||
return "fa-mars";
|
||||
case "both":
|
||||
return "fa-neuter";
|
||||
case "unknown":
|
||||
return "fa-genderless";
|
||||
default:
|
||||
return "fa-genderless";
|
||||
}
|
||||
});
|
||||
|
||||
const genderTranslation = computed(() => {
|
||||
switch (person.gender && person.gender.genderTranslation) {
|
||||
case "woman":
|
||||
return PERSON_MESSAGES_PERSON_GENDER_WOMAN;
|
||||
case "man":
|
||||
return PERSON_MESSAGES_PERSON_GENDER_MAN;
|
||||
case "neutral":
|
||||
return PERSON_MESSAGES_PERSON_GENDER_NEUTRAL;
|
||||
case "unknown":
|
||||
return PERSON_MESSAGES_PERSON_GENDER_UNKNOWN;
|
||||
default:
|
||||
return PERSON_MESSAGES_PERSON_GENDER_UNKNOWN;
|
||||
}
|
||||
});
|
||||
const feminized = computed(() =>
|
||||
person.gender && person.gender.id === "woman" ? "e" : "",
|
||||
);
|
||||
const personAltNamesLabels = computed(() =>
|
||||
person.altNames.map((a) => (a ? a.label : "")),
|
||||
);
|
||||
const queryItems = computed(() =>
|
||||
props.query ? props.query.split(" ") : null,
|
||||
);
|
||||
|
||||
function checkErrors() {
|
||||
errors.value = [];
|
||||
if (person.lastName === "") {
|
||||
errors.value.push("Le nom ne doit pas être vide.");
|
||||
}
|
||||
if (person.firstName === "") {
|
||||
errors.value.push("Le prénom ne doit pas être vide.");
|
||||
}
|
||||
if (!person.gender) {
|
||||
errors.value.push("Le genre doit être renseigné");
|
||||
}
|
||||
if (showCenters.value && person.center === null) {
|
||||
errors.value.push("Le centre doit être renseigné");
|
||||
}
|
||||
interface Props {
|
||||
id: string | number;
|
||||
type?: string;
|
||||
action: "show" | "edit" | "create";
|
||||
query?: string;
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
getPerson(props.id).then((p) => {
|
||||
Object.assign(person, p);
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const person = ref<Person | null>(null);
|
||||
|
||||
function loadData(): void {
|
||||
if (props.id === undefined || props.id === null) {
|
||||
return;
|
||||
}
|
||||
const idNum = typeof props.id === "string" ? Number(props.id) : props.id;
|
||||
if (!Number.isFinite(idNum)) {
|
||||
return;
|
||||
}
|
||||
getPerson(idNum as number).then((p) => {
|
||||
person.value = p;
|
||||
});
|
||||
}
|
||||
|
||||
function onAltNameInput(event) {
|
||||
const key = event.target.id;
|
||||
const label = event.target.value;
|
||||
let updateAltNames = person.altNames.filter((a) => a.key !== key);
|
||||
updateAltNames.push({ key: key, label: label });
|
||||
person.altNames = updateAltNames;
|
||||
}
|
||||
|
||||
function addQueryItem(field, queryItem) {
|
||||
switch (field) {
|
||||
case "lastName":
|
||||
person.lastName = person.lastName
|
||||
? (person.lastName += ` ${queryItem}`)
|
||||
: queryItem;
|
||||
break;
|
||||
case "firstName":
|
||||
person.firstName = person.firstName
|
||||
? (person.firstName += ` ${queryItem}`)
|
||||
: queryItem;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function submitNewAddress(payload) {
|
||||
person.addressId = payload.addressId;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getPersonAltNames().then((altNames) => {
|
||||
config.altNames = altNames;
|
||||
});
|
||||
getCivilities().then((civilities) => {
|
||||
if ("results" in civilities) {
|
||||
config.civilities = civilities.results;
|
||||
}
|
||||
});
|
||||
getGenders().then((genders) => {
|
||||
if ("results" in genders) {
|
||||
config.genders = genders.results;
|
||||
}
|
||||
});
|
||||
if (props.action !== "create") {
|
||||
loadData();
|
||||
} else {
|
||||
getCentersForPersonCreation().then((params) => {
|
||||
config.centers = params.centers.filter((c) => c.isActive);
|
||||
showCenters.value = params.showCenters;
|
||||
if (showCenters.value && config.centers.length === 1) {
|
||||
person.center = config.centers[0];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose(genderClass, genderTranslation, feminized, birthDate);
|
||||
</script>
|
||||
|
@@ -0,0 +1,707 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-3">
|
||||
<div class="input-group has-validation">
|
||||
<div class="form-floating">
|
||||
<input
|
||||
class="form-control form-control-lg"
|
||||
:class="{ 'is-invalid': hasViolation('lastName') }"
|
||||
id="lastname"
|
||||
v-model="lastName"
|
||||
:placeholder="trans(PERSON_MESSAGES_PERSON_LASTNAME)"
|
||||
/>
|
||||
<label for="lastname">{{
|
||||
trans(PERSON_MESSAGES_PERSON_LASTNAME)
|
||||
}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="err in violationTitles('lastName')"
|
||||
class="invalid-feedback was-validated-force"
|
||||
>
|
||||
{{ err }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="queryItems">
|
||||
<ul class="list-suggest add-items inline">
|
||||
<li
|
||||
v-for="(qi, i) in queryItems"
|
||||
:key="i"
|
||||
@click="addQueryItem('lastName', qi)"
|
||||
>
|
||||
<span class="person-text">{{ qi }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="input-group has-validation">
|
||||
<div class="form-floating">
|
||||
<input
|
||||
class="form-control form-control-lg"
|
||||
:class="{ 'is-invalid': hasViolation('firstName') }"
|
||||
id="firstname"
|
||||
v-model="firstName"
|
||||
:placeholder="trans(PERSON_MESSAGES_PERSON_FIRSTNAME)"
|
||||
/>
|
||||
<label for="firstname">{{
|
||||
trans(PERSON_MESSAGES_PERSON_FIRSTNAME)
|
||||
}}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="err in violationTitles('firstName')"
|
||||
class="invalid-feedback was-validated-force"
|
||||
>
|
||||
{{ err }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="queryItems">
|
||||
<ul class="list-suggest add-items inline">
|
||||
<li
|
||||
v-for="(qi, i) in queryItems"
|
||||
:key="i"
|
||||
@click="addQueryItem('firstName', qi)"
|
||||
>
|
||||
<span class="person-text">{{ qi }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-for="(a, i) in config.altNames" :key="a.key" class="mb-3">
|
||||
<div class="input-group has-validation">
|
||||
<div class="form-floating">
|
||||
<input
|
||||
class="form-control form-control-lg"
|
||||
:id="a.key"
|
||||
:name="'label_' + a.key"
|
||||
value=""
|
||||
@input="onAltNameInput($event, a.key)"
|
||||
/>
|
||||
<label :for="'label_' + a.key">{{ localizeString(a.labels) }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="worker in config.identifiers"
|
||||
:key="worker.definition_id"
|
||||
class="mb-3"
|
||||
>
|
||||
<div class="input-group has-validation">
|
||||
<div class="form-floating">
|
||||
<input
|
||||
class="form-control form-control-lg"
|
||||
:class="{'is-invalid': hasViolationWithParameter('identifiers', 'definition_id', worker.definition_id.toString())}"
|
||||
type="text"
|
||||
:name="'worker_' + worker.definition_id"
|
||||
:placeholder="localizeString(worker.label)"
|
||||
@input="onIdentifierInput($event, worker.definition_id)"
|
||||
/>
|
||||
<label :for="'worker_' + worker.definition_id">{{
|
||||
localizeString(worker.label)
|
||||
}}</label>
|
||||
</div>
|
||||
<div
|
||||
v-for="err in violationTitlesWithParameter('identifiers', 'definition_id', worker.definition_id.toString())"
|
||||
class="invalid-feedback was-validated-force"
|
||||
>
|
||||
{{ err }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="input-group has-validation">
|
||||
<div class="form-floating">
|
||||
<select
|
||||
class="form-select form-select-lg"
|
||||
:class="{ 'is-invalid': hasViolation('gender') }"
|
||||
id="gender"
|
||||
v-model="gender"
|
||||
>
|
||||
<option selected disabled>
|
||||
{{ trans(PERSON_MESSAGES_PERSON_GENDER_PLACEHOLDER) }}
|
||||
</option>
|
||||
<option v-for="g in config.genders" :value="g.id" :key="g.id">
|
||||
{{ g.label }}
|
||||
</option>
|
||||
</select>
|
||||
<label for="gender" class="form-label">{{
|
||||
trans(PERSON_MESSAGES_PERSON_GENDER_TITLE)
|
||||
}}</label>
|
||||
</div>
|
||||
<div
|
||||
v-for="err in violationTitles('gender')"
|
||||
class="invalid-feedback was-validated-force"
|
||||
>
|
||||
{{ err }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" v-if="showCenters && config.centers.length > 1">
|
||||
<div class="input-group">
|
||||
<div class="form-floating">
|
||||
<select
|
||||
class="form-select form-select-lg"
|
||||
:class="{ 'is-invalid': hasViolation('center') }"
|
||||
id="center"
|
||||
v-model="center"
|
||||
>
|
||||
<option selected disabled>
|
||||
{{ trans(PERSON_MESSAGES_PERSON_CENTER_PLACEHOLDER) }}
|
||||
</option>
|
||||
<option v-for="c in config.centers" :value="c" :key="c.id">
|
||||
{{ c.name }}
|
||||
</option>
|
||||
</select>
|
||||
<label for="center">{{ trans(PERSON_MESSAGES_PERSON_CENTER_TITLE) }}</label>
|
||||
</div>
|
||||
<div
|
||||
v-for="err in violationTitles('center')"
|
||||
class="invalid-feedback was-validated-force"
|
||||
>
|
||||
{{ err }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="input-group has-validation">
|
||||
<div class="form-floating">
|
||||
<select
|
||||
class="form-select form-select-lg"
|
||||
:class="{ 'is-invalid': hasViolation('civility') }"
|
||||
id="civility"
|
||||
v-model="civility"
|
||||
>
|
||||
<option selected disabled>
|
||||
{{ trans(PERSON_MESSAGES_PERSON_CIVILITY_PLACEHOLDER) }}
|
||||
</option>
|
||||
<option v-for="c in config.civilities" :value="c.id" :key="c.id">
|
||||
{{ localizeString(c.name) }}
|
||||
</option>
|
||||
</select>
|
||||
<label for="civility">{{ trans(PERSON_MESSAGES_PERSON_CIVILITY_TITLE) }}</label>
|
||||
</div>
|
||||
<div
|
||||
v-for="err in violationTitles('civility')"
|
||||
class="invalid-feedback was-validated-force"
|
||||
>
|
||||
{{ err }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="input-group has-validation">
|
||||
<span class="input-group-text">
|
||||
<i class="bi bi-cake2-fill"></i>
|
||||
</span>
|
||||
<div class="form-floating">
|
||||
<input
|
||||
class="form-control form-control-lg"
|
||||
:class="{ 'is-invalid': hasViolation('birthdate') }"
|
||||
name="birthdate"
|
||||
type="date"
|
||||
v-model="birthDate"
|
||||
:placeholder="trans(BIRTHDATE)"
|
||||
:aria-label="trans(BIRTHDATE)"
|
||||
/>
|
||||
<label for="birthdate">{{ trans(BIRTHDATE) }}</label>
|
||||
</div>
|
||||
<div
|
||||
v-for="err in violationTitles('birthdate')"
|
||||
class="invalid-feedback was-validated-force"
|
||||
>
|
||||
{{ err }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="input-group has-validation">
|
||||
<span class="input-group-text" id="phonenumber">
|
||||
<i class="fa fa-fw fa-phone"></i>
|
||||
</span>
|
||||
<div class="form-floating">
|
||||
<input
|
||||
class="form-control form-control-lg"
|
||||
:class="{ 'is-invalid': hasViolation('phonenumber') }"
|
||||
v-model="phonenumber"
|
||||
:placeholder="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)"
|
||||
:aria-label="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)"
|
||||
aria-describedby="phonenumber"
|
||||
/>
|
||||
<label for="phonenumber">{{ trans(PERSON_MESSAGES_PERSON_PHONENUMBER) }}</label>
|
||||
</div>
|
||||
<div
|
||||
v-for="err in violationTitles('phonenumber')"
|
||||
class="invalid-feedback was-validated-force"
|
||||
>
|
||||
{{ err }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="input-group has-validation">
|
||||
<span class="input-group-text" id="mobilenumber">
|
||||
<i class="fa fa-fw fa-mobile"></i>
|
||||
</span>
|
||||
<div class="form-floating">
|
||||
<input
|
||||
class="form-control form-control-lg"
|
||||
:class="{ 'is-invalid': hasViolation('mobilenumber') }"
|
||||
v-model="mobilenumber"
|
||||
:placeholder="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)"
|
||||
:aria-label="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)"
|
||||
aria-describedby="mobilenumber"
|
||||
/>
|
||||
<label for="mobilenumber">{{ trans(PERSON_MESSAGES_PERSON_MOBILENUMBER) }}</label>
|
||||
</div>
|
||||
<div
|
||||
v-for="err in violationTitles('mobilenumber')"
|
||||
class="invalid-feedback was-validated-force"
|
||||
>
|
||||
{{ err }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="input-group has-validation">
|
||||
<span class="input-group-text" id="email">
|
||||
<i class="fa fa-fw fa-at"></i>
|
||||
</span>
|
||||
<div class="form-floating">
|
||||
<input
|
||||
class="form-control form-control-lg"
|
||||
:class="{ 'is-invalid': hasViolation('email') }"
|
||||
v-model="email"
|
||||
:placeholder="trans(PERSON_MESSAGES_PERSON_EMAIL)"
|
||||
:aria-label="trans(PERSON_MESSAGES_PERSON_EMAIL)"
|
||||
aria-describedby="email"
|
||||
/>
|
||||
<label for="email">{{ trans(PERSON_MESSAGES_PERSON_EMAIL) }}</label>
|
||||
</div>
|
||||
<div
|
||||
v-for="err in violationTitles('email')"
|
||||
class="invalid-feedback was-validated-force"
|
||||
>
|
||||
{{ err }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="action === 'create'" class="input-group mb-3 form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
v-model="showAddressForm"
|
||||
name="showAddressForm"
|
||||
/>
|
||||
<label class="form-check-label">
|
||||
{{ trans(PERSON_MESSAGES_PERSON_ADDRESS_SHOW_ADDRESS_FORM) }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="action === 'create' && showAddressFormValue"
|
||||
class="form-floating mb-3"
|
||||
>
|
||||
<p>{{ trans(PERSON_MESSAGES_PERSON_ADDRESS_WARNING) }}</p>
|
||||
<AddAddress
|
||||
:context="addAddress.context"
|
||||
:options="addAddress.options"
|
||||
:addressChangedCallback="submitNewAddress"
|
||||
ref="addAddress"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" v-if="errors.length">
|
||||
<ul>
|
||||
<li v-for="(e, i) in errors" :key="i">{{ e }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from "vue";
|
||||
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
|
||||
import AddAddress from "ChillMainAssets/vuejs/Address/components/AddAddress.vue";
|
||||
import {
|
||||
createPerson,
|
||||
getCentersForPersonCreation,
|
||||
getCivilities,
|
||||
getGenders,
|
||||
getPerson,
|
||||
getPersonAltNames,
|
||||
getPersonIdentifiers,
|
||||
WritePersonViolationMap,
|
||||
} from "../../_api/OnTheFly";
|
||||
import {
|
||||
trans,
|
||||
BIRTHDATE,
|
||||
PERSON_EDIT_ERROR_WHILE_SAVING,
|
||||
PERSON_MESSAGES_PERSON_LASTNAME,
|
||||
PERSON_MESSAGES_PERSON_FIRSTNAME,
|
||||
PERSON_MESSAGES_PERSON_GENDER_PLACEHOLDER,
|
||||
PERSON_MESSAGES_PERSON_GENDER_TITLE,
|
||||
PERSON_MESSAGES_PERSON_CENTER_PLACEHOLDER,
|
||||
PERSON_MESSAGES_PERSON_CENTER_TITLE,
|
||||
PERSON_MESSAGES_PERSON_CIVILITY_PLACEHOLDER,
|
||||
PERSON_MESSAGES_PERSON_CIVILITY_TITLE,
|
||||
PERSON_MESSAGES_PERSON_PHONENUMBER,
|
||||
PERSON_MESSAGES_PERSON_MOBILENUMBER,
|
||||
PERSON_MESSAGES_PERSON_EMAIL,
|
||||
PERSON_MESSAGES_PERSON_ADDRESS_SHOW_ADDRESS_FORM,
|
||||
PERSON_MESSAGES_PERSON_ADDRESS_WARNING,
|
||||
} from "translator";
|
||||
import {
|
||||
Center,
|
||||
Civility,
|
||||
Gender,
|
||||
DateTimeWrite, ValidationExceptionInterface,
|
||||
} from "ChillMainAssets/types";
|
||||
import {
|
||||
AltName,
|
||||
Person,
|
||||
PersonWrite,
|
||||
PersonIdentifierWorker,
|
||||
type Suggestion,
|
||||
type EntitiesOrMe,
|
||||
} from "ChillPersonAssets/types";
|
||||
import {
|
||||
isValidationException,
|
||||
} from "ChillMainAssets/lib/api/apiMethods";
|
||||
import {useToast} from "vue-toast-notification";
|
||||
import {getTimezoneOffsetString, ISOToDate} from "ChillMainAssets/chill/js/date";
|
||||
|
||||
interface PersonEditComponentConfig {
|
||||
id?: number | null;
|
||||
type?: string;
|
||||
action: "edit" | "create";
|
||||
query: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<PersonEditComponentConfig>(), {
|
||||
id: null,
|
||||
type: "TODO",
|
||||
});
|
||||
|
||||
const emit =
|
||||
defineEmits<(e: "onPersonCreated", payload: { person: Person }) => void>();
|
||||
|
||||
defineExpose({ postPerson });
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const person = reactive<PersonWrite>({
|
||||
type: "person",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
altNames: [],
|
||||
// address: null,
|
||||
birthdate: null,
|
||||
deathdate: null,
|
||||
phonenumber: "",
|
||||
mobilenumber: "",
|
||||
email: "",
|
||||
gender: null,
|
||||
center: null,
|
||||
civility: null,
|
||||
identifiers: [],
|
||||
});
|
||||
|
||||
const config = reactive<{
|
||||
altNames: AltName[];
|
||||
civilities: Civility[];
|
||||
centers: Center[];
|
||||
genders: Gender[];
|
||||
identifiers: PersonIdentifierWorker[];
|
||||
}>({
|
||||
altNames: [],
|
||||
civilities: [],
|
||||
centers: [],
|
||||
genders: [],
|
||||
identifiers: [],
|
||||
});
|
||||
|
||||
const showCenters = ref(false);
|
||||
const showAddressFormValue = ref(false);
|
||||
const errors = ref<string[]>([]);
|
||||
|
||||
const addAddress = reactive({
|
||||
options: {
|
||||
button: {
|
||||
text: { create: "person.address.create_address" },
|
||||
size: "btn-sm",
|
||||
},
|
||||
title: { create: "person.address.create_address" },
|
||||
},
|
||||
context: {
|
||||
target: {},
|
||||
edit: false,
|
||||
addressId: null as number | null,
|
||||
defaults: (window as any).addaddress,
|
||||
},
|
||||
});
|
||||
|
||||
const firstName = computed({
|
||||
get: () => person.firstName,
|
||||
set: (value: string) => {
|
||||
person.firstName = value;
|
||||
},
|
||||
});
|
||||
const lastName = computed({
|
||||
get: () => person.lastName,
|
||||
set: (value: string) => {
|
||||
person.lastName = value;
|
||||
},
|
||||
});
|
||||
const gender = computed({
|
||||
get: () => (person.gender ? person.gender.id : null),
|
||||
set: (value: string | null) => {
|
||||
person.gender = value
|
||||
? { id: Number.parseInt(value), type: "chill_main_gender" }
|
||||
: null;
|
||||
},
|
||||
});
|
||||
const civility = computed({
|
||||
get: () => (person.civility ? person.civility.id : null),
|
||||
set: (value: number | null) => {
|
||||
person.civility =
|
||||
value !== null ? { id: value, type: "chill_main_civility" } : null;
|
||||
},
|
||||
});
|
||||
const birthDate = computed({
|
||||
get: () => (person.birthdate ? person.birthdate.datetime.split("T")[0] : ""),
|
||||
set: (value: string) => {
|
||||
const date = ISOToDate(value);
|
||||
if (null === date) {
|
||||
person.birthdate = null;
|
||||
return;
|
||||
}
|
||||
const offset = getTimezoneOffsetString(date, Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
if (person.birthdate) {
|
||||
person.birthdate.datetime = value + "T00:00:00" + offset;
|
||||
} else {
|
||||
person.birthdate = { datetime: value + "T00:00:00" + offset };
|
||||
}
|
||||
}
|
||||
});
|
||||
const phonenumber = computed({
|
||||
get: () => person.phonenumber,
|
||||
set: (value: string) => {
|
||||
person.phonenumber = value;
|
||||
},
|
||||
});
|
||||
const mobilenumber = computed({
|
||||
get: () => person.mobilenumber,
|
||||
set: (value: string) => {
|
||||
person.mobilenumber = value;
|
||||
},
|
||||
});
|
||||
const email = computed({
|
||||
get: () => person.email,
|
||||
set: (value: string) => {
|
||||
person.email = value;
|
||||
},
|
||||
});
|
||||
const showAddressForm = computed({
|
||||
get: () => showAddressFormValue.value,
|
||||
set: (value: boolean) => {
|
||||
showAddressFormValue.value = value;
|
||||
},
|
||||
});
|
||||
const center = computed({
|
||||
get: () => {
|
||||
const c = config.centers.find(
|
||||
(c) => person.center !== null && person.center.id === c.id,
|
||||
);
|
||||
return typeof c === "undefined" ? null : c;
|
||||
},
|
||||
set: (value: Center | null) => {
|
||||
if (null !== value) {
|
||||
person.center = {
|
||||
id: value.id,
|
||||
type: value.type,
|
||||
};
|
||||
} else {
|
||||
person.center = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Find the query items to display for suggestion
|
||||
*/
|
||||
const queryItems = computed(() => {
|
||||
const words: null | string[] = props.query ? props.query.split(" ") : null;
|
||||
|
||||
if (null === words) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstNameWords = (person.firstName || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(" ");
|
||||
const lastNameWords = (person.lastName || "").trim().toLowerCase().split(" ");
|
||||
|
||||
return words
|
||||
.filter((word) => !firstNameWords.includes(word.toLowerCase()))
|
||||
.filter((word) => !lastNameWords.includes(word.toLowerCase()));
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
if (props.id !== undefined && props.id !== null) {
|
||||
const person = await getPerson(props.id);
|
||||
}
|
||||
}
|
||||
|
||||
function onAltNameInput(event: Event, key: string): void {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const value = target.value;
|
||||
const updateAltNamesKey = person.altNames.findIndex((a) => a.key === key);
|
||||
if (-1 === updateAltNamesKey) {
|
||||
person.altNames.push({ key, value });
|
||||
} else {
|
||||
person.altNames[updateAltNamesKey].value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function onIdentifierInput(event: Event, definition_id: number): void {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const value = target.value;
|
||||
const updateIdentifierKey = person.identifiers.findIndex(
|
||||
(w) => w.definition_id === definition_id,
|
||||
);
|
||||
if (-1 === updateIdentifierKey) {
|
||||
person.identifiers.push({
|
||||
type: "person_identifier",
|
||||
definition_id,
|
||||
value: { content: value },
|
||||
});
|
||||
} else {
|
||||
person.identifiers[updateIdentifierKey].value = { content: value };
|
||||
}
|
||||
}
|
||||
|
||||
function addQueryItem(field: "lastName" | "firstName", queryItem: string) {
|
||||
switch (field) {
|
||||
case "lastName":
|
||||
person.lastName = person.lastName
|
||||
? (person.lastName += ` ${queryItem}`)
|
||||
: queryItem;
|
||||
break;
|
||||
case "firstName":
|
||||
person.firstName = person.firstName
|
||||
? (person.firstName += ` ${queryItem}`)
|
||||
: queryItem;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
type WritePersonViolationKey = Extract<keyof WritePersonViolationMap, string>;
|
||||
const violationsList = ref<ValidationExceptionInterface<WritePersonViolationMap>|null>(null);
|
||||
|
||||
function violationTitles<P extends WritePersonViolationKey>(property: P): string[] {
|
||||
if (null === violationsList.value) {
|
||||
return [];
|
||||
}
|
||||
return violationsList.value.violationsByNormalizedProperty(property).map((v) => v.title);
|
||||
|
||||
}
|
||||
function violationTitlesWithParameter<
|
||||
P extends WritePersonViolationKey,
|
||||
Param extends Extract<keyof WritePersonViolationMap[P], string>
|
||||
>(
|
||||
property: P,
|
||||
with_parameter: Param,
|
||||
with_parameter_value: WritePersonViolationMap[P][Param],
|
||||
): string[] {
|
||||
if (violationsList.value === null) {
|
||||
return [];
|
||||
}
|
||||
return violationsList.value.violationsByNormalizedPropertyAndParams(property, with_parameter, with_parameter_value)
|
||||
.map((v) => v.title);
|
||||
}
|
||||
|
||||
|
||||
function hasViolation<P extends WritePersonViolationKey>(property: P): boolean {
|
||||
return violationTitles(property).length > 0;
|
||||
}
|
||||
function hasViolationWithParameter<
|
||||
P extends WritePersonViolationKey,
|
||||
Param extends Extract<keyof WritePersonViolationMap[P], string>
|
||||
>(
|
||||
property: P,
|
||||
with_parameter: Param,
|
||||
with_parameter_value: WritePersonViolationMap[P][Param],
|
||||
): boolean {
|
||||
return violationTitlesWithParameter(property, with_parameter, with_parameter_value).length > 0;
|
||||
}
|
||||
|
||||
function submitNewAddress(payload: { addressId: number }) {
|
||||
// person.addressId = payload.addressId;
|
||||
}
|
||||
|
||||
async function postPerson(): Promise<void> {
|
||||
try {
|
||||
const createdPerson = await createPerson(person);
|
||||
|
||||
emit("onPersonCreated", { person: createdPerson });
|
||||
} catch (e: unknown) {
|
||||
if (isValidationException<WritePersonViolationMap>(e)) {
|
||||
violationsList.value = e;
|
||||
} else {
|
||||
toast.error(trans(PERSON_EDIT_ERROR_WHILE_SAVING));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getPersonAltNames().then((altNames) => {
|
||||
config.altNames = altNames;
|
||||
});
|
||||
getCivilities().then((civilities) => {
|
||||
config.civilities = civilities;
|
||||
});
|
||||
getGenders().then((genders) => {
|
||||
config.genders = genders;
|
||||
});
|
||||
getPersonIdentifiers().then((identifiers) => {
|
||||
config.identifiers = identifiers.filter(
|
||||
(w: PersonIdentifierWorker) =>
|
||||
w.presence === 'ON_CREATION' || w.presence === 'REQUIRED'
|
||||
);
|
||||
});
|
||||
if (props.action !== "create") {
|
||||
loadData();
|
||||
} else {
|
||||
getCentersForPersonCreation().then((params) => {
|
||||
config.centers = params.centers.filter((c: Center) => c.isActive);
|
||||
showCenters.value = params.showCenters;
|
||||
if (showCenters.value && config.centers.length === 1) {
|
||||
// if there is only one center, preselect it
|
||||
person.center = {
|
||||
id: config.centers[0].id,
|
||||
type: config.centers[0].type ?? "center",
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.was-validated-force {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
@@ -88,6 +88,15 @@
|
||||
<div data-suggest-container="{{ altName.vars.full_name|e('html_attr') }}" class="col-sm-8" style="margin-left: auto;"></div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if form.identifiers|length > 0 %}
|
||||
{% for f in form.identifiers %}
|
||||
<div class="row mb-1" style="display:flex;">
|
||||
{{ form_row(f) }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{{ form_widget(form.identifiers) }}
|
||||
{% endif %}
|
||||
|
||||
{{ form_row(form.gender, { 'label' : 'Gender'|trans }) }}
|
||||
|
||||
|
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Serializer\Normalizer;
|
||||
|
||||
use Chill\MainBundle\Entity\Center;
|
||||
use Chill\MainBundle\Entity\Civility;
|
||||
use Chill\MainBundle\Entity\Gender;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Chill\PersonBundle\Entity\PersonAltName;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
|
||||
use libphonenumber\PhoneNumber;
|
||||
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait;
|
||||
|
||||
/**
|
||||
* Denormalize a Person entity from a JSON-like array structure, creating or updating an existing instance.
|
||||
*
|
||||
* To find an existing instance by his id, see the @see{PersonJsonReadDenormalizer}.
|
||||
*/
|
||||
final class PersonJsonDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
|
||||
{
|
||||
use DenormalizerAwareTrait;
|
||||
use ObjectToPopulateTrait;
|
||||
|
||||
public function __construct(private readonly PersonIdentifierManagerInterface $personIdentifierManager) {}
|
||||
|
||||
public function denormalize($data, string $type, ?string $format = null, array $context = []): Person
|
||||
{
|
||||
$person = $this->extractObjectToPopulate($type, $context);
|
||||
|
||||
if (null === $person) {
|
||||
$person = new Person();
|
||||
}
|
||||
|
||||
// Setters applied directly per known field for readability
|
||||
if (\array_key_exists('firstName', $data)) {
|
||||
$person->setFirstName($data['firstName']);
|
||||
}
|
||||
|
||||
if (\array_key_exists('lastName', $data)) {
|
||||
$person->setLastName($data['lastName']);
|
||||
}
|
||||
|
||||
if (\array_key_exists('phonenumber', $data)) {
|
||||
$person->setPhonenumber($this->denormalizer->denormalize($data['phonenumber'], PhoneNumber::class, $format, $context));
|
||||
}
|
||||
|
||||
if (\array_key_exists('mobilenumber', $data)) {
|
||||
$person->setMobilenumber($this->denormalizer->denormalize($data['mobilenumber'], PhoneNumber::class, $format, $context));
|
||||
}
|
||||
|
||||
if (\array_key_exists('gender', $data) && null !== $data['gender']) {
|
||||
$gender = $this->denormalizer->denormalize($data['gender'], Gender::class, $format, []);
|
||||
$person->setGender($gender);
|
||||
}
|
||||
|
||||
if (\array_key_exists('birthdate', $data)) {
|
||||
$object = $this->denormalizer->denormalize($data['birthdate'], \DateTime::class, $format, $context);
|
||||
$person->setBirthdate($object);
|
||||
}
|
||||
|
||||
if (\array_key_exists('deathdate', $data)) {
|
||||
$object = $this->denormalizer->denormalize($data['deathdate'], \DateTimeImmutable::class, $format, $context);
|
||||
$person->setDeathdate($object);
|
||||
}
|
||||
|
||||
if (\array_key_exists('center', $data)) {
|
||||
$object = $this->denormalizer->denormalize($data['center'], Center::class, $format, $context);
|
||||
$person->setCenter($object);
|
||||
}
|
||||
|
||||
if (\array_key_exists('altNames', $data)) {
|
||||
foreach ($data['altNames'] as $altNameData) {
|
||||
if (!array_key_exists('key', $altNameData)
|
||||
|| !array_key_exists('value', $altNameData)
|
||||
|| '' === trim($altNameData['key'])
|
||||
) {
|
||||
throw new UnexpectedValueException('format for alt name is not correct');
|
||||
}
|
||||
$altNameKey = $altNameData['key'];
|
||||
$altNameValue = $altNameData['value'];
|
||||
|
||||
$altName = $person->getAltNames()->findFirst(fn (int $key, PersonAltName $personAltName) => $personAltName->getKey() === $altNameKey);
|
||||
if (null === $altName) {
|
||||
$altName = new PersonAltName();
|
||||
$person->addAltName($altName);
|
||||
}
|
||||
$altName->setKey($altNameKey)->setLabel($altNameValue);
|
||||
}
|
||||
}
|
||||
|
||||
if (\array_key_exists('identifiers', $data)) {
|
||||
foreach ($data['identifiers'] as $identifierData) {
|
||||
if (!array_key_exists('definition_id', $identifierData)
|
||||
|| !array_key_exists('value', $identifierData)
|
||||
|| !is_int($identifierData['definition_id'])
|
||||
|| !is_array($identifierData['value'])
|
||||
) {
|
||||
throw new UnexpectedValueException('format for identifiers is not correct');
|
||||
}
|
||||
|
||||
$definitionId = $identifierData['definition_id'];
|
||||
$value = $identifierData['value'];
|
||||
|
||||
$worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($definitionId);
|
||||
|
||||
if (!$worker->getDefinition()->isEditableByUsers()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$personIdentifier = $person->getIdentifiers()->findFirst(fn (int $key, PersonIdentifier $personIdentifier) => $personIdentifier->getId() === $definitionId);
|
||||
if (null === $personIdentifier) {
|
||||
$personIdentifier = new PersonIdentifier($worker->getDefinition());
|
||||
$person->addIdentifier($personIdentifier);
|
||||
}
|
||||
|
||||
$personIdentifier->setValue($value);
|
||||
$personIdentifier->setCanonical($worker->canonicalizeValue($value));
|
||||
|
||||
if ($worker->isEmpty($personIdentifier)) {
|
||||
$person->removeIdentifier($personIdentifier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (\array_key_exists('email', $data)) {
|
||||
$person->setEmail($data['email']);
|
||||
}
|
||||
|
||||
if (\array_key_exists('civility', $data) && null !== $data['civility']) {
|
||||
$civility = $this->denormalizer->denormalize($data['civility'], Civility::class, $format, []);
|
||||
$person->setCivility($civility);
|
||||
}
|
||||
|
||||
return $person;
|
||||
}
|
||||
|
||||
public function supportsDenormalization($data, $type, $format = null): bool
|
||||
{
|
||||
return Person::class === $type && 'person' === ($data['type'] ?? null) && !isset($data['id']);
|
||||
}
|
||||
}
|
@@ -11,169 +11,32 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\PersonBundle\Serializer\Normalizer;
|
||||
|
||||
use Chill\MainBundle\Entity\Center;
|
||||
use Chill\MainBundle\Entity\Civility;
|
||||
use Chill\MainBundle\Entity\Gender;
|
||||
use Chill\MainBundle\Phonenumber\PhoneNumberHelperInterface;
|
||||
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
|
||||
use Chill\MainBundle\Templating\Entity\ChillEntityRenderExtension;
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Chill\PersonBundle\Entity\PersonAltName;
|
||||
use Chill\PersonBundle\Repository\PersonRepository;
|
||||
use Chill\PersonBundle\Repository\ResidentialAddressRepository;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use libphonenumber\PhoneNumber;
|
||||
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
|
||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
|
||||
use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
|
||||
/**
|
||||
* Serialize a Person entity.
|
||||
*/
|
||||
class PersonJsonNormalizer implements DenormalizerAwareInterface, NormalizerAwareInterface, PersonJsonNormalizerInterface
|
||||
class PersonJsonNormalizer implements NormalizerAwareInterface, NormalizerInterface
|
||||
{
|
||||
use DenormalizerAwareTrait;
|
||||
|
||||
use NormalizerAwareTrait;
|
||||
|
||||
use ObjectToPopulateTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly ChillEntityRenderExtension $render,
|
||||
/* TODO: replace by PersonRenderInterface, as sthis is the only one required */
|
||||
private readonly PersonRepository $repository,
|
||||
private readonly CenterResolverManagerInterface $centerResolverManager,
|
||||
private readonly ResidentialAddressRepository $residentialAddressRepository,
|
||||
private readonly PhoneNumberHelperInterface $phoneNumberHelper,
|
||||
) {}
|
||||
|
||||
public function denormalize($data, $type, $format = null, array $context = [])
|
||||
{
|
||||
$person = $this->extractObjectToPopulate($type, $context);
|
||||
|
||||
if (\array_key_exists('id', $data) && null === $person) {
|
||||
$person = $this->repository->find($data['id']);
|
||||
|
||||
if (null === $person) {
|
||||
throw new UnexpectedValueException("The person with id \"{$data['id']}\" does ".'not exists');
|
||||
}
|
||||
|
||||
// currently, not allowed to update a person through api
|
||||
// if instantiated with id
|
||||
return $person;
|
||||
}
|
||||
|
||||
if (null === $person) {
|
||||
$person = new Person();
|
||||
}
|
||||
|
||||
$fields = [
|
||||
'firstName',
|
||||
'lastName',
|
||||
'phonenumber',
|
||||
'mobilenumber',
|
||||
'gender',
|
||||
'birthdate',
|
||||
'deathdate',
|
||||
'center',
|
||||
'altNames',
|
||||
'email',
|
||||
'civility',
|
||||
];
|
||||
|
||||
$fields = array_filter(
|
||||
$fields,
|
||||
static fn (string $field): bool => \array_key_exists($field, $data)
|
||||
);
|
||||
|
||||
foreach ($fields as $item) {
|
||||
switch ($item) {
|
||||
case 'firstName':
|
||||
$person->setFirstName($data[$item]);
|
||||
|
||||
break;
|
||||
|
||||
case 'lastName':
|
||||
$person->setLastName($data[$item]);
|
||||
|
||||
break;
|
||||
|
||||
case 'phonenumber':
|
||||
$person->setPhonenumber($this->denormalizer->denormalize($data[$item], PhoneNumber::class, $format, $context));
|
||||
|
||||
break;
|
||||
|
||||
case 'mobilenumber':
|
||||
$person->setMobilenumber($this->denormalizer->denormalize($data[$item], PhoneNumber::class, $format, $context));
|
||||
|
||||
break;
|
||||
|
||||
case 'gender':
|
||||
$gender = $this->denormalizer->denormalize($data[$item], Gender::class, $format, []);
|
||||
|
||||
$person->setGender($gender);
|
||||
|
||||
break;
|
||||
|
||||
case 'birthdate':
|
||||
$object = $this->denormalizer->denormalize($data[$item], \DateTime::class, $format, $context);
|
||||
|
||||
$person->setBirthdate($object);
|
||||
|
||||
break;
|
||||
|
||||
case 'deathdate':
|
||||
$object = $this->denormalizer->denormalize($data[$item], \DateTimeImmutable::class, $format, $context);
|
||||
|
||||
$person->setDeathdate($object);
|
||||
|
||||
break;
|
||||
|
||||
case 'center':
|
||||
$object = $this->denormalizer->denormalize($data[$item], Center::class, $format, $context);
|
||||
$person->setCenter($object);
|
||||
|
||||
break;
|
||||
|
||||
case 'altNames':
|
||||
foreach ($data[$item] as $altName) {
|
||||
$oldAltName = $person
|
||||
->getAltNames()
|
||||
->filter(static fn (PersonAltName $n): bool => $n->getKey() === $altName['key'])->first();
|
||||
|
||||
if (false === $oldAltName) {
|
||||
$newAltName = new PersonAltName();
|
||||
$newAltName->setKey($altName['key']);
|
||||
$newAltName->setLabel($altName['label']);
|
||||
$person->addAltName($newAltName);
|
||||
} else {
|
||||
$oldAltName->setLabel($altName['label']);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'email':
|
||||
$person->setEmail($data[$item]);
|
||||
|
||||
break;
|
||||
|
||||
case 'civility':
|
||||
$civility = $this->denormalizer->denormalize($data[$item], Civility::class, $format, []);
|
||||
|
||||
$person->setCivility($civility);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $person;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Person $person
|
||||
* @param string|null $format
|
||||
|
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Serializer\Normalizer;
|
||||
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Chill\PersonBundle\Repository\PersonRepository;
|
||||
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
|
||||
use Symfony\Component\Serializer\Exception\LogicException;
|
||||
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
|
||||
/**
|
||||
* Find a Person entity by his id during the denormalization process.
|
||||
*/
|
||||
readonly class PersonJsonReadDenormalizer implements DenormalizerInterface
|
||||
{
|
||||
public function __construct(private PersonRepository $repository) {}
|
||||
|
||||
public function denormalize($data, string $type, ?string $format = null, array $context = []): Person
|
||||
{
|
||||
if (!is_array($data)) {
|
||||
throw new InvalidArgumentException();
|
||||
}
|
||||
|
||||
if (\array_key_exists('id', $data)) {
|
||||
$person = $this->repository->find($data['id']);
|
||||
|
||||
if (null === $person) {
|
||||
throw new UnexpectedValueException("The person with id \"{$data['id']}\" does ".'not exists');
|
||||
}
|
||||
|
||||
return $person;
|
||||
}
|
||||
|
||||
throw new LogicException();
|
||||
}
|
||||
|
||||
public function supportsDenormalization($data, string $type, ?string $format = null)
|
||||
{
|
||||
return is_array($data) && Person::class === $type && 'person' === ($data['type'] ?? null) && isset($data['id']);
|
||||
}
|
||||
}
|
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Tests\Controller;
|
||||
|
||||
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
|
||||
use Chill\MainBundle\Serializer\Normalizer\CollectionNormalizer;
|
||||
use Chill\PersonBundle\Controller\PersonIdentifierListApiController;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Chill\PersonBundle\PersonIdentifier\Normalizer\PersonIdentifierWorkerNormalizer;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\Encoder\JsonEncoder;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class PersonIdentifierListApiControllerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
public function testListAccessDenied(): void
|
||||
{
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('ROLE_USER')->willReturn(false)->shouldBeCalledOnce();
|
||||
|
||||
$serializer = new Serializer([new PersonIdentifierWorkerNormalizer(), new CollectionNormalizer()], [new JsonEncoder()]);
|
||||
|
||||
$personIdentifierManager = $this->prophesize(PersonIdentifierManagerInterface::class);
|
||||
$paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class);
|
||||
|
||||
$controller = new PersonIdentifierListApiController(
|
||||
$security->reveal(),
|
||||
$serializer,
|
||||
$personIdentifierManager->reveal(),
|
||||
$paginatorFactory->reveal(),
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$controller->list();
|
||||
}
|
||||
|
||||
public function testListSuccess(): void
|
||||
{
|
||||
// Build 3 workers
|
||||
$engine = new class () implements PersonIdentifierEngineInterface {
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'dummy';
|
||||
}
|
||||
|
||||
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {}
|
||||
|
||||
public function renderAsString(?\Chill\PersonBundle\Entity\Identifier\PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
$definition1 = new PersonIdentifierDefinition(['en' => 'Label 1'], 'dummy');
|
||||
$definition2 = new PersonIdentifierDefinition(['en' => 'Label 2'], 'dummy');
|
||||
$definition3 = new PersonIdentifierDefinition(['en' => 'Label 3'], 'dummy');
|
||||
// simulate persisted ids
|
||||
$r = new \ReflectionProperty(PersonIdentifierDefinition::class, 'id');
|
||||
$r->setAccessible(true);
|
||||
$r->setValue($definition1, 1);
|
||||
$r->setValue($definition2, 2);
|
||||
$r->setValue($definition3, 3);
|
||||
|
||||
$workers = [
|
||||
new PersonIdentifierWorker($engine, $definition1),
|
||||
new PersonIdentifierWorker($engine, $definition2),
|
||||
new PersonIdentifierWorker($engine, $definition3),
|
||||
];
|
||||
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('ROLE_USER')->willReturn(true)->shouldBeCalledOnce();
|
||||
|
||||
$personIdentifierManager = $this->prophesize(PersonIdentifierManagerInterface::class);
|
||||
$personIdentifierManager->getWorkers()->willReturn($workers)->shouldBeCalledOnce();
|
||||
|
||||
$paginator = $this->prophesize(\Chill\MainBundle\Pagination\PaginatorInterface::class);
|
||||
$paginator->setItemsPerPage(3)->shouldBeCalledOnce();
|
||||
$paginator->getCurrentPageFirstItemNumber()->willReturn(0);
|
||||
$paginator->getItemsPerPage()->willReturn(count($workers));
|
||||
$paginator->getTotalItems()->willReturn(count($workers));
|
||||
$paginator->hasNextPage()->willReturn(false);
|
||||
$paginator->hasPreviousPage()->willReturn(false);
|
||||
|
||||
$paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class);
|
||||
$paginatorFactory->create(3)->willReturn($paginator->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$serializer = new Serializer([
|
||||
new PersonIdentifierWorkerNormalizer(),
|
||||
new CollectionNormalizer(),
|
||||
], [new JsonEncoder()]);
|
||||
|
||||
$controller = new PersonIdentifierListApiController(
|
||||
$security->reveal(),
|
||||
$serializer,
|
||||
$personIdentifierManager->reveal(),
|
||||
$paginatorFactory->reveal(),
|
||||
);
|
||||
|
||||
$response = $controller->list();
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
$body = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertIsArray($body);
|
||||
self::assertArrayHasKey('count', $body);
|
||||
self::assertArrayHasKey('pagination', $body);
|
||||
self::assertArrayHasKey('results', $body);
|
||||
self::assertSame(3, $body['count']);
|
||||
self::assertCount(3, $body['results']);
|
||||
// spot check one item
|
||||
self::assertSame('person_identifier_worker', $body['results'][0]['type']);
|
||||
self::assertSame(1, $body['results'][0]['id']);
|
||||
self::assertSame('dummy', $body['results'][0]['engine']);
|
||||
self::assertSame(['en' => 'Label 1'], $body['results'][0]['label']);
|
||||
}
|
||||
}
|
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Tests\PersonIdentifier\Normalizer;
|
||||
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Chill\PersonBundle\PersonIdentifier\Normalizer\PersonIdentifierWorkerNormalizer;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class PersonIdentifierWorkerNormalizerTest extends TestCase
|
||||
{
|
||||
public function testSupportsNormalization(): void
|
||||
{
|
||||
$engine = new class () implements PersonIdentifierEngineInterface {
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'dummy';
|
||||
}
|
||||
|
||||
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {}
|
||||
|
||||
public function renderAsString(?\Chill\PersonBundle\Entity\Identifier\PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
$definition = new PersonIdentifierDefinition(label: ['en' => 'SSN'], engine: 'string');
|
||||
$worker = new PersonIdentifierWorker($engine, $definition);
|
||||
|
||||
$normalizer = new PersonIdentifierWorkerNormalizer();
|
||||
|
||||
self::assertTrue($normalizer->supportsNormalization($worker));
|
||||
self::assertFalse($normalizer->supportsNormalization(new \stdClass()));
|
||||
}
|
||||
|
||||
public function testNormalizeReturnsExpectedArray(): void
|
||||
{
|
||||
$engine = new class () implements PersonIdentifierEngineInterface {
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'dummy';
|
||||
}
|
||||
|
||||
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {}
|
||||
|
||||
public function renderAsString(?\Chill\PersonBundle\Entity\Identifier\PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
$definition = new PersonIdentifierDefinition(label: ['en' => 'SSN'], engine: 'string');
|
||||
$definition->setActive(false);
|
||||
$worker = new PersonIdentifierWorker($engine, $definition);
|
||||
|
||||
$normalizer = new PersonIdentifierWorkerNormalizer();
|
||||
$normalized = $normalizer->normalize($worker);
|
||||
|
||||
self::assertSame([
|
||||
'type' => 'person_identifier_worker',
|
||||
'id' => null,
|
||||
'engine' => 'string',
|
||||
'label' => ['en' => 'SSN'],
|
||||
'isActive' => false,
|
||||
], $normalized);
|
||||
}
|
||||
|
||||
public function testNormalizeThrowsOnInvalidObject(): void
|
||||
{
|
||||
$normalizer = new PersonIdentifierWorkerNormalizer();
|
||||
$this->expectException(UnexpectedValueException::class);
|
||||
$normalizer->normalize(new \stdClass());
|
||||
}
|
||||
}
|
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Tests\PersonIdentifier\Validator;
|
||||
|
||||
use Chill\PersonBundle\Entity\Identifier\IdentifierPresenceEnum;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
|
||||
use Chill\PersonBundle\PersonIdentifier\Validator\RequiredIdentifierConstraint;
|
||||
use Chill\PersonBundle\PersonIdentifier\Validator\RequiredIdentifierConstraintValidator;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedValueException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Argument;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
#[CoversClass(RequiredIdentifierConstraintValidator::class)]
|
||||
final class RequiredIdentifierConstraintValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private PersonIdentifierDefinition $requiredDefinition;
|
||||
|
||||
protected function createValidator(): RequiredIdentifierConstraintValidator
|
||||
{
|
||||
$this->requiredDefinition = new PersonIdentifierDefinition(
|
||||
label: ['fr' => 'Identifiant requis'],
|
||||
engine: 'test.engine',
|
||||
);
|
||||
$this->requiredDefinition->setPresence(IdentifierPresenceEnum::REQUIRED);
|
||||
$reflection = new \ReflectionClass($this->requiredDefinition);
|
||||
$id = $reflection->getProperty('id');
|
||||
$id->setValue($this->requiredDefinition, 1);
|
||||
|
||||
// Mock only the required methods of the engine used by the validator through the worker
|
||||
$engineProphecy = $this->prophesize(PersonIdentifierEngineInterface::class);
|
||||
$engineProphecy->isEmpty(Argument::type(PersonIdentifier::class))
|
||||
->will(function (array $args): bool {
|
||||
/** @var PersonIdentifier $identifier */
|
||||
$identifier = $args[0];
|
||||
|
||||
return '' === trim($identifier->getValue()['content'] ?? '');
|
||||
});
|
||||
$engineProphecy->renderAsString(Argument::any(), Argument::any())
|
||||
->will(function (array $args): string {
|
||||
/** @var PersonIdentifier|null $identifier */
|
||||
$identifier = $args[0] ?? null;
|
||||
|
||||
return $identifier?->getValue()['content'] ?? '';
|
||||
});
|
||||
|
||||
$worker = new PersonIdentifierWorker($engineProphecy->reveal(), $this->requiredDefinition);
|
||||
|
||||
// Mock only the required method used by the validator
|
||||
$managerProphecy = $this->prophesize(PersonIdentifierManagerInterface::class);
|
||||
$managerProphecy->getWorkers()->willReturn([$worker]);
|
||||
|
||||
return new RequiredIdentifierConstraintValidator($managerProphecy->reveal());
|
||||
}
|
||||
|
||||
public function testThrowsOnNonCollectionValue(): void
|
||||
{
|
||||
$this->expectException(UnexpectedValueException::class);
|
||||
$this->validator->validate(new \stdClass(), new RequiredIdentifierConstraint());
|
||||
}
|
||||
|
||||
public function testThrowsOnInvalidConstraintType(): void
|
||||
{
|
||||
$this->expectException(UnexpectedTypeException::class);
|
||||
// Provide a valid Collection value so the type check reaches the constraint check
|
||||
$this->validator->validate(new ArrayCollection(), new NotBlank());
|
||||
}
|
||||
|
||||
public function testNoViolationWhenRequiredIdentifierPresentAndNotEmpty(): void
|
||||
{
|
||||
$identifier = new PersonIdentifier($this->requiredDefinition);
|
||||
$identifier->setValue(['content' => 'ABC']);
|
||||
|
||||
$collection = new ArrayCollection([$identifier]);
|
||||
|
||||
$this->validator->validate($collection, new RequiredIdentifierConstraint());
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testViolationWhenRequiredIdentifierMissing(): void
|
||||
{
|
||||
$collection = new ArrayCollection();
|
||||
|
||||
$this->validator->validate($collection, new RequiredIdentifierConstraint());
|
||||
|
||||
$this->buildViolation('person_identifier.This identifier must be set')
|
||||
->setParameter('{{ value }}', '')
|
||||
->setParameter('definition_id', '1')
|
||||
->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05')
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testViolationWhenRequiredIdentifierIsEmpty(): void
|
||||
{
|
||||
$identifier = new PersonIdentifier($this->requiredDefinition);
|
||||
$identifier->setValue(['content' => ' ']);
|
||||
|
||||
$collection = new ArrayCollection([$identifier]);
|
||||
|
||||
$this->validator->validate($collection, new RequiredIdentifierConstraint());
|
||||
|
||||
$this->buildViolation('person_identifier.This identifier must be set')
|
||||
->setParameter('{{ value }}', ' ')
|
||||
->setParameter('definition_id', '1')
|
||||
->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05')
|
||||
->assertRaised();
|
||||
}
|
||||
}
|
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Tests\PersonIdentifier\Validator;
|
||||
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Chill\PersonBundle\PersonIdentifier\Validator\UniqueIdentifierConstraint;
|
||||
use Chill\PersonBundle\PersonIdentifier\Validator\UniqueIdentifierConstraintValidator;
|
||||
use Chill\PersonBundle\Repository\Identifier\PersonIdentifierRepository;
|
||||
use Chill\PersonBundle\Templating\Entity\PersonRenderInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedValueException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
#[CoversClass(UniqueIdentifierConstraintValidator::class)]
|
||||
final class UniqueIdentifierConstraintValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
/**
|
||||
* @var ObjectProphecy|PersonIdentifierRepository
|
||||
*/
|
||||
private ObjectProphecy $repository;
|
||||
|
||||
/**
|
||||
* @var ObjectProphecy|PersonRenderInterface
|
||||
*/
|
||||
private ObjectProphecy $personRender;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = $this->prophesize(PersonIdentifierRepository::class);
|
||||
$this->personRender = $this->prophesize(PersonRenderInterface::class);
|
||||
parent::setUp();
|
||||
}
|
||||
|
||||
protected function createValidator(): UniqueIdentifierConstraintValidator
|
||||
{
|
||||
return new UniqueIdentifierConstraintValidator($this->repository->reveal(), $this->personRender->reveal());
|
||||
}
|
||||
|
||||
public function testThrowsOnInvalidConstraintType(): void
|
||||
{
|
||||
$this->expectException(UnexpectedTypeException::class);
|
||||
|
||||
// Provide a valid value so execution reaches the constraint type check
|
||||
$definition = new PersonIdentifierDefinition(['en' => 'Test'], 'string');
|
||||
$identifier = new PersonIdentifier($definition);
|
||||
$identifier->setValue(['value' => 'ABC']);
|
||||
|
||||
$this->validator->validate($identifier, new NotBlank());
|
||||
}
|
||||
|
||||
public function testThrowsOnInvalidValueType(): void
|
||||
{
|
||||
$this->expectException(UnexpectedValueException::class);
|
||||
$this->validator->validate(new \stdClass(), new UniqueIdentifierConstraint());
|
||||
}
|
||||
|
||||
public function testNoViolationWhenNoDuplicate(): void
|
||||
{
|
||||
$definition = new PersonIdentifierDefinition(['en' => 'Test'], 'string');
|
||||
$identifier = new PersonIdentifier($definition);
|
||||
$identifier->setValue(['value' => 'UNIQ']);
|
||||
|
||||
// Configure repository mock to return empty array
|
||||
$this->repository->findByDefinitionAndValue($definition, ['value' => 'UNIQ'])->willReturn([]);
|
||||
|
||||
$this->validator->validate($identifier, new UniqueIdentifierConstraint());
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testViolationWhenDuplicateFound(): void
|
||||
{
|
||||
$definition = new PersonIdentifierDefinition(['en' => 'SSN'], 'string');
|
||||
$reflectionClass = new \ReflectionClass($definition);
|
||||
$reflectionId = $reflectionClass->getProperty('id');
|
||||
$reflectionId->setValue($definition, 1);
|
||||
|
||||
$personA = new Person();
|
||||
$personA->setFirstName('Alice')->setLastName('Anderson');
|
||||
$personB = new Person();
|
||||
$personB->setFirstName('Bob')->setLastName('Brown');
|
||||
|
||||
$dup1 = new PersonIdentifier($definition);
|
||||
$dup1->setPerson($personA);
|
||||
$dup1->setValue(['value' => '123']);
|
||||
$dup2 = new PersonIdentifier($definition);
|
||||
$dup2->setPerson($personB);
|
||||
$dup2->setValue(['value' => '123']);
|
||||
|
||||
// Repository returns duplicates
|
||||
$this->repository->findByDefinitionAndValue($definition, ['value' => '123'])->willReturn([$dup1, $dup2]);
|
||||
|
||||
// Person renderer returns names
|
||||
$this->personRender->renderString($personA, Argument::type('array'))->willReturn('Alice Anderson');
|
||||
$this->personRender->renderString($personB, Argument::type('array'))->willReturn('Bob Brown');
|
||||
|
||||
$identifier = new PersonIdentifier($definition);
|
||||
$identifier->setPerson(new Person());
|
||||
$identifier->setValue(['value' => '123']);
|
||||
|
||||
$constraint = new UniqueIdentifierConstraint();
|
||||
|
||||
$this->validator->validate($identifier, $constraint);
|
||||
|
||||
$this->buildViolation($constraint->message)
|
||||
->setParameter('{{ persons }}', 'Alice Anderson, Bob Brown')
|
||||
->setParameter('definition_id', '1')
|
||||
->assertRaised();
|
||||
}
|
||||
}
|
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Tests\Repository\Identifier;
|
||||
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Chill\PersonBundle\Repository\Identifier\PersonIdentifierRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class PersonIdentifierRepositoryTest extends KernelTestCase
|
||||
{
|
||||
public function testFindByDefinitionAndValue(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$container = self::getContainer();
|
||||
|
||||
/** @var EntityManagerInterface $em */
|
||||
$em = $container->get(EntityManagerInterface::class);
|
||||
|
||||
// Get a random existing person from fixtures
|
||||
/** @var Person|null $person */
|
||||
$person = $em->getRepository(Person::class)->findOneBy([]);
|
||||
self::assertNotNull($person, 'An existing Person is required for this integration test.');
|
||||
|
||||
// Create a definition
|
||||
$definition = new PersonIdentifierDefinition(['en' => 'Test Identifier'], 'string');
|
||||
$em->persist($definition);
|
||||
$em->flush();
|
||||
|
||||
// Create an identifier attached to the person
|
||||
$value = ['value' => 'ABC-'.bin2hex(random_bytes(4))];
|
||||
$identifier = new PersonIdentifier($definition);
|
||||
$identifier->setPerson($person);
|
||||
$identifier->setValue($value);
|
||||
$identifier->setCanonical('canonical-'.$value['value']);
|
||||
$em->persist($identifier);
|
||||
$em->flush();
|
||||
|
||||
// Use the repository to find by definition and value
|
||||
/** @var PersonIdentifierRepository $repo */
|
||||
$repo = $container->get(PersonIdentifierRepository::class);
|
||||
$results = $repo->findByDefinitionAndValue($definition, $value);
|
||||
|
||||
self::assertNotEmpty($results, 'Repository should return at least one result.');
|
||||
self::assertContainsOnlyInstancesOf(PersonIdentifier::class, $results);
|
||||
self::assertTrue(in_array($identifier->getId(), array_map(static fn (PersonIdentifier $pi) => $pi->getId(), $results), true));
|
||||
|
||||
// Cleanup
|
||||
foreach ($results as $res) {
|
||||
$em->remove($res);
|
||||
}
|
||||
$em->flush();
|
||||
$em->remove($definition);
|
||||
$em->flush();
|
||||
}
|
||||
}
|
@@ -18,6 +18,7 @@ use Chill\MainBundle\Repository\CountryRepository;
|
||||
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Chill\PersonBundle\Entity\PersonPhone;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
|
||||
use Chill\PersonBundle\Repository\PersonACLAwareRepository;
|
||||
use Chill\PersonBundle\Security\Authorization\PersonVoter;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -42,6 +43,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase
|
||||
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
private PersonIdentifierManagerInterface $personIdentifierManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
@@ -49,6 +52,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase
|
||||
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->countryRepository = self::getContainer()->get(CountryRepository::class);
|
||||
$this->centerRepository = self::getContainer()->get(CenterRepositoryInterface::class);
|
||||
$this->personIdentifierManager = self::getContainer()->get(PersonIdentifierManagerInterface::class);
|
||||
|
||||
}
|
||||
|
||||
public function testCountByCriteria()
|
||||
@@ -66,7 +71,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase
|
||||
$security->reveal(),
|
||||
$this->entityManager,
|
||||
$this->countryRepository,
|
||||
$authorizationHelper->reveal()
|
||||
$authorizationHelper->reveal(),
|
||||
$this->personIdentifierManager,
|
||||
);
|
||||
|
||||
$number = $repository->countBySearchCriteria('diallo');
|
||||
@@ -89,7 +95,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase
|
||||
$security->reveal(),
|
||||
$this->entityManager,
|
||||
$this->countryRepository,
|
||||
$authorizationHelper->reveal()
|
||||
$authorizationHelper->reveal(),
|
||||
$this->personIdentifierManager,
|
||||
);
|
||||
|
||||
$results = $repository->findBySearchCriteria(0, 5, false, 'diallo');
|
||||
@@ -120,7 +127,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase
|
||||
$security->reveal(),
|
||||
$this->entityManager,
|
||||
$this->countryRepository,
|
||||
$authorizationHelper->reveal()
|
||||
$authorizationHelper->reveal(),
|
||||
$this->personIdentifierManager,
|
||||
);
|
||||
|
||||
$actual = $repository->findByPhone($phoneNumber, 0, 10);
|
||||
|
@@ -0,0 +1,302 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Tests\Serializer\Normalizer;
|
||||
|
||||
use Chill\MainBundle\Entity\Center;
|
||||
use Chill\MainBundle\Entity\Civility;
|
||||
use Chill\MainBundle\Entity\Gender;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
|
||||
use Chill\PersonBundle\Serializer\Normalizer\PersonJsonDenormalizer;
|
||||
use libphonenumber\PhoneNumber;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @covers \Chill\PersonBundle\Serializer\Normalizer\PersonJsonDenormalizer
|
||||
*/
|
||||
final class PersonJsonDenormalizerTest extends TestCase
|
||||
{
|
||||
private function createIdentifierManager(): PersonIdentifierManagerInterface
|
||||
{
|
||||
return new class () implements PersonIdentifierManagerInterface {
|
||||
public function getWorkers(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function buildWorkerByPersonIdentifierDefinition(int|PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker
|
||||
{
|
||||
if (is_int($personIdentifierDefinition)) {
|
||||
$definition = new PersonIdentifierDefinition(['en' => 'Test'], 'dummy');
|
||||
// Force the id for testing purposes
|
||||
$r = new \ReflectionProperty(PersonIdentifierDefinition::class, 'id');
|
||||
$r->setAccessible(true);
|
||||
$r->setValue($definition, $personIdentifierDefinition);
|
||||
} else {
|
||||
$definition = $personIdentifierDefinition;
|
||||
}
|
||||
|
||||
$engine = new class () implements PersonIdentifierEngineInterface {
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'dummy';
|
||||
}
|
||||
|
||||
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
|
||||
{
|
||||
// trivial canonicalization for tests
|
||||
return isset($value['content']) ? (string) $value['content'] : null;
|
||||
}
|
||||
|
||||
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {}
|
||||
|
||||
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function isEmpty(PersonIdentifier $identifier): bool
|
||||
{
|
||||
$value = $identifier->getValue();
|
||||
$content = isset($value['content']) ? trim((string) $value['content']) : '';
|
||||
|
||||
return '' === $content;
|
||||
}
|
||||
};
|
||||
|
||||
return new PersonIdentifierWorker($engine, $definition);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function testSupportsDenormalizationReturnsTrueForValidData(): void
|
||||
{
|
||||
$denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager());
|
||||
|
||||
$data = [
|
||||
'type' => 'person',
|
||||
// important: new Person (creation) must not contain an id
|
||||
];
|
||||
|
||||
self::assertTrue($denormalizer->supportsDenormalization($data, Person::class));
|
||||
}
|
||||
|
||||
public function testSupportsDenormalizationReturnsFalseForInvalidData(): void
|
||||
{
|
||||
$denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager());
|
||||
|
||||
// not an array
|
||||
self::assertFalse($denormalizer->supportsDenormalization('not-an-array', Person::class));
|
||||
|
||||
// missing type
|
||||
self::assertFalse($denormalizer->supportsDenormalization([], Person::class));
|
||||
|
||||
// wrong type value
|
||||
self::assertFalse($denormalizer->supportsDenormalization(['type' => 'not-person'], Person::class));
|
||||
|
||||
// id present means it's not a create payload for this denormalizer
|
||||
self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person', 'id' => 123], Person::class));
|
||||
|
||||
// wrong target class
|
||||
self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person'], \stdClass::class));
|
||||
}
|
||||
|
||||
public function testDenormalizeMapsPayloadToPersonProperties(): void
|
||||
{
|
||||
$json = <<<'JSON'
|
||||
{
|
||||
"type": "person",
|
||||
"firstName": "Jérome",
|
||||
"lastName": "diallo",
|
||||
"altNames": [
|
||||
{
|
||||
"key": "jeune_fille",
|
||||
"value": "FJ"
|
||||
}
|
||||
],
|
||||
"birthdate": null,
|
||||
"deathdate": null,
|
||||
"phonenumber": "",
|
||||
"mobilenumber": "",
|
||||
"email": "",
|
||||
"gender": {
|
||||
"id": 5,
|
||||
"type": "chill_main_gender"
|
||||
},
|
||||
"center": {
|
||||
"id": 1,
|
||||
"type": "center"
|
||||
},
|
||||
"civility": null,
|
||||
"identifiers": [
|
||||
{
|
||||
"type": "person_identifier",
|
||||
"value": {
|
||||
"content": "789456"
|
||||
},
|
||||
"definition_id": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON;
|
||||
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
$inner = new class () implements DenormalizerInterface {
|
||||
public ?Gender $gender = null;
|
||||
public ?Center $center = null;
|
||||
|
||||
public function denormalize($data, $type, $format = null, array $context = [])
|
||||
{
|
||||
if (PhoneNumber::class === $type) {
|
||||
return '' === $data ? null : new PhoneNumber();
|
||||
}
|
||||
if (\DateTime::class === $type || \DateTimeImmutable::class === $type) {
|
||||
return null === $data ? null : new \DateTimeImmutable((string) $data);
|
||||
}
|
||||
if (Gender::class === $type) {
|
||||
return $this->gender ??= new Gender();
|
||||
}
|
||||
if (Center::class === $type) {
|
||||
return $this->center ??= new Center();
|
||||
}
|
||||
if (Civility::class === $type) {
|
||||
return null; // input is null in our payload
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function supportsDenormalization($data, $type, $format = null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
$denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager());
|
||||
$denormalizer->setDenormalizer($inner);
|
||||
|
||||
$person = $denormalizer->denormalize($data, Person::class);
|
||||
|
||||
self::assertInstanceOf(Person::class, $person);
|
||||
self::assertSame('Jérome', $person->getFirstName());
|
||||
self::assertSame('diallo', $person->getLastName());
|
||||
|
||||
// phone numbers: empty strings map to null via the inner denormalizer stub
|
||||
self::assertNull($person->getPhonenumber());
|
||||
self::assertNull($person->getMobilenumber());
|
||||
|
||||
// email passes through as is
|
||||
self::assertSame('', $person->getEmail());
|
||||
|
||||
// nested objects are provided by our inner denormalizer and must be set back on the Person
|
||||
self::assertSame($inner->gender, $person->getGender());
|
||||
self::assertSame($inner->center, $person->getCenter());
|
||||
|
||||
// dates are null in the provided payload
|
||||
self::assertNull($person->getBirthdate());
|
||||
self::assertNull($person->getDeathdate());
|
||||
|
||||
// civility is null as provided
|
||||
self::assertNull($person->getCivility());
|
||||
|
||||
// altNames: make sure the alt name with key jeune_fille has label FJ
|
||||
$found = false;
|
||||
foreach ($person->getAltNames() as $altName) {
|
||||
if ('jeune_fille' === $altName->getKey()) {
|
||||
$found = true;
|
||||
self::assertSame('FJ', $altName->getLabel());
|
||||
}
|
||||
}
|
||||
self::assertTrue($found, 'Expected altNames to contain key "jeune_fille"');
|
||||
|
||||
$found = false;
|
||||
foreach ($person->getIdentifiers() as $identifier) {
|
||||
if (5 === $identifier->getDefinition()->getId()) {
|
||||
$found = true;
|
||||
self::assertSame(['content' => '789456'], $identifier->getValue());
|
||||
}
|
||||
}
|
||||
self::assertTrue($found, 'Expected identifiers with definition id 5');
|
||||
}
|
||||
|
||||
public function testDenormalizeRemovesEmptyIdentifier(): void
|
||||
{
|
||||
$data = [
|
||||
'type' => 'person',
|
||||
'firstName' => 'Alice',
|
||||
'lastName' => 'Smith',
|
||||
'identifiers' => [
|
||||
[
|
||||
'type' => 'person_identifier',
|
||||
'value' => ['content' => ''],
|
||||
'definition_id' => 7,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager());
|
||||
|
||||
$person = $denormalizer->denormalize($data, Person::class);
|
||||
|
||||
// The identifier with empty content must be considered empty and removed
|
||||
self::assertSame(0, $person->getIdentifiers()->count(), 'Expected no identifiers to remain on the person');
|
||||
}
|
||||
|
||||
public function testDenormalizeRemovesPreviouslyExistingIdentifierWhenIncomingValueIsEmpty(): void
|
||||
{
|
||||
// Prepare an existing Person with a pre-existing identifier (definition id = 9)
|
||||
$definition = new PersonIdentifierDefinition(['en' => 'Test'], 'dummy');
|
||||
$ref = new \ReflectionProperty(PersonIdentifierDefinition::class, 'id');
|
||||
$ref->setValue($definition, 9);
|
||||
|
||||
$existingIdentifier = new PersonIdentifier($definition);
|
||||
$existingIdentifier->setValue(['content' => 'ABC']);
|
||||
|
||||
$person = new Person();
|
||||
$person->addIdentifier($existingIdentifier);
|
||||
|
||||
// Also set the identifier's own id = 9 so that the denormalizer logic matches it
|
||||
// (the current denormalizer matches by PersonIdentifier->getId() === definition_id)
|
||||
$refId = new \ReflectionProperty(PersonIdentifier::class, 'id');
|
||||
$refId->setValue($existingIdentifier, 9);
|
||||
|
||||
// Incoming payload sets the same definition id with an empty value
|
||||
$data = [
|
||||
'type' => 'person',
|
||||
'identifiers' => [
|
||||
[
|
||||
'type' => 'person_identifier',
|
||||
'value' => ['content' => ''],
|
||||
'definition_id' => 9,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager());
|
||||
|
||||
// Use AbstractNormalizer::OBJECT_TO_POPULATE to update the existing person
|
||||
$result = $denormalizer->denormalize($data, Person::class, null, [
|
||||
AbstractNormalizer::OBJECT_TO_POPULATE => $person,
|
||||
]);
|
||||
|
||||
self::assertSame($person, $result, 'Denormalizer should update and return the provided Person instance');
|
||||
self::assertSame(0, $person->getIdentifiers()->count(), 'The previously existing identifier should be removed when incoming value is empty');
|
||||
}
|
||||
}
|
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Tests\Serializer\Normalizer;
|
||||
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Chill\PersonBundle\Repository\PersonRepository;
|
||||
use Chill\PersonBundle\Serializer\Normalizer\PersonJsonReadDenormalizer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @covers \Chill\PersonBundle\Serializer\Normalizer\PersonJsonReadDenormalizer
|
||||
*/
|
||||
final class PersonJsonReadDenormalizerTest extends TestCase
|
||||
{
|
||||
public function testSupportsDenormalizationReturnsTrueForValidData(): void
|
||||
{
|
||||
$repository = $this->getMockBuilder(PersonRepository::class)
|
||||
->disableOriginalConstructor()
|
||||
->getMock();
|
||||
|
||||
$denormalizer = new PersonJsonReadDenormalizer($repository);
|
||||
|
||||
$data = [
|
||||
'type' => 'person',
|
||||
'id' => 123,
|
||||
];
|
||||
|
||||
self::assertTrue($denormalizer->supportsDenormalization($data, Person::class));
|
||||
}
|
||||
|
||||
public function testSupportsDenormalizationReturnsFalseForInvalidData(): void
|
||||
{
|
||||
$repository = $this->getMockBuilder(PersonRepository::class)
|
||||
->disableOriginalConstructor()
|
||||
->getMock();
|
||||
|
||||
$denormalizer = new PersonJsonReadDenormalizer($repository);
|
||||
|
||||
// not an array
|
||||
self::assertFalse($denormalizer->supportsDenormalization('not-an-array', Person::class));
|
||||
|
||||
// missing type
|
||||
self::assertFalse($denormalizer->supportsDenormalization(['id' => 1], Person::class));
|
||||
|
||||
// wrong type value
|
||||
self::assertFalse($denormalizer->supportsDenormalization(['type' => 'not-person', 'id' => 1], Person::class));
|
||||
|
||||
// missing id
|
||||
self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person'], Person::class));
|
||||
|
||||
// wrong target class
|
||||
self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person', 'id' => 1], \stdClass::class));
|
||||
}
|
||||
|
||||
public function testDenormalizeReturnsPersonFromRepository(): void
|
||||
{
|
||||
$person = new Person();
|
||||
|
||||
$repository = $this->getMockBuilder(PersonRepository::class)
|
||||
->disableOriginalConstructor()
|
||||
->onlyMethods(['find'])
|
||||
->getMock();
|
||||
|
||||
$repository->expects(self::once())
|
||||
->method('find')
|
||||
->with(123)
|
||||
->willReturn($person);
|
||||
|
||||
$denormalizer = new PersonJsonReadDenormalizer($repository);
|
||||
|
||||
$result = $denormalizer->denormalize(['id' => 123], Person::class);
|
||||
|
||||
self::assertSame($person, $result);
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@@ -108,3 +108,9 @@ services:
|
||||
|
||||
Chill\PersonBundle\PersonIdentifier\Rendering\:
|
||||
resource: '../PersonIdentifier/Rendering'
|
||||
|
||||
Chill\PersonBundle\PersonIdentifier\Normalizer\:
|
||||
resource: '../PersonIdentifier/Normalizer'
|
||||
|
||||
Chill\PersonBundle\PersonIdentifier\Validator\:
|
||||
resource: '../PersonIdentifier/Validator'
|
||||
|
@@ -1,11 +0,0 @@
|
||||
---
|
||||
services:
|
||||
# note: normalizers are loaded from ../services.yaml
|
||||
|
||||
Chill\PersonBundle\Serializer\Normalizer\:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
resource: '../../Serializer/Normalizer'
|
||||
tags:
|
||||
- { name: 'serializer.normalizer', priority: 64 }
|
||||
|
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\Migrations\Person;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20250918095044 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add more details about presence in PersonIdentifier';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE chill_person_identifier_definition ADD presence VARCHAR(255) DEFAULT \'ON_EDIT\' NOT NULL');
|
||||
$this->addSql('UPDATE chill_person_identifier_definition SET presence = \'NOT_EDITABLE\' WHERE is_editable_by_users IS FALSE');
|
||||
$this->addSql('ALTER TABLE chill_person_identifier_definition DROP is_editable_by_users');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE chill_person_identifier_definition ADD is_editable_by_users BOOLEAN DEFAULT false NOT NULL');
|
||||
$this->addSql('UPDATE chill_person_identifier_definition SET is_editable_by_users = true WHERE presence <> \'NOT_EDITABLE\' ');
|
||||
$this->addSql('ALTER TABLE chill_person_identifier_definition DROP presence');
|
||||
}
|
||||
}
|
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\Migrations\Person;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20250922151020 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add unique constraint for person identifiers';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE UNIQUE INDEX chill_person_identifier_unique ON chill_person_identifier (definition_id, value)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX chill_person_identifier_unique');
|
||||
}
|
||||
}
|
@@ -105,6 +105,8 @@ Administrative status: Situation administrative
|
||||
person:
|
||||
Identifiers: Identifiants
|
||||
|
||||
person_edit:
|
||||
Error while saving: Erreur lors de l'enregistrement
|
||||
|
||||
# dédoublonnage
|
||||
Old person: Doublon
|
||||
@@ -1547,7 +1549,7 @@ person_messages:
|
||||
center_id: "Identifiant du centre"
|
||||
center_type: "Type de centre"
|
||||
center_name: "Territoire"
|
||||
phonenumber: "Téléphone"
|
||||
phonenumber: "Téléphone fixe"
|
||||
mobilenumber: "Mobile"
|
||||
altnames: "Autres noms"
|
||||
email: "Courriel"
|
||||
|
@@ -73,5 +73,9 @@ relationship:
|
||||
person_creation:
|
||||
If you want to create an household, an address is required: Pour la création d'un ménage, une adresse est requise
|
||||
|
||||
person_identifier:
|
||||
This identifier must be set: Cet identifiant doit être présent.
|
||||
Identifier must be unique. The same identifier already exists for {{ persons }}: Identifiant déjà utilisé pour {{ persons }}
|
||||
|
||||
accompanying_course_work:
|
||||
The endDate should be greater or equal than the start date: La date de fin doit être égale ou supérieure à la date de début
|
||||
|
@@ -17,7 +17,6 @@ use Chill\MainBundle\Entity\Address;
|
||||
use Chill\MainBundle\Entity\Center;
|
||||
use Chill\MainBundle\Entity\Civility;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\Common\Collections\ReadableCollection;
|
||||
@@ -206,12 +205,12 @@ class ThirdParty implements TrackCreationInterface, TrackUpdateInterface, \Strin
|
||||
|
||||
#[Groups(['read', 'write', 'docgen:read', 'docgen:read:3party:parent'])]
|
||||
#[ORM\Column(name: 'telephone', type: 'phone_number', nullable: true)]
|
||||
#[PhonenumberConstraint(type: 'any')]
|
||||
#[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber]
|
||||
private ?PhoneNumber $telephone = null;
|
||||
|
||||
#[Groups(['read', 'write', 'docgen:read', 'docgen:read:3party:parent'])]
|
||||
#[ORM\Column(name: 'telephone2', type: 'phone_number', nullable: true)]
|
||||
#[PhonenumberConstraint(type: 'any')]
|
||||
#[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber]
|
||||
private ?PhoneNumber $telephone2 = null;
|
||||
|
||||
#[ORM\Column(name: 'types', type: \Doctrine\DBAL\Types\Types::JSON, nullable: true)]
|
||||
|
Reference in New Issue
Block a user