mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-10-21 04:32:51 +00:00
Compare commits
67 Commits
ticket/scr
...
ticket/64-
Author | SHA1 | Date | |
---|---|---|---|
532f2dd842
|
|||
d14d4d4d8f
|
|||
a22cbe0239 | |||
98902bdeb8 | |||
592a0f3698
|
|||
d469eb19ad
|
|||
4765d4fe28 | |||
|
30bcb85549 | ||
189a9337b4
|
|||
c030232a73
|
|||
d4f9726f90
|
|||
8740025dbd
|
|||
6d8ef035ea
|
|||
60eab628ee
|
|||
1fd559b722
|
|||
b526e802d7
|
|||
60937152c3
|
|||
6d2e78ce55
|
|||
e566f60a4a
|
|||
c06531cddb
|
|||
61ca700bbe | |||
|
b43aeebc3c | ||
056e2dcc5f | |||
e57d1ac696 | |||
4a1da25fee
|
|||
0eff1d2e79 | |||
3928b2cc7a | |||
02783e5391
|
|||
be3b9f0f56
|
|||
ee006f55d6
|
|||
13b1c45271
|
|||
ad2b6d63ac
|
|||
bfbde078b7
|
|||
d42a1296c4
|
|||
4b7e3c1601
|
|||
6ea9af588b
|
|||
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,7 +1,12 @@
|
||||
import { trans, setLocale, setLocaleFallbacks } from "./ux-translator";
|
||||
import {
|
||||
trans,
|
||||
setLocale,
|
||||
getLocale,
|
||||
setLocaleFallbacks,
|
||||
} from "./ux-translator";
|
||||
|
||||
setLocaleFallbacks({"en": "fr", "nl": "fr", "fr": "en"});
|
||||
setLocale('fr');
|
||||
setLocaleFallbacks({ en: "fr", nl: "fr", fr: "en" });
|
||||
setLocale("fr");
|
||||
|
||||
export { trans };
|
||||
export * from '../var/translations';
|
||||
export { trans, getLocale };
|
||||
export * from "../var/translations";
|
||||
|
@@ -66,6 +66,7 @@ framework:
|
||||
'Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage': priority
|
||||
'Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage': async
|
||||
'Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage': async
|
||||
'Chill\TicketBundle\Messenger\PostTicketUpdateMessage': async
|
||||
# end of routes added by chill-bundles recipes
|
||||
# Route your messages to the transports
|
||||
# 'App\Message\YourMessage': async
|
||||
|
@@ -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,7 +14,6 @@ namespace Chill\MainBundle;
|
||||
use Chill\MainBundle\Cron\CronJobInterface;
|
||||
use Chill\MainBundle\CRUD\CompilerPass\CRUDControllerCompilerPass;
|
||||
use Chill\MainBundle\DependencyInjection\CompilerPass\ACLFlagsCompilerPass;
|
||||
use Chill\MainBundle\DependencyInjection\CompilerPass\MenuCompilerPass;
|
||||
use Chill\MainBundle\DependencyInjection\CompilerPass\NotificationCounterCompilerPass;
|
||||
use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompilerPass;
|
||||
use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass;
|
||||
@@ -70,7 +69,6 @@ class ChillMainBundle extends Bundle
|
||||
$container->addCompilerPass(new TimelineCompilerClass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
|
||||
$container->addCompilerPass(new WidgetsCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
|
||||
$container->addCompilerPass(new NotificationCounterCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
|
||||
$container->addCompilerPass(new MenuCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
|
||||
$container->addCompilerPass(new ACLFlagsCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
|
||||
$container->addCompilerPass(new CRUDControllerCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -0,0 +1,17 @@
|
||||
import {Gender, GenderTranslation} from "ChillMainAssets/types";
|
||||
|
||||
/**
|
||||
* Translates a given gender object into its corresponding gender translation string.
|
||||
*
|
||||
* @param {Gender|null} gender - The gender object to be translated, null values are also supported
|
||||
* @return {GenderTranslation} Returns the gender translation string corresponding to the provided gender,
|
||||
* or "unknown" if the gender is null.
|
||||
*/
|
||||
export function toGenderTranslation(gender: Gender|null): GenderTranslation
|
||||
{
|
||||
if (null === gender) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
return gender.genderTranslation;
|
||||
}
|
@@ -1,4 +1,5 @@
|
||||
import { TranslatableString } from "ChillMainAssets/types";
|
||||
import { DateTime, TranslatableString } from "ChillMainAssets/types";
|
||||
import { getLocale } from "translator";
|
||||
|
||||
/**
|
||||
* Localizes a translatable string object based on the current locale.
|
||||
@@ -17,11 +18,10 @@ import { TranslatableString } from "ChillMainAssets/types";
|
||||
* @returns The localized URL
|
||||
*/
|
||||
export function localizedUrl(url: string): string {
|
||||
const lang =
|
||||
document.documentElement.lang || navigator.language.split("-")[0] || "fr";
|
||||
const locale = getLocale();
|
||||
// Ensure url starts with a slash and does not already start with /{lang}/
|
||||
const normalizedUrl = url.startsWith("/") ? url : `/${url}`;
|
||||
const langPrefix = `/${lang}`;
|
||||
const langPrefix = `/${locale}`;
|
||||
if (normalizedUrl.startsWith(langPrefix + "/")) {
|
||||
return normalizedUrl;
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function localizeString(
|
||||
return "";
|
||||
}
|
||||
|
||||
const currentLocale = locale || navigator.language.split("-")[0] || "fr";
|
||||
const currentLocale = locale || getLocale();
|
||||
|
||||
if (translatableString[currentLocale]) {
|
||||
return translatableString[currentLocale];
|
||||
@@ -59,3 +59,47 @@ export function localizeString(
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
const datetimeFormats: Record<
|
||||
string,
|
||||
Record<string, Intl.DateTimeFormatOptions>
|
||||
> = {
|
||||
fr: {
|
||||
short: {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
},
|
||||
text: {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
long: {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: false,
|
||||
},
|
||||
hoursOnly: {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
export function localizeDateTimeFormat(
|
||||
dateTime: DateTime,
|
||||
format: keyof typeof datetimeFormats.fr = "short",
|
||||
): string {
|
||||
const locale = getLocale();
|
||||
const options =
|
||||
datetimeFormats[locale]?.[format] || datetimeFormats.fr[format];
|
||||
return new Intl.DateTimeFormat(locale, options).format(
|
||||
new Date(dateTime.datetime),
|
||||
);
|
||||
}
|
||||
|
||||
export default datetimeFormats;
|
||||
|
@@ -1,14 +1,64 @@
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gender translation.
|
||||
*
|
||||
* Match the GenderEnum in PHP code.
|
||||
*/
|
||||
export type GenderTranslation = "male" | "female" | "neutral" | "unknown";
|
||||
|
||||
/**
|
||||
* A gender
|
||||
*
|
||||
* See also
|
||||
*/
|
||||
export interface Gender {
|
||||
type: "chill_main_gender";
|
||||
id: number;
|
||||
label: string;
|
||||
genderTranslation: GenderTranslation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight reference to a Gender, used in POST / PUT requests.
|
||||
*/
|
||||
export interface SetGender {
|
||||
type: "chill_main_gender";
|
||||
id: number;
|
||||
// TODO
|
||||
}
|
||||
|
||||
export interface Household {
|
||||
@@ -28,6 +78,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 +288,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 +412,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"
|
||||
|
@@ -122,7 +122,6 @@ const tabDefinitions: TabDefinition[] = [
|
||||
];
|
||||
|
||||
const displayedTabs = computed(() => {
|
||||
// Always show MyCustoms first if present
|
||||
const tabs = [] as TabDefinition[];
|
||||
for (const tabEnum of homepageConfig.value.displayTabs) {
|
||||
const def = tabDefinitions.find(
|
||||
@@ -137,10 +136,7 @@ const activeTab = ref(Number(HomepageTabs[homepageConfig.value.defaultTab]));
|
||||
|
||||
const loading = computed(() => store.state.loading);
|
||||
|
||||
function selectTab(tab: HomepageTabs) {
|
||||
if (tab !== HomepageTabs.MyCustoms) {
|
||||
store.dispatch("getByTab", { tab: tab });
|
||||
}
|
||||
async function selectTab(tab: HomepageTabs) {
|
||||
activeTab.value = tab;
|
||||
}
|
||||
|
||||
|
@@ -2,7 +2,9 @@
|
||||
<li>
|
||||
<h2>{{ props.item.title }}</h2>
|
||||
<time class="createdBy" datetime="{{item.startDate.datetime}}">{{
|
||||
$d(newsItemStartDate(), "text")
|
||||
props.item?.startDate
|
||||
? localizeDateTimeFormat(props.item?.startDate, "text")
|
||||
: ""
|
||||
}}</time>
|
||||
<div class="content" v-if="shouldTruncate(item.content)">
|
||||
<div v-html="prepareContent(item.content)"></div>
|
||||
@@ -26,7 +28,9 @@
|
||||
<template #body>
|
||||
<p class="news-date">
|
||||
<time class="createdBy" datetime="{{item.startDate.datetime}}">{{
|
||||
$d(newsItemStartDate(), "text")
|
||||
props.item?.startDate
|
||||
? localizeDateTimeFormat(props.item?.startDate, "text")
|
||||
: ""
|
||||
}}</time>
|
||||
</p>
|
||||
<div v-html="convertMarkdownToHtml(item.content)"></div>
|
||||
@@ -42,7 +46,7 @@ import DOMPurify from "dompurify";
|
||||
import { NewsItemType } from "../../../types";
|
||||
import type { PropType } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { ISOToDatetime } from "../../../chill/js/date";
|
||||
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
@@ -133,7 +137,7 @@ const preprocess = (markdown: string): string => {
|
||||
};
|
||||
|
||||
const postprocess = (html: string): string => {
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node: any) => {
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
|
||||
if ("target" in node) {
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer");
|
||||
@@ -159,10 +163,6 @@ const prepareContent = (content: string): string => {
|
||||
const htmlContent = convertMarkdownToHtml(content);
|
||||
return truncateContent(htmlContent);
|
||||
};
|
||||
|
||||
const newsItemStartDate = (): null | Date => {
|
||||
return ISOToDatetime(props.item?.startDate.datetime);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
@@ -21,7 +21,7 @@
|
||||
</template>
|
||||
<template #tbody>
|
||||
<tr v-for="(c, i) in accompanyingCourses.results" :key="`course-${i}`">
|
||||
<td>{{ $d(new Date(c.openingDate.datetime), "short") }}</td>
|
||||
<td>{{ localizeDateTimeFormat(c.openingDate, "short") }}</td>
|
||||
<td>
|
||||
<span
|
||||
v-for="(issue, index) in c.socialIssues"
|
||||
@@ -82,6 +82,8 @@ import {
|
||||
CONFIDENTIAL,
|
||||
trans,
|
||||
} from "translator";
|
||||
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const accompanyingCourses: ComputedRef<PaginationResponse<AccompanyingCourse>> =
|
||||
|
@@ -1,62 +1,59 @@
|
||||
<template>
|
||||
<span v-if="noResults" class="chill-no-data-statement">
|
||||
{{ trans(NO_DASHBOARD) }}
|
||||
</span>
|
||||
<div v-else id="dashboards" class="container g-3">
|
||||
<div id="dashboards" class="container g-3">
|
||||
<div class="row">
|
||||
<div class="mbloc col-xs-12 col-sm-4">
|
||||
<div class="custom1">
|
||||
<ul class="list-unstyled">
|
||||
<li v-if="(counter.value?.notifications || 0) > 0">
|
||||
<li v-if="counter.notifications > 0">
|
||||
<span :class="counterClass">
|
||||
{{
|
||||
trans(COUNTER_UNREAD_NOTIFICATIONS, {
|
||||
n: counter.value?.notifications || 0,
|
||||
n: counter.notifications,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="(counter.value?.accompanyingCourses || 0) > 0">
|
||||
<li v-if="counter.accompanyingCourses > 0">
|
||||
<span :class="counterClass">
|
||||
{{
|
||||
trans(COUNTER_ASSIGNATED_COURSES, {
|
||||
n: counter.value?.accompanyingCourses || 0,
|
||||
n: counter.accompanyingCourses,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="(counter.value?.works || 0) > 0">
|
||||
<li v-if="counter.works > 0">
|
||||
<span :class="counterClass">
|
||||
{{
|
||||
trans(COUNTER_ASSIGNATED_ACTIONS, {
|
||||
n: counter.value?.works || 0,
|
||||
n: counter.works,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="(counter.value?.evaluations || 0) > 0">
|
||||
<li v-if="counter.evaluations > 0">
|
||||
<span :class="counterClass">
|
||||
{{
|
||||
trans(COUNTER_ASSIGNATED_EVALUATIONS, {
|
||||
n: counter.value?.evaluations || 0,
|
||||
n: counter.evaluations,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="(counter.value?.tasksAlert || 0) > 0">
|
||||
<li v-if="counter.tasksAlert > 0">
|
||||
<span :class="counterClass">
|
||||
{{
|
||||
trans(COUNTER_ALERT_TASKS, {
|
||||
n: counter.value?.tasksAlert || 0,
|
||||
n: counter.tasksAlert,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</li>
|
||||
<li v-if="(counter.value?.tasksWarning || 0) > 0">
|
||||
<li v-if="counter.tasksWarning > 0">
|
||||
<span :class="counterClass">
|
||||
{{
|
||||
trans(COUNTER_WARNING_TASKS, {
|
||||
n: counter.value?.tasksWarning || 0,
|
||||
n: counter.tasksWarning,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
@@ -85,7 +82,6 @@ import { useStore } from "vuex";
|
||||
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
|
||||
import News from "./DashboardWidgets/News.vue";
|
||||
import {
|
||||
NO_DASHBOARD,
|
||||
COUNTER_UNREAD_NOTIFICATIONS,
|
||||
COUNTER_ASSIGNATED_COURSES,
|
||||
COUNTER_ASSIGNATED_ACTIONS,
|
||||
@@ -105,14 +101,19 @@ interface MyCustom {
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const counter = computed(() => store.getters.counter);
|
||||
const counter = computed(() => ({
|
||||
notifications: store.state.homepage.notifications?.count ?? 0,
|
||||
accompanyingCourses: store.state.homepage.accompanyingCourses?.count ?? 0,
|
||||
works: store.state.homepage.works?.count ?? 0,
|
||||
evaluations: store.state.homepage.evaluations?.count ?? 0,
|
||||
tasksAlert: store.state.homepage.tasksAlert?.count ?? 0,
|
||||
tasksWarning: store.state.homepage.tasksWarning?.count ?? 0,
|
||||
}));
|
||||
|
||||
const counterClass = { counter: true };
|
||||
|
||||
const dashboardItems = ref<MyCustom[]>([]);
|
||||
|
||||
const noResults = computed(() => false);
|
||||
|
||||
const hasDashboardItems = computed(() => dashboardItems.value.length > 0);
|
||||
|
||||
onMounted(async () => {
|
||||
|
@@ -22,11 +22,7 @@
|
||||
<template #tbody>
|
||||
<tr v-for="(e, i) in evaluations.results" :key="`evaluation-${i}`">
|
||||
<td>
|
||||
{{
|
||||
e.maxDate?.datetime
|
||||
? $d(new Date(e.maxDate.datetime), "short")
|
||||
: ""
|
||||
}}
|
||||
{{ e.maxDate ? localizeDateTimeFormat(e.maxDate, "short") : "" }}
|
||||
</td>
|
||||
<td>
|
||||
{{ localizeString(e.evaluation?.title ?? null) }}
|
||||
@@ -115,6 +111,8 @@ import {
|
||||
NO_DATA,
|
||||
trans,
|
||||
} from "translator";
|
||||
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
|
||||
|
||||
const evaluations: ComputedRef<
|
||||
PaginationResponse<AccompanyingPeriodWorkEvaluation>
|
||||
> = computed(() => store.state.homepage.evaluations);
|
||||
|
@@ -20,7 +20,7 @@
|
||||
</template>
|
||||
<template #tbody>
|
||||
<tr v-for="(n, i) in notifications.results" :key="`notify-${i}`">
|
||||
<td>{{ $d(new Date(n.date.datetime), "long") }}</td>
|
||||
<td>{{ localizeDateTimeFormat(n.date, "long") }}</td>
|
||||
<td>
|
||||
<span class="unread">
|
||||
<i class="fa fa-envelope-o" />
|
||||
@@ -65,6 +65,8 @@ import {
|
||||
trans,
|
||||
} from "translator";
|
||||
import { PaginationResponse } from "ChillMainAssets/lib/api/apiMethods";
|
||||
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const notifications: ComputedRef<PaginationResponse<Notification>> = computed(
|
||||
|
@@ -21,12 +21,12 @@
|
||||
<template #tbody>
|
||||
<tr v-for="(t, i) in tasks.alert.results" :key="`task-alert-${i}`">
|
||||
<td v-if="t.warningDate !== null">
|
||||
{{ $d(new Date(t.warningDate.datetime), "short") }}
|
||||
{{ localizeDateTimeFormat(t.warningDate, "short") }}
|
||||
</td>
|
||||
<td v-else />
|
||||
<td>
|
||||
<span class="outdated">{{
|
||||
$d(new Date(t.endDate.datetime), "short")
|
||||
localizeDateTimeFormat(t.endDate, "short")
|
||||
}}</span>
|
||||
</td>
|
||||
<td>{{ t.title }}</td>
|
||||
@@ -62,10 +62,10 @@
|
||||
<tr v-for="(t, i) in tasks.warning.results" :key="`task-warning-${i}`">
|
||||
<td>
|
||||
<span class="outdated">{{
|
||||
$d(new Date(t.warningDate.datetime), "short")
|
||||
localizeDateTimeFormat(t.warningDate, "short")
|
||||
}}</span>
|
||||
</td>
|
||||
<td>{{ $d(new Date(t.endDate.datetime), "short") }}</td>
|
||||
<td>{{ localizeDateTimeFormat(t.endDate, "short") }}</td>
|
||||
<td>{{ t.title }}</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-show" :href="getUrl(t)">
|
||||
@@ -94,6 +94,7 @@ import {
|
||||
} from "translator";
|
||||
import { TasksState } from "./store/modules/homepage";
|
||||
import { Alert, Warning } from "ChillPersonAssets/types";
|
||||
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
|
||||
|
||||
const store = useStore();
|
||||
|
||||
|
@@ -21,7 +21,7 @@
|
||||
</template>
|
||||
<template #tbody>
|
||||
<tr v-for="(w, i) in works.value.results" :key="`works-${i}`">
|
||||
<td>{{ $d(w.startDate.datetime, "short") }}</td>
|
||||
<td>{{ localizeDateTimeFormat(w.startDate.datetime, "short") }}</td>
|
||||
<td>
|
||||
<span class="chill-entity entity-social-issue">
|
||||
<span class="badge bg-chill-l-gray text-dark">
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
trans,
|
||||
} from "translator";
|
||||
import { Workflow } from "ChillPersonAssets/types";
|
||||
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
|
||||
|
||||
const store = useStore();
|
||||
|
||||
|
@@ -11,6 +11,7 @@ import {
|
||||
Warning,
|
||||
Workflow,
|
||||
WorflowCc,
|
||||
Notification,
|
||||
} from "ChillPersonAssets/types";
|
||||
import { RootState } from "..";
|
||||
import { HomepageTabs } from "ChillMainAssets/types";
|
||||
@@ -191,6 +192,7 @@ export const moduleHomepage: Module<State, RootState> = {
|
||||
if (!getters.isNotificationsLoaded) {
|
||||
commit("setLoading", true);
|
||||
const url = `/api/1.0/main/notification/my/unread${"?" + param}`;
|
||||
|
||||
makeFetch("GET", url)
|
||||
.then((response) => {
|
||||
commit("addNotifications", response);
|
||||
|
@@ -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 });
|
||||
});
|
||||
|
@@ -1,28 +1,28 @@
|
||||
<template>
|
||||
<i :class="['fa', genderClass, 'px-1']" />
|
||||
<i :class="['bi', genderClass]"></i>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
const props = defineProps({
|
||||
gender: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
import type { Gender } from "ChillMainAssets/types";
|
||||
import {toGenderTranslation} from "ChillMainAssets/lib/api/genderHelper";
|
||||
|
||||
const genderClass = computed(() => {
|
||||
switch (props.gender.genderTranslation) {
|
||||
case "woman":
|
||||
return "fa-venus";
|
||||
case "man":
|
||||
return "fa-mars";
|
||||
case "both":
|
||||
return "fa-neuter";
|
||||
interface GenderIconRenderBoxProps {
|
||||
gender: Gender;
|
||||
}
|
||||
|
||||
const props = defineProps<GenderIconRenderBoxProps>();
|
||||
|
||||
const genderClass = computed<string>(() => {
|
||||
switch (toGenderTranslation(props.gender)) {
|
||||
case "female":
|
||||
return "bi-gender-female";
|
||||
case "male":
|
||||
return "bi-gender-male";
|
||||
case "neutral":
|
||||
case "unknown":
|
||||
return "fa-genderless";
|
||||
default:
|
||||
return "fa-genderless";
|
||||
return "bi-gender-neuter";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
@@ -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<{
|
||||
|
@@ -1,34 +1,16 @@
|
||||
{#
|
||||
* Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS,
|
||||
<info@champs-libres.coop> / <http://www.champs-libres.coop>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#}
|
||||
|
||||
<li class="nav-item dropdown btn btn-primary nav-section">
|
||||
<a id="menu-section"
|
||||
class="nav-link dropdown-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
<a id="menu-section"
|
||||
class="nav-link dropdown-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false">
|
||||
|
||||
|
||||
{{ 'Sections'|trans }}
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="menu-section">
|
||||
{% for menu in menus %}
|
||||
<a class="dropdown-item list-group-item bg-dark text-white"
|
||||
<a class="dropdown-item list-group-item bg-dark text-white"
|
||||
href="{{ menu.uri }}">
|
||||
{{ menu.label }}
|
||||
{% apply spaceless %}
|
||||
|
@@ -13,39 +13,37 @@ namespace Chill\MainBundle\Routing;
|
||||
|
||||
use Knp\Menu\FactoryInterface;
|
||||
use Knp\Menu\ItemInterface;
|
||||
use Symfony\Component\Routing\RouteCollection;
|
||||
use Knp\Menu\MenuItem;
|
||||
use Symfony\Component\Routing\RouterInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* This class permit to build menu from the routing information
|
||||
* stored in each bundle.
|
||||
*
|
||||
* how to must come here FIXME
|
||||
*/
|
||||
class MenuComposer
|
||||
final readonly class MenuComposer
|
||||
{
|
||||
private array $localMenuBuilders = [];
|
||||
public function __construct(
|
||||
private RouterInterface $router,
|
||||
private FactoryInterface $menuFactory,
|
||||
private TranslatorInterface $translator,
|
||||
/**
|
||||
* @var iterable<LocalMenuBuilderInterface>
|
||||
*/
|
||||
private iterable $localMenuBuilders,
|
||||
) {}
|
||||
|
||||
private RouteCollection $routeCollection;
|
||||
|
||||
public function __construct(private readonly RouterInterface $router, private readonly FactoryInterface $menuFactory, private readonly TranslatorInterface $translator) {}
|
||||
|
||||
public function addLocalMenuBuilder(LocalMenuBuilderInterface $menuBuilder, $menuId)
|
||||
public function getMenuFor($menuId, array $parameters = []): ItemInterface
|
||||
{
|
||||
$this->localMenuBuilders[$menuId][] = $menuBuilder;
|
||||
}
|
||||
|
||||
public function getMenuFor($menuId, array $parameters = [])
|
||||
{
|
||||
$routes = $this->getRoutesFor($menuId, $parameters);
|
||||
$routes = $this->getRoutesForInternal($menuId, $parameters);
|
||||
/** @var MenuItem $menu */
|
||||
$menu = $this->menuFactory->createItem($menuId);
|
||||
|
||||
// build menu from routes
|
||||
foreach ($routes as $order => $route) {
|
||||
$menu->addChild($this->translator->trans($route['label']), [
|
||||
'route' => $route['key'],
|
||||
'routeParameters' => $parameters['args'],
|
||||
'routeParameters' => $parameters,
|
||||
'order' => $order,
|
||||
])
|
||||
->setExtras([
|
||||
@@ -55,10 +53,9 @@ class MenuComposer
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->hasLocalMenuBuilder($menuId)) {
|
||||
foreach ($this->localMenuBuilders[$menuId] as $builder) {
|
||||
/* @var $builder LocalMenuBuilderInterface */
|
||||
$builder->buildMenu($menuId, $menu, $parameters['args']);
|
||||
foreach ($this->localMenuBuilders as $builder) {
|
||||
if (in_array($menuId, $builder::getMenuIds(), true)) {
|
||||
$builder->buildMenu($menuId, $menu, $parameters);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,12 +68,16 @@ class MenuComposer
|
||||
* Return an array of routes added to $menuId,
|
||||
* The array is aimed to build route with MenuTwig.
|
||||
*
|
||||
* @param string $menuId
|
||||
* @param array $parameters see https://redmine.champs-libres.coop/issues/179
|
||||
* @deprecated
|
||||
*
|
||||
* @return array
|
||||
* @param array $parameters see https://redmine.champs-libres.coop/issues/179
|
||||
*/
|
||||
public function getRoutesFor($menuId, array $parameters = [])
|
||||
public function getRoutesFor(string $menuId, array $parameters = []): array
|
||||
{
|
||||
return $this->getRoutesForInternal($menuId, $parameters);
|
||||
}
|
||||
|
||||
private function getRoutesForInternal(string $menuId, array $parameters = []): array
|
||||
{
|
||||
$routes = [];
|
||||
$routeCollection = $this->router->getRouteCollection();
|
||||
@@ -108,22 +109,17 @@ class MenuComposer
|
||||
* should be used, or `getRouteFor`. The method `getMenuFor` should be used
|
||||
* if the result is true (it **does** exists at least one menu builder.
|
||||
*
|
||||
* @param string $menuId
|
||||
* @deprecated
|
||||
*/
|
||||
public function hasLocalMenuBuilder($menuId): bool
|
||||
public function hasLocalMenuBuilder(string $menuId): bool
|
||||
{
|
||||
return \array_key_exists($menuId, $this->localMenuBuilders);
|
||||
}
|
||||
foreach ($this->localMenuBuilders as $localMenuBuilder) {
|
||||
if (in_array($menuId, $localMenuBuilder::getMenuIds(), true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the route Collection
|
||||
* This function is needed for testing purpose: routeCollection is not
|
||||
* available as a service (RouterInterface is provided as a service and
|
||||
* added to this class as paramater in __construct).
|
||||
*/
|
||||
public function setRouteCollection(RouteCollection $routeCollection)
|
||||
{
|
||||
$this->routeCollection = $routeCollection;
|
||||
return false;
|
||||
}
|
||||
|
||||
private function reorderMenu(ItemInterface $menu)
|
||||
|
@@ -18,7 +18,7 @@ use Twig\TwigFunction;
|
||||
/**
|
||||
* Add the filter 'chill_menu'.
|
||||
*/
|
||||
class MenuTwig extends AbstractExtension
|
||||
final class MenuTwig extends AbstractExtension
|
||||
{
|
||||
/**
|
||||
* the default parameters for chillMenu.
|
||||
@@ -43,22 +43,16 @@ class MenuTwig extends AbstractExtension
|
||||
*
|
||||
* @deprecated link: see https://redmine.champs-libres.coop/issues/179 for more informations
|
||||
*
|
||||
* @param string $menuId
|
||||
* @param mixed[] $params
|
||||
* @param array{layout?: string, activeRouteKey?: string|null, args?: array<array-key, mixed>} $params
|
||||
*/
|
||||
public function chillMenu(Environment $env, $menuId, array $params = [])
|
||||
public function chillMenu(Environment $env, string $menuId, array $params = []): string
|
||||
{
|
||||
$resolvedParams = array_merge($this->defaultParams, $params);
|
||||
|
||||
$layout = $resolvedParams['layout'];
|
||||
unset($resolvedParams['layout']);
|
||||
|
||||
if (false === $this->menuComposer->hasLocalMenuBuilder($menuId)) {
|
||||
$resolvedParams['routes'] = $this->menuComposer->getRoutesFor($menuId, $resolvedParams);
|
||||
|
||||
return $env->render($layout, $resolvedParams);
|
||||
}
|
||||
$resolvedParams['menus'] = $this->menuComposer->getMenuFor($menuId, $resolvedParams);
|
||||
$resolvedParams['routes'] = $this->menuComposer->getMenuFor($menuId, $resolvedParams['args']);
|
||||
$resolvedParams['menus'] = $resolvedParams['routes'];
|
||||
|
||||
return $env->render($layout, $resolvedParams);
|
||||
}
|
||||
|
@@ -80,9 +80,7 @@ class ExtractPhonenumberFromPattern
|
||||
}
|
||||
|
||||
if (5 < $length) {
|
||||
$filtered = \trim(\strtr($subject, [$matches[0] => '']));
|
||||
|
||||
return new SearchExtractionResult($filtered, [\implode('', $phonenumber)]);
|
||||
return new SearchExtractionResult($subject, [\implode('', $phonenumber)]);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -43,20 +43,20 @@ final class ExtractPhonenumberFromPatternTest extends KernelTestCase
|
||||
|
||||
yield ['BE', 'Diallo 15/06/2021', 0, [], 'Diallo 15/06/2021', 'no phonenumber and a date'];
|
||||
|
||||
yield ['BE', 'Diallo 0486 123 456', 1, ['+32486123456'], 'Diallo', 'a phonenumber and a name'];
|
||||
yield ['BE', 'Diallo 0486 123 456', 1, ['+32486123456'], 'Diallo 0486 123 456', 'a phonenumber and a name'];
|
||||
|
||||
yield ['BE', 'Diallo 123 456', 1, ['123456'], 'Diallo', 'a number and a name, without leadiing 0'];
|
||||
yield ['BE', 'Diallo 123 456', 1, ['123456'], 'Diallo 123 456', 'a number and a name, without leadiing 0'];
|
||||
|
||||
yield ['BE', '123 456', 1, ['123456'], '', 'only phonenumber'];
|
||||
yield ['BE', '123 456', 1, ['123456'], '123 456', 'only phonenumber'];
|
||||
|
||||
yield ['BE', '0123 456', 1, ['+32123456'], '', 'only phonenumber with a leading 0'];
|
||||
yield ['BE', '0123 456', 1, ['+32123456'], '0123 456', 'only phonenumber with a leading 0'];
|
||||
|
||||
yield ['FR', '123 456', 1, ['123456'], '', 'only phonenumber'];
|
||||
yield ['FR', '123 456', 1, ['123456'], '123 456', 'only phonenumber'];
|
||||
|
||||
yield ['FR', '0123 456', 1, ['+33123456'], '', 'only phonenumber with a leading 0'];
|
||||
yield ['FR', '0123 456', 1, ['+33123456'], '0123 456', 'only phonenumber with a leading 0'];
|
||||
|
||||
yield ['FR', 'Diallo 0486 123 456', 1, ['+33486123456'], 'Diallo', 'a phonenumber and a name'];
|
||||
yield ['FR', 'Diallo 0486 123 456', 1, ['+33486123456'], 'Diallo 0486 123 456', 'a phonenumber and a name'];
|
||||
|
||||
yield ['FR', 'Diallo +32486 123 456', 1, ['+32486123456'], 'Diallo', 'a phonenumber and a name'];
|
||||
yield ['FR', 'Diallo +32486 123 456', 1, ['+32486123456'], 'Diallo +32486 123 456', 'a phonenumber and a name'];
|
||||
}
|
||||
}
|
||||
|
@@ -11,44 +11,107 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\MainBundle\Tests\Services;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
|
||||
use Chill\MainBundle\Routing\MenuComposer;
|
||||
use Knp\Menu\MenuFactory;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Component\Routing\RouterInterface;
|
||||
use Symfony\Component\Routing\RouteCollection;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* This class provide functional test for MenuComposer.
|
||||
* Tests for MenuComposer methods.
|
||||
*
|
||||
* We only verify that items provided by local menu builders are present
|
||||
* when getRoutesFor() yields no routes, and that hasLocalMenuBuilder behaves
|
||||
* as expected with the configured builders.
|
||||
*
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
final class MenuComposerTest extends KernelTestCase
|
||||
final class MenuComposerTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @var \Symfony\Bundle\FrameworkBundle\Routing\DelegatingLoader;
|
||||
*/
|
||||
private $loader;
|
||||
use ProphecyTrait;
|
||||
|
||||
/**
|
||||
* @var \Chill\MainBundle\DependencyInjection\Services\MenuComposer;
|
||||
*/
|
||||
private $menuComposer;
|
||||
|
||||
protected function setUp(): void
|
||||
private function buildMenuComposerWithDefaultBuilder(): array
|
||||
{
|
||||
self::bootKernel(['environment' => 'test']);
|
||||
$this->menuComposer = self::getContainer()
|
||||
->get('chill.main.menu_composer');
|
||||
// Router: returns an empty RouteCollection so getRoutesFor() yields []
|
||||
$routerProphecy = $this->prophesize(RouterInterface::class);
|
||||
$routerProphecy->getRouteCollection()->willReturn(new RouteCollection());
|
||||
$router = $routerProphecy->reveal();
|
||||
|
||||
// Menu factory from Knp\Menu
|
||||
$menuFactory = new MenuFactory();
|
||||
|
||||
// Translator: identity translator
|
||||
$translator = new class () implements TranslatorInterface {
|
||||
public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string
|
||||
{
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function getLocale(): string
|
||||
{
|
||||
return 'en';
|
||||
}
|
||||
};
|
||||
|
||||
// Local builder that adds two items to the requested menu
|
||||
$builder = new class () implements LocalMenuBuilderInterface {
|
||||
public static function getMenuIds(): array
|
||||
{
|
||||
return ['main'];
|
||||
}
|
||||
|
||||
public function buildMenu($menuId, \Knp\Menu\MenuItem $menu, array $parameters)
|
||||
{
|
||||
// Ensure we can use parameters passed to getMenuFor
|
||||
$suffix = $parameters['suffix'] ?? '';
|
||||
$menu->addChild('local_item_one', [
|
||||
'label' => 'Local Item One'.$suffix,
|
||||
])->setExtras(['order' => 1]);
|
||||
$menu->addChild('local_item_two', [
|
||||
'label' => 'Local Item Two'.$suffix,
|
||||
])->setExtras(['order' => 2]);
|
||||
}
|
||||
};
|
||||
|
||||
$composer = new MenuComposer(
|
||||
$router,
|
||||
$menuFactory,
|
||||
$translator,
|
||||
[$builder]
|
||||
);
|
||||
|
||||
return [$composer, $builder];
|
||||
}
|
||||
|
||||
/**
|
||||
* @covers \Chill\MainBundle\Routing\MenuComposer
|
||||
*/
|
||||
public function testMenuComposer()
|
||||
public function testGetMenuForReturnsItemsFromLocalBuildersOnly(): void
|
||||
{
|
||||
$collection = new RouteCollection();
|
||||
[$composer] = $this->buildMenuComposerWithDefaultBuilder();
|
||||
|
||||
$routes = $this->menuComposer->getRoutesFor('dummy0');
|
||||
$menu = $composer->getMenuFor('main', []);
|
||||
|
||||
$this->assertIsArray($routes);
|
||||
// No routes were added, only local builder items should be present
|
||||
$children = $menu->getChildren();
|
||||
self::assertCount(2, $children, 'Menu should contain exactly the items provided by local builders');
|
||||
|
||||
// Assert the two expected items exist with their names
|
||||
self::assertNotNull($menu->getChild('local_item_one'));
|
||||
self::assertNotNull($menu->getChild('local_item_two'));
|
||||
|
||||
// And that their labels include the parameter suffix
|
||||
self::assertSame('Local Item One', $menu->getChild('local_item_one')->getLabel());
|
||||
self::assertSame('Local Item Two', $menu->getChild('local_item_two')->getLabel());
|
||||
}
|
||||
|
||||
public function testHasLocalMenuBuilder(): void
|
||||
{
|
||||
[$composer] = $this->buildMenuComposerWithDefaultBuilder();
|
||||
|
||||
self::assertTrue($composer->hasLocalMenuBuilder('main'));
|
||||
self::assertFalse($composer->hasLocalMenuBuilder('secondary'));
|
||||
}
|
||||
}
|
||||
|
@@ -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) {}
|
||||
|
@@ -6,9 +6,8 @@ services:
|
||||
chill.main.menu_composer:
|
||||
class: Chill\MainBundle\Routing\MenuComposer
|
||||
arguments:
|
||||
- '@Symfony\Component\Routing\RouterInterface'
|
||||
- '@Knp\Menu\FactoryInterface'
|
||||
- '@Symfony\Contracts\Translation\TranslatorInterface'
|
||||
$localMenuBuilders: !tagged_iterator 'chill.menu_builder'
|
||||
|
||||
Chill\MainBundle\Routing\MenuComposer: '@chill.main.menu_composer'
|
||||
|
||||
chill.main.routes_loader:
|
||||
|
@@ -136,34 +136,6 @@ filter_order:
|
||||
Search: Chercher dans la liste
|
||||
By date: Filtrer par date
|
||||
search_box: Filtrer par contenu
|
||||
renderbox:
|
||||
person: "Usager"
|
||||
birthday:
|
||||
man: "Né le"
|
||||
woman: "Née le"
|
||||
neutral: "Né·e le"
|
||||
unknown: "Né·e le"
|
||||
deathdate: "Date de décès"
|
||||
household_without_address: "Le ménage de l'usager est sans adresse"
|
||||
no_data: "Aucune information renseignée"
|
||||
type:
|
||||
thirdparty: "Tiers"
|
||||
person: "Usager"
|
||||
holder: "Titulaire"
|
||||
years_old: >-
|
||||
{n, plural,
|
||||
=0 {0 an}
|
||||
one {1 an}
|
||||
other {# ans}
|
||||
}
|
||||
residential_address: "Adresse de résidence"
|
||||
located_at: "réside chez"
|
||||
household_number: "Ménage n°{number}"
|
||||
current_members: "Membres actuels"
|
||||
no_current_address: "Sans adresse actuellement"
|
||||
new_household: "Nouveau ménage"
|
||||
no_members_yet: "Aucun membre actuellement"
|
||||
|
||||
pick_entity:
|
||||
add: "Ajouter"
|
||||
modal_title: >-
|
||||
|
@@ -279,7 +279,7 @@ final class PersonController extends AbstractController
|
||||
private function lastPostDataBuildHash(Form $form, Request $request): string
|
||||
{
|
||||
$fields = [];
|
||||
$ignoredFields = ['form_status', '_token'];
|
||||
$ignoredFields = ['form_status', '_token', 'identifiers'];
|
||||
|
||||
foreach ($request->request->all()[$form->getName()] as $field => $value) {
|
||||
if (\in_array($field, $ignoredFields, true)) {
|
||||
|
@@ -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,16 @@ declare(strict_types=1);
|
||||
namespace Chill\PersonBundle\Entity\Identifier;
|
||||
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Chill\PersonBundle\PersonIdentifier\Validator\UniqueIdentifierConstraint;
|
||||
use Chill\PersonBundle\PersonIdentifier\Validator\ValidIdentifierConstraint;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'chill_person_identifier')]
|
||||
#[ORM\UniqueConstraint(name: 'chill_person_identifier_unique', columns: ['definition_id', 'canonical'])]
|
||||
#[ORM\UniqueConstraint(name: 'chill_person_identifier_unique_person_definition', columns: ['definition_id', 'person_id'])]
|
||||
#[UniqueIdentifierConstraint]
|
||||
#[ValidIdentifierConstraint]
|
||||
class PersonIdentifier
|
||||
{
|
||||
#[ORM\Id]
|
||||
@@ -30,12 +36,12 @@ 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(
|
||||
#[ORM\ManyToOne(targetEntity: PersonIdentifierDefinition::class)]
|
||||
#[ORM\JoinColumn(name: 'definition_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
#[ORM\JoinColumn(name: 'definition_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
|
||||
private PersonIdentifierDefinition $definition,
|
||||
) {}
|
||||
|
||||
|
@@ -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, [
|
||||
|
@@ -38,8 +38,13 @@ final readonly class PersonIdentifiersDataMapper implements DataMapperInterface
|
||||
if (!$worker->getDefinition()->isEditableByUsers()) {
|
||||
continue;
|
||||
}
|
||||
$form = $formsByKey['identifier_'.$worker->getDefinition()->getId()];
|
||||
$identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition()->getId() === $identifier->getId());
|
||||
|
||||
$form = $formsByKey['identifier_'.$worker->getDefinition()->getId()] ?? null;
|
||||
if (null === $form) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition() === $identifier->getDefinition());
|
||||
if (null === $identifier) {
|
||||
$identifier = new PersonIdentifier($worker->getDefinition());
|
||||
}
|
||||
@@ -55,7 +60,7 @@ final readonly class PersonIdentifiersDataMapper implements DataMapperInterface
|
||||
|
||||
foreach ($forms as $name => $form) {
|
||||
$identifierId = (int) substr((string) $name, 11);
|
||||
$identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $identifier->getId() === $identifierId);
|
||||
$identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $identifier->getDefinition()->getId() === $identifierId);
|
||||
$definition = $this->identifierDefinitionRepository->find($identifierId);
|
||||
if (null === $identifier) {
|
||||
$identifier = new PersonIdentifier($definition);
|
||||
|
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
@@ -13,20 +13,26 @@ namespace Chill\PersonBundle\PersonIdentifier\Identifier;
|
||||
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Chill\PersonBundle\PersonIdentifier\IdentifierViolationDTO;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
||||
final readonly class StringIdentifier implements PersonIdentifierEngineInterface
|
||||
{
|
||||
public const NAME = 'chill-person-bundle.string-identifier';
|
||||
|
||||
private const ONLY_NUMBERS = 'only_numbers';
|
||||
private const FIXED_LENGTH = 'fixed_length';
|
||||
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'chill-person-bundle.string-identifier';
|
||||
return self::NAME;
|
||||
}
|
||||
|
||||
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 +42,32 @@ 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'] ?? '');
|
||||
}
|
||||
|
||||
public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array
|
||||
{
|
||||
$config = $definition->getData();
|
||||
$content = (string) ($identifier->getValue()['content'] ?? '');
|
||||
$violations = [];
|
||||
|
||||
if (($config[self::ONLY_NUMBERS] ?? false) && !preg_match('/^[0-9]+$/', $content)) {
|
||||
$violations[] = new IdentifierViolationDTO('person_identifier.only_number', '2a3352c0-a2b9-11f0-a767-b7a3f80e52f1');
|
||||
}
|
||||
|
||||
if (null !== ($config[self::FIXED_LENGTH] ?? null) && strlen($content) !== $config[self::FIXED_LENGTH]) {
|
||||
$violations[] = new IdentifierViolationDTO(
|
||||
'person_identifier.fixed_length',
|
||||
'2b02a8fe-a2b9-11f0-bfe5-033300972783',
|
||||
['limit' => (string) $config[self::FIXED_LENGTH]]
|
||||
);
|
||||
}
|
||||
|
||||
return $violations;
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\PersonIdentifier;
|
||||
|
||||
/**
|
||||
* Data Transfer Object to create a ConstraintViolationListInterface.
|
||||
*/
|
||||
class IdentifierViolationDTO
|
||||
{
|
||||
public function __construct(
|
||||
public string $message,
|
||||
/**
|
||||
* @var string an UUID
|
||||
*/
|
||||
public string $code,
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public array $parameters = [],
|
||||
public string $messageDomain = 'validators',
|
||||
) {}
|
||||
}
|
@@ -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,19 @@ 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;
|
||||
|
||||
/**
|
||||
* Return a list of @see{IdentifierViolationDTO} to generatie violation errors.
|
||||
*
|
||||
* @return list<IdentifierViolationDTO>
|
||||
*/
|
||||
public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array;
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
||||
|
@@ -18,9 +18,16 @@ interface PersonIdentifierManagerInterface
|
||||
/**
|
||||
* Build PersonIdentifierWorker's for all active definition.
|
||||
*
|
||||
* Only active definition are returned.
|
||||
*
|
||||
* @return list<PersonIdentifierWorker>
|
||||
*/
|
||||
public function getWorkers(): array;
|
||||
|
||||
public function buildWorkerByPersonIdentifierDefinition(PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker;
|
||||
/**
|
||||
* @param int|PersonIdentifierDefinition $personIdentifierDefinition an instance of PersonIdentifierDefinition, or his id
|
||||
*
|
||||
* @throw PersonIdentifierNotFoundException
|
||||
*/
|
||||
public function buildWorkerByPersonIdentifierDefinition(int|PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker;
|
||||
}
|
||||
|
@@ -15,7 +15,7 @@ use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
|
||||
final readonly class PersonIdentifierWorker
|
||||
readonly class PersonIdentifierWorker
|
||||
{
|
||||
public function __construct(
|
||||
private PersonIdentifierEngineInterface $identifierEngine,
|
||||
@@ -46,4 +46,20 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<IdentifierViolationDTO>
|
||||
*/
|
||||
public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array
|
||||
{
|
||||
return $this->identifierEngine->validate($identifier, $definition);
|
||||
}
|
||||
}
|
||||
|
@@ -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,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\PersonIdentifier;
|
||||
use Chill\PersonBundle\Repository\Identifier\PersonIdentifierRepository;
|
||||
use Chill\PersonBundle\Templating\Entity\PersonRenderInterface;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedValueException;
|
||||
|
||||
class UniqueIdentifierConstraintValidator extends ConstraintValidator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PersonIdentifierRepository $personIdentifierRepository,
|
||||
private readonly PersonRenderInterface $personRender,
|
||||
) {}
|
||||
|
||||
public function validate($value, Constraint $constraint): void
|
||||
{
|
||||
if (!$constraint instanceof UniqueIdentifierConstraint) {
|
||||
throw new UnexpectedTypeException($constraint, UniqueIdentifierConstraint::class);
|
||||
}
|
||||
|
||||
if (!$value instanceof PersonIdentifier) {
|
||||
throw new UnexpectedValueException($value, PersonIdentifier::class);
|
||||
}
|
||||
|
||||
$identifiers = $this->personIdentifierRepository->findByDefinitionAndCanonical($value->getDefinition(), $value->getValue());
|
||||
|
||||
if (count($identifiers) > 0) {
|
||||
if (count($identifiers) > 1 || $identifiers[0]->getPerson() !== $value->getPerson()) {
|
||||
$persons = array_map(fn (PersonIdentifier $idf): string => $this->personRender->renderString($idf->getPerson(), []), $identifiers);
|
||||
|
||||
$this->context->buildViolation($constraint->message)
|
||||
->setParameter('{{ persons }}', implode(', ', $persons))
|
||||
->setParameter('definition_id', (string) $value->getDefinition()->getId())
|
||||
->addViolation();
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\PersonIdentifier\Validator;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
#[\Attribute]
|
||||
class ValidIdentifierConstraint extends Constraint
|
||||
{
|
||||
public function getTargets(): string
|
||||
{
|
||||
return self::CLASS_CONSTRAINT;
|
||||
}
|
||||
}
|
@@ -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\PersonIdentifier\Validator;
|
||||
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedValueException;
|
||||
|
||||
final class ValidIdentifierConstraintValidator extends ConstraintValidator
|
||||
{
|
||||
public function __construct(private readonly PersonIdentifierManagerInterface $personIdentifierManager) {}
|
||||
|
||||
public function validate($value, Constraint $constraint): void
|
||||
{
|
||||
if (!$constraint instanceof ValidIdentifierConstraint) {
|
||||
throw new UnexpectedTypeException($constraint, ValidIdentifierConstraint::class);
|
||||
}
|
||||
|
||||
if (!$value instanceof PersonIdentifier) {
|
||||
throw new UnexpectedValueException($value, PersonIdentifier::class);
|
||||
}
|
||||
|
||||
$worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($value->getDefinition());
|
||||
|
||||
$violations = $worker->validate($value, $value->getDefinition());
|
||||
|
||||
foreach ($violations as $violation) {
|
||||
$this->context->buildViolation($violation->message)
|
||||
->setParameters($violation->parameters)
|
||||
->setParameter('{{ code }}', $violation->code)
|
||||
->setParameter('definition_id', (string) $value->getDefinition()->getId())
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
@@ -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\Repository\Identifier;
|
||||
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class PersonIdentifierRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry, private readonly PersonIdentifierManagerInterface $personIdentifierManager)
|
||||
{
|
||||
parent::__construct($registry, PersonIdentifier::class);
|
||||
}
|
||||
|
||||
public function findByDefinitionAndCanonical(PersonIdentifierDefinition $definition, array|string $valueOrCanonical): array
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->where('p.definition = :definition')
|
||||
->andWhere('p.canonical = :canonical')
|
||||
->setParameter('definition', $definition)
|
||||
->setParameter(
|
||||
'canonical',
|
||||
is_string($valueOrCanonical) ?
|
||||
$valueOrCanonical :
|
||||
$this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($definition)->canonicalizeValue($valueOrCanonical),
|
||||
)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
@@ -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,20 +141,53 @@ 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 * 1000000";
|
||||
$qArguments = [...$qArguments, $str, ...$idDefinitionWorkers];
|
||||
$pertinenceArgs = [...$pertinenceArgs, $str, ...$idDefinitionWorkers];
|
||||
}
|
||||
|
||||
$andWhereSearchClause[] = '('.implode(' OR ', $q).')';
|
||||
$andWhereSearchClauseArgs = [...$andWhereSearchClauseArgs, ...$qArguments];
|
||||
}
|
||||
|
||||
$query->andWhereClause(
|
||||
\implode(' AND ', $andWhereSearchClause),
|
||||
$andWhereSearchClauseArgs
|
||||
);
|
||||
} else {
|
||||
$pertinence = ['1'];
|
||||
$pertinenceArgs = [];
|
||||
}
|
||||
|
||||
if (null !== $phonenumber) {
|
||||
$personPhoneClause = "person.phonenumber LIKE '%' || ? || '%' OR person.mobilenumber LIKE '%' || ? || '%' OR EXISTS (SELECT 1 FROM chill_person_phone where person_id = person.id AND phonenumber LIKE '%' || ? || '%')";
|
||||
if (count($andWhereSearchClauseArgs) > 0) {
|
||||
$initialSearchClause = '(('.\implode(' AND ', $andWhereSearchClause).') OR '.$personPhoneClause.')';
|
||||
}
|
||||
$andWhereSearchClauseArgs = [...$andWhereSearchClauseArgs, $phonenumber, $phonenumber, $phonenumber];
|
||||
|
||||
// drastically increase pertinence
|
||||
$pertinence[] = "(person.phonenumber LIKE '%' || ? || '%' OR person.mobilenumber LIKE '%' || ? || '%' OR EXISTS (SELECT 1 FROM chill_person_phone where person_id = person.id AND phonenumber LIKE '%' || ? || '%'))::int * 1000000";
|
||||
$pertinenceArgs = [...$pertinenceArgs, $phonenumber, $phonenumber, $phonenumber];
|
||||
} else {
|
||||
$initialSearchClause = \implode(' AND ', $andWhereSearchClause);
|
||||
}
|
||||
|
||||
if (isset($initialSearchClause)) {
|
||||
$query->andWhereClause(
|
||||
$initialSearchClause,
|
||||
$andWhereSearchClauseArgs
|
||||
);
|
||||
}
|
||||
|
||||
$query
|
||||
->setSelectPertinence(\implode(' + ', $pertinence), $pertinenceArgs);
|
||||
|
||||
@@ -176,14 +226,6 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor
|
||||
);
|
||||
}
|
||||
|
||||
if (null !== $phonenumber) {
|
||||
$query->andWhereClause(
|
||||
"person.phonenumber LIKE '%' || ? || '%' OR person.mobilenumber LIKE '%' || ? || '%' OR pp.phonenumber LIKE '%' || ? || '%'",
|
||||
[$phonenumber, $phonenumber, $phonenumber]
|
||||
);
|
||||
$query->setFromClause($query->getFromClause().' LEFT JOIN chill_person_phone pp ON pp.person_id = person.id');
|
||||
}
|
||||
|
||||
if (null !== $city) {
|
||||
$query->setFromClause($query->getFromClause().' '.
|
||||
'JOIN view_chill_person_current_address vcpca ON vcpca.person_id = person.id '.
|
||||
|
@@ -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";
|
||||
@@ -41,6 +50,36 @@ export interface Person {
|
||||
civility: Civility | null;
|
||||
current_household_id: number;
|
||||
current_residential_addresses: Address[];
|
||||
/**
|
||||
* The person id as configured by the user
|
||||
*/
|
||||
personId: string;
|
||||
}
|
||||
|
||||
export interface PersonIdentifierWrite {
|
||||
type: "person_identifier";
|
||||
definition_id: number;
|
||||
value: object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Person representation to create or update a Person
|
||||
*/
|
||||
export interface PersonWrite {
|
||||
type: "person";
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
altNames: AltNameWrite[];
|
||||
// address: number | null;
|
||||
birthdate: DateTimeWrite | null;
|
||||
deathdate: DateTimeWrite | null;
|
||||
phonenumber: string;
|
||||
mobilenumber: string;
|
||||
email: string;
|
||||
gender: SetGender | null;
|
||||
center: SetCenter | null;
|
||||
civility: SetCivility | null;
|
||||
identifiers: PersonIdentifierWrite[];
|
||||
}
|
||||
|
||||
export interface AccompanyingPeriod {
|
||||
@@ -291,6 +330,7 @@ export interface Notification {
|
||||
relatedEntityClass: string;
|
||||
relatedEntityId: number;
|
||||
}
|
||||
|
||||
export interface Participation {
|
||||
person: Person;
|
||||
}
|
||||
@@ -328,11 +368,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 +417,8 @@ export interface Search {
|
||||
|
||||
export interface SearchOptions {
|
||||
uniq: boolean;
|
||||
type: string[];
|
||||
/** @deprecated */
|
||||
type: EntityType[];
|
||||
priority: number | null;
|
||||
button: {
|
||||
size: string;
|
||||
@@ -380,6 +428,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,414 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<modal
|
||||
@close="() => emit('close')"
|
||||
:modal-dialog-class="modalDialogClass"
|
||||
:hide-footer="false"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="modal-title">{{ modalTitle }}</h3>
|
||||
</template>
|
||||
|
||||
<template #body-head>
|
||||
<div class="modal-body">
|
||||
<div class="search">
|
||||
<label class="col-form-label" style="float: right">
|
||||
{{
|
||||
trans(ADD_PERSONS_SUGGESTED_COUNTER, {
|
||||
count: suggestedCounter,
|
||||
})
|
||||
}}
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="search-persons"
|
||||
name="query"
|
||||
v-model="query"
|
||||
:placeholder="trans(ADD_PERSONS_SEARCH_SOME_PERSONS)"
|
||||
ref="searchRef"
|
||||
/>
|
||||
<i class="fa fa-search fa-lg" />
|
||||
<i
|
||||
class="fa fa-times"
|
||||
v-if="queryLength >= 3"
|
||||
@click="resetSuggestion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" v-if="checkUniq === 'checkbox'">
|
||||
<div class="count">
|
||||
<span>
|
||||
<a v-if="suggestedCounter > 2" @click="selectAll">
|
||||
{{ trans(ACTION_CHECK_ALL) }}
|
||||
</a>
|
||||
<a v-if="selectedCounter > 0" @click="resetSelection">
|
||||
<i v-if="suggestedCounter > 2"> • </i>
|
||||
{{ trans(ACTION_RESET) }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="selectedCounter > 0">
|
||||
{{
|
||||
trans(ADD_PERSONS_SELECTED_COUNTER, { count: selectedCounter })
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="results">
|
||||
<person-suggestion
|
||||
v-for="item in selectedAndSuggested.slice().reverse()"
|
||||
:key="itemKey(item)"
|
||||
:item="item"
|
||||
:search="search"
|
||||
:type="checkUniq"
|
||||
@new-prior-suggestion="newPriorSuggestion"
|
||||
@update-selected="updateSelected"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="props.allowCreate && query.length > 0"
|
||||
class="create-button"
|
||||
>
|
||||
<button type="button" class="btn btn-submit" @click="emit('onAskForCreate', { query })">
|
||||
{{ trans(ONTHEFLY_CREATE_BUTTON, { q: query }) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-create"
|
||||
@click.prevent="pickEntities"
|
||||
>
|
||||
{{ trans(ACTION_ADD) }}
|
||||
</button>
|
||||
</template>
|
||||
</modal>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, nextTick, watch, onMounted } from "vue";
|
||||
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
|
||||
import PersonSuggestion from "./PersonSuggestion.vue";
|
||||
import OnTheFly from "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue";
|
||||
import { searchEntities } from "ChillPersonAssets/vuejs/_api/AddPersons";
|
||||
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
|
||||
|
||||
import {
|
||||
trans,
|
||||
ADD_PERSONS_SUGGESTED_COUNTER,
|
||||
ADD_PERSONS_SEARCH_SOME_PERSONS,
|
||||
ADD_PERSONS_SELECTED_COUNTER,
|
||||
ONTHEFLY_CREATE_BUTTON,
|
||||
ACTION_CHECK_ALL,
|
||||
ACTION_RESET,
|
||||
ACTION_ADD,
|
||||
} from "translator";
|
||||
|
||||
import type {
|
||||
Suggestion,
|
||||
Search,
|
||||
AddPersonResult as OriginalResult,
|
||||
SearchOptions,
|
||||
EntitiesOrMe,
|
||||
} from "ChillPersonAssets/types";
|
||||
|
||||
type Result = OriginalResult & { addressId?: number };
|
||||
|
||||
interface Props {
|
||||
modalTitle: string;
|
||||
options: SearchOptions;
|
||||
suggested?: Suggestion[];
|
||||
selected?: Suggestion[];
|
||||
modalDialogClass?: string;
|
||||
allowCreate?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
suggested: () => [],
|
||||
selected: () => [],
|
||||
modalDialogClass: "modal-dialog-scrollable modal-xl",
|
||||
allowCreate: () => true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
/** @deprecated use 'onPickEntities' */
|
||||
(e: "addNewPersons", payload: { selected: Suggestion[] }): void;
|
||||
(e: "onPickEntities", payload: { selected: EntitiesOrMe[] }): void;
|
||||
(e: "onAskForCreate", payload: { query: string }): void;
|
||||
}>();
|
||||
|
||||
const searchRef = ref<HTMLInputElement | null>(null);
|
||||
const onTheFly = ref<InstanceType<typeof OnTheFly> | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
// give the focus on the search bar
|
||||
searchRef.value?.focus();
|
||||
});
|
||||
|
||||
const search = reactive({
|
||||
query: "" as string,
|
||||
previousQuery: "" as string,
|
||||
currentSearchQueryController: null as AbortController | null,
|
||||
suggested: (props.suggested ?? []) as Suggestion[],
|
||||
selected: (props.selected ?? []) as Suggestion[],
|
||||
priorSuggestion: {} as Partial<Suggestion>,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.selected,
|
||||
(newSelected) => {
|
||||
search.selected = newSelected ? [...newSelected] : [];
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.suggested,
|
||||
(newSuggested) => {
|
||||
search.suggested = newSuggested ? [...newSuggested] : [];
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const query = computed({
|
||||
get: () => search.query,
|
||||
set: (val: string) => setQuery(val),
|
||||
});
|
||||
const queryLength = computed(() => search.query.length);
|
||||
const suggestedCounter = computed(() => search.suggested.length);
|
||||
const selectedComputed = computed<Suggestion[]>(() => search.selected);
|
||||
const selectedCounter = computed(() => search.selected.length);
|
||||
|
||||
const checkUniq = computed(() =>
|
||||
props.options.uniq === true ? "radio" : "checkbox",
|
||||
);
|
||||
|
||||
const priorSuggestion = computed(() => search.priorSuggestion);
|
||||
const hasPriorSuggestion = computed(() => !!search.priorSuggestion.key);
|
||||
|
||||
const itemKey = (item: Suggestion) => item.result.type + item.result.id;
|
||||
|
||||
function addPriorSuggestion() {
|
||||
if (hasPriorSuggestion.value) {
|
||||
search.suggested.unshift(priorSuggestion.value as Suggestion);
|
||||
search.selected.unshift(priorSuggestion.value as Suggestion);
|
||||
newPriorSuggestion(null);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedAndSuggested = computed(() => {
|
||||
addPriorSuggestion();
|
||||
const uniqBy = (a: Suggestion[], key: (item: Suggestion) => string) => [
|
||||
...new Map(a.map((x) => [key(x), x])).values(),
|
||||
];
|
||||
const union = [
|
||||
...new Set([
|
||||
...search.suggested.slice().reverse(),
|
||||
...search.selected.slice().reverse(),
|
||||
]),
|
||||
];
|
||||
return uniqBy(union, (k: Suggestion) => k.key);
|
||||
});
|
||||
|
||||
function setQuery(q: string) {
|
||||
search.query = q;
|
||||
|
||||
if (search.currentSearchQueryController) {
|
||||
search.currentSearchQueryController.abort();
|
||||
search.currentSearchQueryController = null;
|
||||
}
|
||||
|
||||
if (q === "") {
|
||||
loadSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = q.length > 3 ? 300 : 700;
|
||||
|
||||
setTimeout(() => {
|
||||
if (q !== search.query) return;
|
||||
|
||||
search.currentSearchQueryController = new AbortController();
|
||||
|
||||
searchEntities(
|
||||
{ query: q, options: props.options },
|
||||
search.currentSearchQueryController.signal,
|
||||
)
|
||||
.then((suggested: Search) => {
|
||||
loadSuggestions(suggested.results);
|
||||
})
|
||||
.catch((error: DOMException) => {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function loadSuggestions(suggestedArr: Suggestion[]) {
|
||||
search.suggested = suggestedArr;
|
||||
search.suggested.forEach((item) => {
|
||||
item.key = itemKey(item);
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelected(value: Suggestion[]) {
|
||||
search.selected = value;
|
||||
}
|
||||
|
||||
function resetSuggestion() {
|
||||
search.query = "";
|
||||
search.suggested = [];
|
||||
}
|
||||
|
||||
function resetSelection() {
|
||||
search.selected = [];
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
resetSelection();
|
||||
resetSuggestion();
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
search.suggested.forEach((item) => {
|
||||
search.selected.push(item);
|
||||
});
|
||||
}
|
||||
|
||||
function newPriorSuggestion(entity: Result | null) {
|
||||
if (entity !== null) {
|
||||
const suggestion: Suggestion = {
|
||||
key: entity.type + entity.id,
|
||||
relevance: 0.5,
|
||||
result: entity,
|
||||
} as Suggestion;
|
||||
search.priorSuggestion = suggestion;
|
||||
} else {
|
||||
search.priorSuggestion = {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when the user clicks on the "add" button.
|
||||
*/
|
||||
function pickEntities(): void {
|
||||
emit("addNewPersons", { selected: search.selected });
|
||||
emit("onPickEntities", {
|
||||
selected: search.selected.map((s: Suggestion) => s.result),
|
||||
});
|
||||
search.query = "";
|
||||
emit("close");
|
||||
}
|
||||
|
||||
/*
|
||||
TODO remove this
|
||||
async function saveFormOnTheFly({ type, data }: { type: string; data: Result }) {
|
||||
try {
|
||||
if (type === 'person') {
|
||||
const responsePerson: Result = await makeFetch('POST', '/api/1.0/person/person.json', data);
|
||||
newPriorSuggestion(responsePerson);
|
||||
onTheFly.value?.closeModal();
|
||||
|
||||
if (data.addressId != null) {
|
||||
const household = { type: 'household' };
|
||||
const address = { id: data.addressId };
|
||||
try {
|
||||
const responseHousehold: Result = await makeFetch('POST', '/api/1.0/person/household.json', household);
|
||||
const member = {
|
||||
concerned: [
|
||||
{
|
||||
person: { type: 'person', id: responsePerson.id },
|
||||
start_date: { datetime: `${new Date().toISOString().split('T')[0]}T00:00:00+02:00` },
|
||||
holder: false,
|
||||
comment: null,
|
||||
},
|
||||
],
|
||||
destination: { type: 'household', id: responseHousehold.id },
|
||||
composition: null,
|
||||
};
|
||||
await makeFetch('POST', '/api/1.0/person/household/members/move.json', member);
|
||||
try {
|
||||
await makeFetch('POST', `/api/1.0/person/household/${responseHousehold.id}/address.json`, address);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
} else if (type === 'thirdparty') {
|
||||
const response: Result = await makeFetch('POST', '/api/1.0/thirdparty/thirdparty.json', data);
|
||||
newPriorSuggestion(response);
|
||||
onTheFly.value?.closeModal();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
defineExpose({ resetSearch });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
li.add-persons {
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
div.body-head {
|
||||
overflow-y: unset;
|
||||
div.modal-body:first-child {
|
||||
margin: auto 4em;
|
||||
div.search {
|
||||
position: relative;
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 1.2em 1.5em 1.2em 2.5em;
|
||||
}
|
||||
i {
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
padding: 0.65em 0;
|
||||
top: 50%;
|
||||
}
|
||||
i.fa-search {
|
||||
left: 0.5em;
|
||||
}
|
||||
i.fa-times {
|
||||
right: 1em;
|
||||
padding: 0.75em 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
div.modal-body:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
div.count {
|
||||
margin: -0.5em 0 0.7em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.create-button > a {
|
||||
margin-top: 0.5em;
|
||||
margin-left: 2.6em;
|
||||
}
|
||||
</style>
|
@@ -5,46 +5,16 @@
|
||||
<div class="item-col">
|
||||
<div class="entity-label">
|
||||
<div :class="'denomination h' + options.hLevel">
|
||||
<a v-if="options.addLink === true" :href="getUrl">
|
||||
<!-- use person-text here to avoid code duplication ? TODO -->
|
||||
<span class="firstname">{{ person.firstName }}</span>
|
||||
<span class="lastname">{{ person.lastName }}</span>
|
||||
<span v-if="person.suffixText" class="suffixtext"
|
||||
> {{ person.suffixText }}</span
|
||||
>
|
||||
<span
|
||||
v-if="person.altNames && options.addAltNames == true"
|
||||
class="altnames"
|
||||
>
|
||||
<span :class="'altname altname-' + altNameKey">{{
|
||||
altNameLabel
|
||||
}}</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- use person-text here to avoid code duplication ? TODO -->
|
||||
<span class="firstname">{{ person.firstName + " " }}</span>
|
||||
<span class="lastname">{{ person.lastName }}</span>
|
||||
<span v-if="person.suffixText" class="suffixtext"
|
||||
> {{ person.suffixText }}</span
|
||||
>
|
||||
<span v-if="person.deathdate" class="deathdate"> (‡)</span>
|
||||
<span
|
||||
v-if="person.altNames && options.addAltNames == true"
|
||||
class="altnames"
|
||||
>
|
||||
<span :class="'altname altname-' + altNameKey">{{
|
||||
altNameLabel
|
||||
}}</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="options.addId == true"
|
||||
class="id-number"
|
||||
:title="'n° ' + person.id"
|
||||
>{{ person.id }}</span
|
||||
>
|
||||
|
||||
<template v-if="options.addLink === true">
|
||||
<a v-if="options.addLink === true" :href="getUrl">
|
||||
<span>{{ person.text }}</span>
|
||||
<span v-if="person.deathdate" class="deathdate"> (‡)</span>
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ person.text }}</span>
|
||||
<span v-if="person.deathdate" class="deathdate"> (‡)</span>
|
||||
</template>
|
||||
<badge-entity
|
||||
v-if="options.addEntity === true"
|
||||
:entity="person"
|
||||
@@ -52,61 +22,36 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<span
|
||||
v-if="options.addId == true"
|
||||
:title="person.personId"
|
||||
><i class="bi bi-info-circle"></i> {{ person.personId }}</span
|
||||
>
|
||||
</p>
|
||||
|
||||
<p v-if="options.addInfo === true" class="moreinfo">
|
||||
<gender-icon-render-box
|
||||
v-if="person.gender"
|
||||
:gender="person.gender"
|
||||
/>
|
||||
<time
|
||||
v-if="person.birthdate && !person.deathdate"
|
||||
:datetime="person.birthdate"
|
||||
:title="birthdate"
|
||||
/> <span
|
||||
v-if="person.birthdate"
|
||||
>
|
||||
{{
|
||||
trans(birthdateTranslation) +
|
||||
" " +
|
||||
new Intl.DateTimeFormat("fr-FR", {
|
||||
dateStyle: "long",
|
||||
}).format(birthdate)
|
||||
}}
|
||||
</time>
|
||||
|
||||
<time
|
||||
v-else-if="person.birthdate && person.deathdate"
|
||||
:datetime="person.deathdate"
|
||||
:title="person.deathdate"
|
||||
>
|
||||
{{
|
||||
new Intl.DateTimeFormat("fr-FR", {
|
||||
dateStyle: "long",
|
||||
}).format(birthdate)
|
||||
}}
|
||||
-
|
||||
{{
|
||||
new Intl.DateTimeFormat("fr-FR", {
|
||||
dateStyle: "long",
|
||||
}).format(deathdate)
|
||||
}}
|
||||
</time>
|
||||
|
||||
<time
|
||||
v-else-if="person.deathdate"
|
||||
:datetime="person.deathdate"
|
||||
:title="person.deathdate"
|
||||
>
|
||||
{{
|
||||
trans(RENDERBOX_DEATHDATE) +
|
||||
" " +
|
||||
new Intl.DateTimeFormat("fr-FR", {
|
||||
dateStyle: "long",
|
||||
}).format(deathdate)
|
||||
}}
|
||||
</time>
|
||||
|
||||
{{ trans(RENDERBOX_BIRTHDAY_STATEMENT, {gender: toGenderTranslation(person.gender), birthdate: ISOToDate(person.birthdate?.datetime)}) }}
|
||||
</span>
|
||||
<span v-if="options.addAge && person.birthdate" class="age">
|
||||
({{ trans(RENDERBOX_YEARS_OLD, { n: person.age }) }})
|
||||
({{ trans(RENDERBOX_YEARS_OLD, {n: person.age}) }})
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span
|
||||
v-if="person.deathdate"
|
||||
>
|
||||
{{ trans(RENDERBOX_DEATHDATE_STATEMENT, {gender: toGenderTranslation(person.gender), deathdate: ISOToDate(person.deathdate?.datetime)}) }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -114,11 +59,11 @@
|
||||
<div class="float-button bottom">
|
||||
<div class="box">
|
||||
<div class="action">
|
||||
<slot name="record-actions" />
|
||||
<slot name="record-actions"/>
|
||||
</div>
|
||||
<ul class="list-content fa-ul">
|
||||
<li v-if="person.current_household_id">
|
||||
<i class="fa fa-li fa-map-marker" />
|
||||
<i class="fa fa-li fa-map-marker"/>
|
||||
<address-render-box
|
||||
v-if="person.current_household_address"
|
||||
:address="person.current_household_address"
|
||||
@@ -130,11 +75,6 @@
|
||||
<a
|
||||
v-if="options.addHouseholdLink === true"
|
||||
:href="getCurrentHouseholdUrl"
|
||||
:title="
|
||||
trans(PERSONS_ASSOCIATED_SHOW_HOUSEHOLD_NUMBER, {
|
||||
id: person.current_household_id,
|
||||
})
|
||||
"
|
||||
>
|
||||
<span class="badge rounded-pill bg-chill-beige">
|
||||
<i
|
||||
@@ -144,7 +84,7 @@
|
||||
</a>
|
||||
</li>
|
||||
<li v-else-if="options.addNoData">
|
||||
<i class="fa fa-li fa-map-marker" />
|
||||
<i class="fa fa-li fa-map-marker"/>
|
||||
<p class="chill-no-data-statement">
|
||||
{{ trans(RENDERBOX_NO_DATA) }}
|
||||
</p>
|
||||
@@ -160,7 +100,7 @@
|
||||
v-for="(addr, i) in person.current_residential_addresses"
|
||||
:key="i"
|
||||
>
|
||||
<i class="fa fa-li fa-map-marker" />
|
||||
<i class="fa fa-li fa-map-marker"/>
|
||||
<div v-if="addr.address">
|
||||
<span class="item-key">
|
||||
{{ trans(RENDERBOX_RESIDENTIAL_ADDRESS) }}:
|
||||
@@ -180,6 +120,7 @@
|
||||
:person="addr.hostPerson"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<address-render-box
|
||||
v-if="addr.hostPerson.address"
|
||||
:address="addr.hostPerson.address"
|
||||
@@ -204,36 +145,36 @@
|
||||
</template>
|
||||
|
||||
<li v-if="person.email">
|
||||
<i class="fa fa-li fa-envelope-o" />
|
||||
<i class="fa fa-li fa-envelope-o"/>
|
||||
<a :href="'mailto: ' + person.email">{{ person.email }}</a>
|
||||
</li>
|
||||
<li v-else-if="options.addNoData">
|
||||
<i class="fa fa-li fa-envelope-o" />
|
||||
<i class="fa fa-li fa-envelope-o"/>
|
||||
<p class="chill-no-data-statement">
|
||||
{{ trans(RENDERBOX_NO_DATA) }}
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li v-if="person.mobilenumber">
|
||||
<i class="fa fa-li fa-mobile" />
|
||||
<i class="fa fa-li fa-mobile"/>
|
||||
<a :href="'tel: ' + person.mobilenumber">
|
||||
{{ person.mobilenumber }}
|
||||
</a>
|
||||
</li>
|
||||
<li v-else-if="options.addNoData">
|
||||
<i class="fa fa-li fa-mobile" />
|
||||
<i class="fa fa-li fa-mobile"/>
|
||||
<p class="chill-no-data-statement">
|
||||
{{ trans(RENDERBOX_NO_DATA) }}
|
||||
</p>
|
||||
</li>
|
||||
<li v-if="person.phonenumber">
|
||||
<i class="fa fa-li fa-phone" />
|
||||
<i class="fa fa-li fa-phone"/>
|
||||
<a :href="'tel: ' + person.phonenumber">
|
||||
{{ person.phonenumber }}
|
||||
</a>
|
||||
</li>
|
||||
<li v-else-if="options.addNoData">
|
||||
<i class="fa fa-li fa-phone" />
|
||||
<i class="fa fa-li fa-phone"/>
|
||||
<p class="chill-no-data-statement">
|
||||
{{ trans(RENDERBOX_NO_DATA) }}
|
||||
</p>
|
||||
@@ -246,25 +187,25 @@
|
||||
options.addCenter
|
||||
"
|
||||
>
|
||||
<i class="fa fa-li fa-long-arrow-right" />
|
||||
<i class="fa fa-li fa-long-arrow-right"/>
|
||||
<template v-for="c in person.centers">
|
||||
{{ c.name }}
|
||||
</template>
|
||||
</li>
|
||||
<li v-else-if="options.addNoData">
|
||||
<i class="fa fa-li fa-long-arrow-right" />
|
||||
<i class="fa fa-li fa-long-arrow-right"/>
|
||||
<p class="chill-no-data-statement">
|
||||
{{ trans(RENDERBOX_NO_DATA) }}
|
||||
</p>
|
||||
</li>
|
||||
<slot name="custom-zone" />
|
||||
<slot name="custom-zone"/>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot name="end-bloc" />
|
||||
<slot name="end-bloc"/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -278,11 +219,11 @@
|
||||
class="fa-stack fa-holder"
|
||||
:title="trans(RENDERBOX_HOLDER)"
|
||||
>
|
||||
<i class="fa fa-circle fa-stack-1x text-success" />
|
||||
<i class="fa fa-circle fa-stack-1x text-success"/>
|
||||
<i class="fa fa-stack-1x">T</i>
|
||||
</span>
|
||||
|
||||
<person-text :person="person" />
|
||||
<person-text :person="person"/>
|
||||
</a>
|
||||
<span v-else>
|
||||
<span
|
||||
@@ -290,18 +231,18 @@
|
||||
class="fa-stack fa-holder"
|
||||
:title="trans(RENDERBOX_HOLDER)"
|
||||
>
|
||||
<i class="fa fa-circle fa-stack-1x text-success" />
|
||||
<i class="fa fa-circle fa-stack-1x text-success"/>
|
||||
<i class="fa fa-stack-1x">T</i>
|
||||
</span>
|
||||
<person-text :person="person" />
|
||||
<person-text :person="person"/>
|
||||
</span>
|
||||
<slot name="post-badge" />
|
||||
<slot name="post-badge"/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { ISOToDate } from "ChillMainAssets/chill/js/date";
|
||||
<script setup lang="ts">
|
||||
import {computed} from "vue";
|
||||
import {ISOToDate} from "ChillMainAssets/chill/js/date";
|
||||
import AddressRenderBox from "ChillMainAssets/vuejs/_components/Entity/AddressRenderBox.vue";
|
||||
import GenderIconRenderBox from "ChillMainAssets/vuejs/_components/Entity/GenderIconRenderBox.vue";
|
||||
import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue";
|
||||
@@ -311,108 +252,69 @@ import {
|
||||
trans,
|
||||
RENDERBOX_HOLDER,
|
||||
RENDERBOX_NO_DATA,
|
||||
RENDERBOX_DEATHDATE,
|
||||
RENDERBOX_DEATHDATE_STATEMENT,
|
||||
RENDERBOX_HOUSEHOLD_WITHOUT_ADDRESS,
|
||||
RENDERBOX_RESIDENTIAL_ADDRESS,
|
||||
RENDERBOX_LOCATED_AT,
|
||||
RENDERBOX_BIRTHDAY_MAN,
|
||||
RENDERBOX_BIRTHDAY_WOMAN,
|
||||
RENDERBOX_BIRTHDAY_UNKNOWN,
|
||||
RENDERBOX_BIRTHDAY_NEUTRAL,
|
||||
PERSONS_ASSOCIATED_SHOW_HOUSEHOLD_NUMBER,
|
||||
RENDERBOX_BIRTHDAY_STATEMENT,
|
||||
// PERSONS_ASSOCIATED_SHOW_HOUSEHOLD_NUMBER,
|
||||
RENDERBOX_YEARS_OLD,
|
||||
} from "translator";
|
||||
import {Person} from "ChillPersonAssets/types";
|
||||
import {toGenderTranslation} from "ChillMainAssets/lib/api/genderHelper";
|
||||
|
||||
const props = defineProps({
|
||||
person: {
|
||||
required: true,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
render: {
|
||||
type: String,
|
||||
},
|
||||
returnPath: {
|
||||
type: String,
|
||||
},
|
||||
showResidentialAddresses: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
interface RenderOptions {
|
||||
addInfo?: boolean;
|
||||
addEntity?: boolean;
|
||||
addAltNames?: boolean;
|
||||
addAge?: boolean;
|
||||
addId?: boolean;
|
||||
addLink?: boolean;
|
||||
hLevel?: number;
|
||||
entityDisplayLong?: boolean;
|
||||
addCenter?: boolean;
|
||||
addNoData?: boolean;
|
||||
isMultiline?: boolean;
|
||||
isHolder?: boolean;
|
||||
addHouseholdLink?: boolean;
|
||||
}
|
||||
|
||||
const birthdateTranslation = computed(() => {
|
||||
if (props.person.gender) {
|
||||
const { genderTranslation } = props.person.gender;
|
||||
switch (genderTranslation) {
|
||||
case "man":
|
||||
return RENDERBOX_BIRTHDAY_MAN;
|
||||
case "woman":
|
||||
return RENDERBOX_BIRTHDAY_WOMAN;
|
||||
case "neutral":
|
||||
return RENDERBOX_BIRTHDAY_NEUTRAL;
|
||||
case "unknown":
|
||||
return RENDERBOX_BIRTHDAY_UNKNOWN;
|
||||
default:
|
||||
return RENDERBOX_BIRTHDAY_UNKNOWN;
|
||||
}
|
||||
} else {
|
||||
return RENDERBOX_BIRTHDAY_UNKNOWN;
|
||||
interface Props {
|
||||
person: Person;
|
||||
options?: RenderOptions;
|
||||
render?: "bloc" | "badge";
|
||||
returnPath?: string;
|
||||
showResidentialAddresses?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
render: "bloc", options: {
|
||||
addInfo: true,
|
||||
addEntity: false,
|
||||
addAltNames: true,
|
||||
addAge: true,
|
||||
addId: true,
|
||||
addLink: false,
|
||||
hLevel: 3,
|
||||
entityDisplayingLong: true,
|
||||
addCenter: true,
|
||||
addNoData: true,
|
||||
isMultiline: true,
|
||||
isHolder: false,
|
||||
addHouseholdLink: true
|
||||
}
|
||||
});
|
||||
|
||||
const isMultiline = computed(() => {
|
||||
const isMultiline = computed<boolean>(() => {
|
||||
return props.options?.isMultiline || false;
|
||||
});
|
||||
|
||||
const birthdate = computed(() => {
|
||||
if (
|
||||
props.person.birthdate !== null &&
|
||||
props.person.birthdate !== undefined &&
|
||||
props.person.birthdate.datetime
|
||||
) {
|
||||
return ISOToDate(props.person.birthdate.datetime);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const deathdate = computed(() => {
|
||||
if (
|
||||
props.person.deathdate !== null &&
|
||||
props.person.deathdate !== undefined &&
|
||||
props.person.deathdate.datetime
|
||||
) {
|
||||
return new Date(props.person.deathdate.datetime);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const altNameLabel = computed(() => {
|
||||
let altNameLabel = "";
|
||||
(props.person.altNames || []).forEach(
|
||||
(altName) => (altNameLabel += altName.label),
|
||||
);
|
||||
return altNameLabel;
|
||||
});
|
||||
|
||||
const altNameKey = computed(() => {
|
||||
let altNameKey = "";
|
||||
(props.person.altNames || []).forEach(
|
||||
(altName) => (altNameKey += altName.key),
|
||||
);
|
||||
return altNameKey;
|
||||
});
|
||||
|
||||
const getUrl = computed(() => {
|
||||
const getUrl = computed<string>(() => {
|
||||
return `/fr/person/${props.person.id}/general`;
|
||||
});
|
||||
|
||||
const getCurrentHouseholdUrl = computed(() => {
|
||||
let returnPath = props.returnPath ? `?returnPath=${props.returnPath}` : ``;
|
||||
const getCurrentHouseholdUrl = computed<string>(() => {
|
||||
const returnPath = props.returnPath ? `?returnPath=${props.returnPath}` : ``;
|
||||
return `/fr/person/household/${props.person.current_household_id}/summary${returnPath}`;
|
||||
});
|
||||
</script>
|
||||
|
@@ -1,13 +1,7 @@
|
||||
<template>
|
||||
<span v-if="isCut">{{ cutText }}</span>
|
||||
<span v-else class="person-text">
|
||||
<span class="firstname">{{ person.firstName }}</span>
|
||||
<span class="lastname"> {{ person.lastName }}</span>
|
||||
<span v-if="person.altNames && person.altNames.length > 0" class="altnames">
|
||||
<span :class="'altname altname-' + altNameKey"
|
||||
> ({{ altNameLabel }})</span
|
||||
>
|
||||
</span>
|
||||
<span>{{ person.text }}</span>
|
||||
<span v-if="person.suffixText" class="suffixtext"
|
||||
> {{ person.suffixText }}</span
|
||||
>
|
||||
@@ -33,16 +27,6 @@ const props = defineProps<{
|
||||
|
||||
const { person, isCut = false, addAge = true } = toRefs(props);
|
||||
|
||||
const altNameLabel = computed(() => {
|
||||
if (!person.value.altNames) return "";
|
||||
return person.value.altNames.map((a: AltName) => a.label).join("");
|
||||
});
|
||||
|
||||
const altNameKey = computed(() => {
|
||||
if (!person.value.altNames) return "";
|
||||
return person.value.altNames.map((a: AltName) => a.key).join("");
|
||||
});
|
||||
|
||||
const cutText = computed(() => {
|
||||
if (!person.value.text) return "";
|
||||
const more = person.value.text.length > 15 ? "…" : "";
|
||||
|
@@ -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,33 @@ 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,
|
||||
private readonly \Chill\PersonBundle\PersonIdentifier\Rendering\PersonIdRenderingInterface $personIdRendering,
|
||||
) {}
|
||||
|
||||
public function denormalize($data, $type, $format = null, array $context = [])
|
||||
{
|
||||
$person = $this->extractObjectToPopulate($type, $context);
|
||||
|
||||
if (\array_key_exists('id', $data) && null === $person) {
|
||||
$person = $this->repository->find($data['id']);
|
||||
|
||||
if (null === $person) {
|
||||
throw new UnexpectedValueException("The person with id \"{$data['id']}\" does ".'not exists');
|
||||
}
|
||||
|
||||
// currently, not allowed to update a person through api
|
||||
// if instantiated with id
|
||||
return $person;
|
||||
}
|
||||
|
||||
if (null === $person) {
|
||||
$person = new Person();
|
||||
}
|
||||
|
||||
$fields = [
|
||||
'firstName',
|
||||
'lastName',
|
||||
'phonenumber',
|
||||
'mobilenumber',
|
||||
'gender',
|
||||
'birthdate',
|
||||
'deathdate',
|
||||
'center',
|
||||
'altNames',
|
||||
'email',
|
||||
'civility',
|
||||
];
|
||||
|
||||
$fields = array_filter(
|
||||
$fields,
|
||||
static fn (string $field): bool => \array_key_exists($field, $data)
|
||||
);
|
||||
|
||||
foreach ($fields as $item) {
|
||||
switch ($item) {
|
||||
case 'firstName':
|
||||
$person->setFirstName($data[$item]);
|
||||
|
||||
break;
|
||||
|
||||
case 'lastName':
|
||||
$person->setLastName($data[$item]);
|
||||
|
||||
break;
|
||||
|
||||
case 'phonenumber':
|
||||
$person->setPhonenumber($this->denormalizer->denormalize($data[$item], PhoneNumber::class, $format, $context));
|
||||
|
||||
break;
|
||||
|
||||
case 'mobilenumber':
|
||||
$person->setMobilenumber($this->denormalizer->denormalize($data[$item], PhoneNumber::class, $format, $context));
|
||||
|
||||
break;
|
||||
|
||||
case 'gender':
|
||||
$gender = $this->denormalizer->denormalize($data[$item], Gender::class, $format, []);
|
||||
|
||||
$person->setGender($gender);
|
||||
|
||||
break;
|
||||
|
||||
case 'birthdate':
|
||||
$object = $this->denormalizer->denormalize($data[$item], \DateTime::class, $format, $context);
|
||||
|
||||
$person->setBirthdate($object);
|
||||
|
||||
break;
|
||||
|
||||
case 'deathdate':
|
||||
$object = $this->denormalizer->denormalize($data[$item], \DateTimeImmutable::class, $format, $context);
|
||||
|
||||
$person->setDeathdate($object);
|
||||
|
||||
break;
|
||||
|
||||
case 'center':
|
||||
$object = $this->denormalizer->denormalize($data[$item], Center::class, $format, $context);
|
||||
$person->setCenter($object);
|
||||
|
||||
break;
|
||||
|
||||
case 'altNames':
|
||||
foreach ($data[$item] as $altName) {
|
||||
$oldAltName = $person
|
||||
->getAltNames()
|
||||
->filter(static fn (PersonAltName $n): bool => $n->getKey() === $altName['key'])->first();
|
||||
|
||||
if (false === $oldAltName) {
|
||||
$newAltName = new PersonAltName();
|
||||
$newAltName->setKey($altName['key']);
|
||||
$newAltName->setLabel($altName['label']);
|
||||
$person->addAltName($newAltName);
|
||||
} else {
|
||||
$oldAltName->setLabel($altName['label']);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'email':
|
||||
$person->setEmail($data[$item]);
|
||||
|
||||
break;
|
||||
|
||||
case 'civility':
|
||||
$civility = $this->denormalizer->denormalize($data[$item], Civility::class, $format, []);
|
||||
|
||||
$person->setCivility($civility);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $person;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Person $person
|
||||
* @param string|null $format
|
||||
@@ -204,6 +68,7 @@ class PersonJsonNormalizer implements DenormalizerAwareInterface, NormalizerAwar
|
||||
'email' => $person->getEmail(),
|
||||
'gender' => $this->normalizer->normalize($person->getGender(), $format, $context),
|
||||
'civility' => $this->normalizer->normalize($person->getCivility(), $format, $context),
|
||||
'personId' => $this->personIdRendering->renderPersonId($person),
|
||||
];
|
||||
|
||||
if (\in_array('minimal', $groups, true) && 1 === \count($groups)) {
|
||||
@@ -215,11 +80,6 @@ class PersonJsonNormalizer implements DenormalizerAwareInterface, NormalizerAwar
|
||||
null];
|
||||
}
|
||||
|
||||
public function supportsDenormalization($data, $type, $format = null)
|
||||
{
|
||||
return Person::class === $type && 'person' === ($data['type'] ?? null);
|
||||
}
|
||||
|
||||
public function supportsNormalization($data, $format = null): bool
|
||||
{
|
||||
return $data instanceof Person && 'json' === $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,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Tests\Controller;
|
||||
|
||||
use Chill\MainBundle\Pagination\PaginatorFactoryInterface;
|
||||
use Chill\MainBundle\Serializer\Normalizer\CollectionNormalizer;
|
||||
use Chill\PersonBundle\Controller\PersonIdentifierListApiController;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Chill\PersonBundle\PersonIdentifier\Normalizer\PersonIdentifierWorkerNormalizer;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\Encoder\JsonEncoder;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class PersonIdentifierListApiControllerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
public function testListAccessDenied(): void
|
||||
{
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('ROLE_USER')->willReturn(false)->shouldBeCalledOnce();
|
||||
|
||||
$serializer = new Serializer([new PersonIdentifierWorkerNormalizer(), new CollectionNormalizer()], [new JsonEncoder()]);
|
||||
|
||||
$personIdentifierManager = $this->prophesize(PersonIdentifierManagerInterface::class);
|
||||
$paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class);
|
||||
|
||||
$controller = new PersonIdentifierListApiController(
|
||||
$security->reveal(),
|
||||
$serializer,
|
||||
$personIdentifierManager->reveal(),
|
||||
$paginatorFactory->reveal(),
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
$controller->list();
|
||||
}
|
||||
|
||||
public function testListSuccess(): void
|
||||
{
|
||||
// Build 3 workers
|
||||
$engine = new class () implements PersonIdentifierEngineInterface {
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'dummy';
|
||||
}
|
||||
|
||||
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {}
|
||||
|
||||
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function validate(ExecutionContextInterface $context, PersonIdentifier $identifier, PersonIdentifierDefinition $definition): void {}
|
||||
};
|
||||
|
||||
$definition1 = new PersonIdentifierDefinition(['en' => 'Label 1'], 'dummy');
|
||||
$definition2 = new PersonIdentifierDefinition(['en' => 'Label 2'], 'dummy');
|
||||
$definition3 = new PersonIdentifierDefinition(['en' => 'Label 3'], 'dummy');
|
||||
// simulate persisted ids
|
||||
$r = new \ReflectionProperty(PersonIdentifierDefinition::class, 'id');
|
||||
$r->setAccessible(true);
|
||||
$r->setValue($definition1, 1);
|
||||
$r->setValue($definition2, 2);
|
||||
$r->setValue($definition3, 3);
|
||||
|
||||
$workers = [
|
||||
new PersonIdentifierWorker($engine, $definition1),
|
||||
new PersonIdentifierWorker($engine, $definition2),
|
||||
new PersonIdentifierWorker($engine, $definition3),
|
||||
];
|
||||
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('ROLE_USER')->willReturn(true)->shouldBeCalledOnce();
|
||||
|
||||
$personIdentifierManager = $this->prophesize(PersonIdentifierManagerInterface::class);
|
||||
$personIdentifierManager->getWorkers()->willReturn($workers)->shouldBeCalledOnce();
|
||||
|
||||
$paginator = $this->prophesize(\Chill\MainBundle\Pagination\PaginatorInterface::class);
|
||||
$paginator->setItemsPerPage(3)->shouldBeCalledOnce();
|
||||
$paginator->getCurrentPageFirstItemNumber()->willReturn(0);
|
||||
$paginator->getItemsPerPage()->willReturn(count($workers));
|
||||
$paginator->getTotalItems()->willReturn(count($workers));
|
||||
$paginator->hasNextPage()->willReturn(false);
|
||||
$paginator->hasPreviousPage()->willReturn(false);
|
||||
|
||||
$paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class);
|
||||
$paginatorFactory->create(3)->willReturn($paginator->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$serializer = new Serializer([
|
||||
new PersonIdentifierWorkerNormalizer(),
|
||||
new CollectionNormalizer(),
|
||||
], [new JsonEncoder()]);
|
||||
|
||||
$controller = new PersonIdentifierListApiController(
|
||||
$security->reveal(),
|
||||
$serializer,
|
||||
$personIdentifierManager->reveal(),
|
||||
$paginatorFactory->reveal(),
|
||||
);
|
||||
|
||||
$response = $controller->list();
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
$body = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertIsArray($body);
|
||||
self::assertArrayHasKey('count', $body);
|
||||
self::assertArrayHasKey('pagination', $body);
|
||||
self::assertArrayHasKey('results', $body);
|
||||
self::assertSame(3, $body['count']);
|
||||
self::assertCount(3, $body['results']);
|
||||
// spot check one item
|
||||
self::assertSame('person_identifier_worker', $body['results'][0]['type']);
|
||||
self::assertSame(1, $body['results'][0]['id']);
|
||||
self::assertSame('dummy', $body['results'][0]['engine']);
|
||||
self::assertSame(['en' => 'Label 1'], $body['results'][0]['label']);
|
||||
}
|
||||
}
|
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Tests\PersonIdentifier\Identifier;
|
||||
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Chill\PersonBundle\PersonIdentifier\Identifier\StringIdentifier;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class StringIdentifierValidationTest extends TestCase
|
||||
{
|
||||
private function makeDefinition(array $data = []): PersonIdentifierDefinition
|
||||
{
|
||||
$definition = new PersonIdentifierDefinition(label: ['en' => 'Test'], engine: StringIdentifier::NAME);
|
||||
if ([] !== $data) {
|
||||
$definition->setData($data);
|
||||
}
|
||||
|
||||
return $definition;
|
||||
}
|
||||
|
||||
private function makeIdentifier(PersonIdentifierDefinition $definition, ?string $content): PersonIdentifier
|
||||
{
|
||||
$identifier = new PersonIdentifier($definition);
|
||||
$identifier->setValue(['content' => $content]);
|
||||
|
||||
return $identifier;
|
||||
}
|
||||
|
||||
public function testValidateWithoutOptionsHasNoViolations(): void
|
||||
{
|
||||
$definition = $this->makeDefinition();
|
||||
$identifier = $this->makeIdentifier($definition, 'AB-123');
|
||||
|
||||
$engine = new StringIdentifier();
|
||||
$violations = $engine->validate($identifier, $definition);
|
||||
|
||||
self::assertIsArray($violations);
|
||||
self::assertCount(0, $violations);
|
||||
}
|
||||
|
||||
public function testValidateOnlyNumbersOption(): void
|
||||
{
|
||||
$definition = $this->makeDefinition(['only_numbers' => true]);
|
||||
$engine = new StringIdentifier();
|
||||
|
||||
// valid numeric content
|
||||
$identifierOk = $this->makeIdentifier($definition, '123456');
|
||||
$violationsOk = $engine->validate($identifierOk, $definition);
|
||||
self::assertCount(0, $violationsOk);
|
||||
|
||||
// invalid alphanumeric content
|
||||
$identifierBad = $this->makeIdentifier($definition, '12AB');
|
||||
$violationsBad = $engine->validate($identifierBad, $definition);
|
||||
self::assertCount(1, $violationsBad);
|
||||
self::assertSame('person_identifier.only_number', $violationsBad[0]->message);
|
||||
self::assertSame('2a3352c0-a2b9-11f0-a767-b7a3f80e52f1', $violationsBad[0]->code);
|
||||
}
|
||||
|
||||
public function testValidateFixedLengthOption(): void
|
||||
{
|
||||
$definition = $this->makeDefinition(['fixed_length' => 5]);
|
||||
$engine = new StringIdentifier();
|
||||
|
||||
// valid exact length
|
||||
$identifierOk = $this->makeIdentifier($definition, 'ABCDE');
|
||||
$violationsOk = $engine->validate($identifierOk, $definition);
|
||||
self::assertCount(0, $violationsOk);
|
||||
|
||||
// invalid length (too short)
|
||||
$identifierBad = $this->makeIdentifier($definition, 'AB');
|
||||
$violationsBad = $engine->validate($identifierBad, $definition);
|
||||
self::assertCount(1, $violationsBad);
|
||||
self::assertSame('person_identifier.fixed_length', $violationsBad[0]->message);
|
||||
self::assertSame('2b02a8fe-a2b9-11f0-bfe5-033300972783', $violationsBad[0]->code);
|
||||
self::assertSame(['limit' => '5'], $violationsBad[0]->parameters);
|
||||
}
|
||||
|
||||
public function testValidateOnlyNumbersAndFixedLengthTogether(): void
|
||||
{
|
||||
$definition = $this->makeDefinition(['only_numbers' => true, 'fixed_length' => 4]);
|
||||
$engine = new StringIdentifier();
|
||||
|
||||
// valid: numeric and correct length
|
||||
$identifierOk = $this->makeIdentifier($definition, '1234');
|
||||
$violationsOk = $engine->validate($identifierOk, $definition);
|
||||
self::assertCount(0, $violationsOk);
|
||||
|
||||
// invalid: non-numeric and wrong length -> two violations expected
|
||||
$identifierBad = $this->makeIdentifier($definition, 'AB');
|
||||
$violationsBad = $engine->validate($identifierBad, $definition);
|
||||
self::assertCount(2, $violationsBad);
|
||||
// Order is defined by implementation: numbers check first, then length
|
||||
self::assertSame('person_identifier.only_number', $violationsBad[0]->message);
|
||||
self::assertSame('person_identifier.fixed_length', $violationsBad[1]->message);
|
||||
}
|
||||
}
|
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Tests\PersonIdentifier\Normalizer;
|
||||
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Chill\PersonBundle\PersonIdentifier\Normalizer\PersonIdentifierWorkerNormalizer;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class PersonIdentifierWorkerNormalizerTest extends TestCase
|
||||
{
|
||||
public function testSupportsNormalization(): void
|
||||
{
|
||||
$engine = new class () implements PersonIdentifierEngineInterface {
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'dummy';
|
||||
}
|
||||
|
||||
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {}
|
||||
|
||||
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
$definition = new PersonIdentifierDefinition(label: ['en' => 'SSN'], engine: 'string');
|
||||
$worker = new PersonIdentifierWorker($engine, $definition);
|
||||
|
||||
$normalizer = new PersonIdentifierWorkerNormalizer();
|
||||
|
||||
self::assertTrue($normalizer->supportsNormalization($worker));
|
||||
self::assertFalse($normalizer->supportsNormalization(new \stdClass()));
|
||||
}
|
||||
|
||||
public function testNormalizeReturnsExpectedArray(): void
|
||||
{
|
||||
$engine = new class () implements PersonIdentifierEngineInterface {
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'dummy';
|
||||
}
|
||||
|
||||
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {}
|
||||
|
||||
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function validate(ExecutionContextInterface $context, PersonIdentifier $identifier, PersonIdentifierDefinition $definition): void {}
|
||||
};
|
||||
|
||||
$definition = new PersonIdentifierDefinition(label: ['en' => 'SSN'], engine: 'string');
|
||||
$definition->setActive(false);
|
||||
$worker = new PersonIdentifierWorker($engine, $definition);
|
||||
|
||||
$normalizer = new PersonIdentifierWorkerNormalizer();
|
||||
$normalized = $normalizer->normalize($worker);
|
||||
|
||||
self::assertSame([
|
||||
'type' => 'person_identifier_worker',
|
||||
'id' => null,
|
||||
'engine' => 'string',
|
||||
'label' => ['en' => 'SSN'],
|
||||
'isActive' => false,
|
||||
], $normalized);
|
||||
}
|
||||
|
||||
public function testNormalizeThrowsOnInvalidObject(): void
|
||||
{
|
||||
$normalizer = new PersonIdentifierWorkerNormalizer();
|
||||
$this->expectException(UnexpectedValueException::class);
|
||||
$normalizer->normalize(new \stdClass());
|
||||
}
|
||||
}
|
@@ -24,6 +24,7 @@ use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@@ -71,6 +72,8 @@ class PersonIdRenderingTest extends TestCase
|
||||
// same behavior as StringIdentifier::renderAsString
|
||||
return $identifier?->getValue()['content'] ?? '';
|
||||
}
|
||||
|
||||
public function validate(ExecutionContextInterface $context, PersonIdentifier $identifier, PersonIdentifierDefinition $definition): void {}
|
||||
};
|
||||
|
||||
return new PersonIdentifierWorker($engine, $definition);
|
||||
|
@@ -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,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Tests\PersonIdentifier\Validator;
|
||||
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Chill\PersonBundle\PersonIdentifier\Validator\UniqueIdentifierConstraint;
|
||||
use Chill\PersonBundle\PersonIdentifier\Validator\UniqueIdentifierConstraintValidator;
|
||||
use Chill\PersonBundle\Repository\Identifier\PersonIdentifierRepository;
|
||||
use Chill\PersonBundle\Templating\Entity\PersonRenderInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedValueException;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
#[CoversClass(UniqueIdentifierConstraintValidator::class)]
|
||||
final class UniqueIdentifierConstraintValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
/**
|
||||
* @var ObjectProphecy|PersonIdentifierRepository
|
||||
*/
|
||||
private ObjectProphecy $repository;
|
||||
|
||||
/**
|
||||
* @var ObjectProphecy|PersonRenderInterface
|
||||
*/
|
||||
private ObjectProphecy $personRender;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repository = $this->prophesize(PersonIdentifierRepository::class);
|
||||
$this->personRender = $this->prophesize(PersonRenderInterface::class);
|
||||
parent::setUp();
|
||||
}
|
||||
|
||||
protected function createValidator(): UniqueIdentifierConstraintValidator
|
||||
{
|
||||
return new UniqueIdentifierConstraintValidator($this->repository->reveal(), $this->personRender->reveal());
|
||||
}
|
||||
|
||||
public function testThrowsOnInvalidConstraintType(): void
|
||||
{
|
||||
$this->expectException(UnexpectedTypeException::class);
|
||||
|
||||
// Provide a valid value so execution reaches the constraint type check
|
||||
$definition = new PersonIdentifierDefinition(['en' => 'Test'], 'string');
|
||||
$identifier = new PersonIdentifier($definition);
|
||||
$identifier->setValue(['value' => 'ABC']);
|
||||
|
||||
$this->validator->validate($identifier, new NotBlank());
|
||||
}
|
||||
|
||||
public function testThrowsOnInvalidValueType(): void
|
||||
{
|
||||
$this->expectException(UnexpectedValueException::class);
|
||||
$this->validator->validate(new \stdClass(), new UniqueIdentifierConstraint());
|
||||
}
|
||||
|
||||
public function testNoViolationWhenNoDuplicate(): void
|
||||
{
|
||||
$definition = new PersonIdentifierDefinition(['en' => 'Test'], 'string');
|
||||
$identifier = new PersonIdentifier($definition);
|
||||
$identifier->setValue(['value' => 'UNIQ']);
|
||||
|
||||
// Configure repository mock to return empty array
|
||||
$this->repository->findByDefinitionAndCanonical($definition, ['value' => 'UNIQ'])->willReturn([]);
|
||||
|
||||
$this->validator->validate($identifier, new UniqueIdentifierConstraint());
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
|
||||
public function testViolationWhenDuplicateFound(): void
|
||||
{
|
||||
$definition = new PersonIdentifierDefinition(['en' => 'SSN'], 'string');
|
||||
$reflectionClass = new \ReflectionClass($definition);
|
||||
$reflectionId = $reflectionClass->getProperty('id');
|
||||
$reflectionId->setValue($definition, 1);
|
||||
|
||||
$personA = new Person();
|
||||
$personA->setFirstName('Alice')->setLastName('Anderson');
|
||||
$personB = new Person();
|
||||
$personB->setFirstName('Bob')->setLastName('Brown');
|
||||
|
||||
$dup1 = new PersonIdentifier($definition);
|
||||
$dup1->setPerson($personA);
|
||||
$dup1->setValue(['value' => '123']);
|
||||
$dup2 = new PersonIdentifier($definition);
|
||||
$dup2->setPerson($personB);
|
||||
$dup2->setValue(['value' => '123']);
|
||||
|
||||
// Repository returns duplicates
|
||||
$this->repository->findByDefinitionAndCanonical($definition, ['value' => '123'])->willReturn([$dup1, $dup2]);
|
||||
|
||||
// Person renderer returns names
|
||||
$this->personRender->renderString($personA, Argument::type('array'))->willReturn('Alice Anderson');
|
||||
$this->personRender->renderString($personB, Argument::type('array'))->willReturn('Bob Brown');
|
||||
|
||||
$identifier = new PersonIdentifier($definition);
|
||||
$identifier->setPerson(new Person());
|
||||
$identifier->setValue(['value' => '123']);
|
||||
|
||||
$constraint = new UniqueIdentifierConstraint();
|
||||
|
||||
$this->validator->validate($identifier, $constraint);
|
||||
|
||||
$this->buildViolation($constraint->message)
|
||||
->setParameter('{{ persons }}', 'Alice Anderson, Bob Brown')
|
||||
->setParameter('definition_id', '1')
|
||||
->assertRaised();
|
||||
}
|
||||
|
||||
public function testViolationWhenDuplicateFoundButForSamePerson(): void
|
||||
{
|
||||
$definition = new PersonIdentifierDefinition(['en' => 'SSN'], 'string');
|
||||
$reflectionClass = new \ReflectionClass($definition);
|
||||
$reflectionId = $reflectionClass->getProperty('id');
|
||||
$reflectionId->setValue($definition, 1);
|
||||
|
||||
$personA = new Person();
|
||||
$personA->setFirstName('Alice')->setLastName('Anderson');
|
||||
|
||||
$dup1 = new PersonIdentifier($definition);
|
||||
$dup1->setPerson($personA);
|
||||
$dup1->setValue(['value' => '123']);
|
||||
|
||||
// Repository returns duplicates
|
||||
$this->repository->findByDefinitionAndCanonical($definition, ['value' => '123'])->willReturn([$dup1]);
|
||||
|
||||
$identifier = new PersonIdentifier($definition);
|
||||
$identifier->setPerson($personA);
|
||||
$identifier->setValue(['value' => '123']);
|
||||
|
||||
$constraint = new UniqueIdentifierConstraint();
|
||||
|
||||
$this->validator->validate($identifier, $constraint);
|
||||
|
||||
$this->assertNoViolation();
|
||||
}
|
||||
}
|
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Tests\PersonIdentifier\Validator;
|
||||
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Chill\PersonBundle\PersonIdentifier\IdentifierViolationDTO;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
|
||||
use Chill\PersonBundle\PersonIdentifier\Validator\ValidIdentifierConstraint;
|
||||
use Chill\PersonBundle\PersonIdentifier\Validator\ValidIdentifierConstraintValidator;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
#[CoversClass(ValidIdentifierConstraintValidator::class)]
|
||||
final class ValidIdentifierConstraintValidatorTest extends ConstraintValidatorTestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
/**
|
||||
* @var ObjectProphecy|PersonIdentifierManagerInterface
|
||||
*/
|
||||
private ObjectProphecy $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->manager = $this->prophesize(PersonIdentifierManagerInterface::class);
|
||||
parent::setUp();
|
||||
}
|
||||
|
||||
protected function createValidator(): ValidIdentifierConstraintValidator
|
||||
{
|
||||
return new ValidIdentifierConstraintValidator($this->manager->reveal());
|
||||
}
|
||||
|
||||
public function testAddsViolationFromWorker(): void
|
||||
{
|
||||
$definition = new PersonIdentifierDefinition(['en' => 'SSN'], 'string');
|
||||
// set definition id via reflection for definition_id parameter
|
||||
$ref = new \ReflectionClass($definition);
|
||||
$prop = $ref->getProperty('id');
|
||||
$prop->setValue($definition, 1);
|
||||
|
||||
$identifier = new PersonIdentifier($definition);
|
||||
$identifier->setValue(['value' => 'bad']);
|
||||
|
||||
$violation = new IdentifierViolationDTO('Invalid Identifier', '0000-1111-2222-3333', ['{{ foo }}' => 'bar']);
|
||||
|
||||
// engine that returns one violation
|
||||
$engine = new class ([$violation]) implements PersonIdentifierEngineInterface {
|
||||
public function __construct(private array $violations) {}
|
||||
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'dummy';
|
||||
}
|
||||
|
||||
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {}
|
||||
|
||||
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function isEmpty(PersonIdentifier $identifier): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array
|
||||
{
|
||||
return $this->violations;
|
||||
}
|
||||
};
|
||||
$worker = new PersonIdentifierWorker($engine, $definition);
|
||||
|
||||
$this->manager
|
||||
->buildWorkerByPersonIdentifierDefinition($definition)
|
||||
->willReturn($worker);
|
||||
|
||||
$constraint = new ValidIdentifierConstraint();
|
||||
$this->validator->validate($identifier, $constraint);
|
||||
|
||||
$this->buildViolation('Invalid Identifier')
|
||||
->setParameters(['{{ foo }}' => 'bar'])
|
||||
->setParameter('{{ code }}', '0000-1111-2222-3333')
|
||||
->setParameter('definition_id', '1')
|
||||
->assertRaised();
|
||||
}
|
||||
}
|
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Tests\Repository\Identifier;
|
||||
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Chill\PersonBundle\PersonIdentifier\Identifier\StringIdentifier;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
|
||||
use Chill\PersonBundle\Repository\Identifier\PersonIdentifierRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class PersonIdentifierRepositoryTest extends KernelTestCase
|
||||
{
|
||||
public function testFindByDefinitionAndCanonical(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$container = self::getContainer();
|
||||
/** @var PersonIdentifierManagerInterface $personIdentifierManager */
|
||||
$personIdentifierManager = $container->get(PersonIdentifierManagerInterface::class);
|
||||
|
||||
/** @var EntityManagerInterface $em */
|
||||
$em = $container->get(EntityManagerInterface::class);
|
||||
|
||||
// Get a random existing person from fixtures
|
||||
/** @var Person|null $person */
|
||||
$person = $em->getRepository(Person::class)->findOneBy([]);
|
||||
self::assertNotNull($person, 'An existing Person is required for this integration test.');
|
||||
|
||||
// Create a definition
|
||||
$definition = new PersonIdentifierDefinition(['en' => 'Test Identifier'], StringIdentifier::NAME);
|
||||
$em->persist($definition);
|
||||
$em->flush();
|
||||
|
||||
// Create an identifier attached to the person
|
||||
$value = ['content' => 'ABC-'.bin2hex(random_bytes(4))];
|
||||
$identifier = new PersonIdentifier($definition);
|
||||
$identifier->setPerson($person);
|
||||
$identifier->setValue($value);
|
||||
$identifier->setCanonical($personIdentifierManager->buildWorkerByPersonIdentifierDefinition($definition)->canonicalizeValue($identifier->getValue()));
|
||||
$em->persist($identifier);
|
||||
$em->flush();
|
||||
|
||||
// Use the repository to find by definition and value
|
||||
/** @var PersonIdentifierRepository $repo */
|
||||
$repo = $container->get(PersonIdentifierRepository::class);
|
||||
$results = $repo->findByDefinitionAndCanonical($definition, $value);
|
||||
|
||||
self::assertNotEmpty($results, 'Repository should return at least one result.');
|
||||
self::assertContainsOnlyInstancesOf(PersonIdentifier::class, $results);
|
||||
self::assertTrue(in_array($identifier->getId(), array_map(static fn (PersonIdentifier $pi) => $pi->getId(), $results), true));
|
||||
|
||||
// Cleanup
|
||||
foreach ($results as $res) {
|
||||
$em->remove($res);
|
||||
}
|
||||
$em->flush();
|
||||
$em->remove($definition);
|
||||
$em->flush();
|
||||
}
|
||||
}
|
@@ -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,305 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Chill\PersonBundle\Tests\Serializer\Normalizer;
|
||||
|
||||
use Chill\MainBundle\Entity\Center;
|
||||
use Chill\MainBundle\Entity\Civility;
|
||||
use Chill\MainBundle\Entity\Gender;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
|
||||
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
|
||||
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
|
||||
use Chill\PersonBundle\Serializer\Normalizer\PersonJsonDenormalizer;
|
||||
use libphonenumber\PhoneNumber;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @covers \Chill\PersonBundle\Serializer\Normalizer\PersonJsonDenormalizer
|
||||
*/
|
||||
final class PersonJsonDenormalizerTest extends TestCase
|
||||
{
|
||||
private function createIdentifierManager(): PersonIdentifierManagerInterface
|
||||
{
|
||||
return new class () implements PersonIdentifierManagerInterface {
|
||||
public function getWorkers(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function buildWorkerByPersonIdentifierDefinition(int|PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker
|
||||
{
|
||||
if (is_int($personIdentifierDefinition)) {
|
||||
$definition = new PersonIdentifierDefinition(['en' => 'Test'], 'dummy');
|
||||
// Force the id for testing purposes
|
||||
$r = new \ReflectionProperty(PersonIdentifierDefinition::class, 'id');
|
||||
$r->setAccessible(true);
|
||||
$r->setValue($definition, $personIdentifierDefinition);
|
||||
} else {
|
||||
$definition = $personIdentifierDefinition;
|
||||
}
|
||||
|
||||
$engine = new class () implements PersonIdentifierEngineInterface {
|
||||
public static function getName(): string
|
||||
{
|
||||
return 'dummy';
|
||||
}
|
||||
|
||||
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
|
||||
{
|
||||
// trivial canonicalization for tests
|
||||
return isset($value['content']) ? (string) $value['content'] : null;
|
||||
}
|
||||
|
||||
public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {}
|
||||
|
||||
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function isEmpty(PersonIdentifier $identifier): bool
|
||||
{
|
||||
$value = $identifier->getValue();
|
||||
$content = isset($value['content']) ? trim((string) $value['content']) : '';
|
||||
|
||||
return '' === $content;
|
||||
}
|
||||
|
||||
public function validate(ExecutionContextInterface $context, PersonIdentifier $identifier, PersonIdentifierDefinition $definition): void {}
|
||||
};
|
||||
|
||||
return new PersonIdentifierWorker($engine, $definition);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function testSupportsDenormalizationReturnsTrueForValidData(): void
|
||||
{
|
||||
$denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager());
|
||||
|
||||
$data = [
|
||||
'type' => 'person',
|
||||
// important: new Person (creation) must not contain an id
|
||||
];
|
||||
|
||||
self::assertTrue($denormalizer->supportsDenormalization($data, Person::class));
|
||||
}
|
||||
|
||||
public function testSupportsDenormalizationReturnsFalseForInvalidData(): void
|
||||
{
|
||||
$denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager());
|
||||
|
||||
// not an array
|
||||
self::assertFalse($denormalizer->supportsDenormalization('not-an-array', Person::class));
|
||||
|
||||
// missing type
|
||||
self::assertFalse($denormalizer->supportsDenormalization([], Person::class));
|
||||
|
||||
// wrong type value
|
||||
self::assertFalse($denormalizer->supportsDenormalization(['type' => 'not-person'], Person::class));
|
||||
|
||||
// id present means it's not a create payload for this denormalizer
|
||||
self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person', 'id' => 123], Person::class));
|
||||
|
||||
// wrong target class
|
||||
self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person'], \stdClass::class));
|
||||
}
|
||||
|
||||
public function testDenormalizeMapsPayloadToPersonProperties(): void
|
||||
{
|
||||
$json = <<<'JSON'
|
||||
{
|
||||
"type": "person",
|
||||
"firstName": "Jérome",
|
||||
"lastName": "diallo",
|
||||
"altNames": [
|
||||
{
|
||||
"key": "jeune_fille",
|
||||
"value": "FJ"
|
||||
}
|
||||
],
|
||||
"birthdate": null,
|
||||
"deathdate": null,
|
||||
"phonenumber": "",
|
||||
"mobilenumber": "",
|
||||
"email": "",
|
||||
"gender": {
|
||||
"id": 5,
|
||||
"type": "chill_main_gender"
|
||||
},
|
||||
"center": {
|
||||
"id": 1,
|
||||
"type": "center"
|
||||
},
|
||||
"civility": null,
|
||||
"identifiers": [
|
||||
{
|
||||
"type": "person_identifier",
|
||||
"value": {
|
||||
"content": "789456"
|
||||
},
|
||||
"definition_id": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON;
|
||||
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
$inner = new class () implements DenormalizerInterface {
|
||||
public ?Gender $gender = null;
|
||||
public ?Center $center = null;
|
||||
|
||||
public function denormalize($data, $type, $format = null, array $context = [])
|
||||
{
|
||||
if (PhoneNumber::class === $type) {
|
||||
return '' === $data ? null : new PhoneNumber();
|
||||
}
|
||||
if (\DateTime::class === $type || \DateTimeImmutable::class === $type) {
|
||||
return null === $data ? null : new \DateTimeImmutable((string) $data);
|
||||
}
|
||||
if (Gender::class === $type) {
|
||||
return $this->gender ??= new Gender();
|
||||
}
|
||||
if (Center::class === $type) {
|
||||
return $this->center ??= new Center();
|
||||
}
|
||||
if (Civility::class === $type) {
|
||||
return null; // input is null in our payload
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function supportsDenormalization($data, $type, $format = null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
$denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager());
|
||||
$denormalizer->setDenormalizer($inner);
|
||||
|
||||
$person = $denormalizer->denormalize($data, Person::class);
|
||||
|
||||
self::assertInstanceOf(Person::class, $person);
|
||||
self::assertSame('Jérome', $person->getFirstName());
|
||||
self::assertSame('diallo', $person->getLastName());
|
||||
|
||||
// phone numbers: empty strings map to null via the inner denormalizer stub
|
||||
self::assertNull($person->getPhonenumber());
|
||||
self::assertNull($person->getMobilenumber());
|
||||
|
||||
// email passes through as is
|
||||
self::assertSame('', $person->getEmail());
|
||||
|
||||
// nested objects are provided by our inner denormalizer and must be set back on the Person
|
||||
self::assertSame($inner->gender, $person->getGender());
|
||||
self::assertSame($inner->center, $person->getCenter());
|
||||
|
||||
// dates are null in the provided payload
|
||||
self::assertNull($person->getBirthdate());
|
||||
self::assertNull($person->getDeathdate());
|
||||
|
||||
// civility is null as provided
|
||||
self::assertNull($person->getCivility());
|
||||
|
||||
// altNames: make sure the alt name with key jeune_fille has label FJ
|
||||
$found = false;
|
||||
foreach ($person->getAltNames() as $altName) {
|
||||
if ('jeune_fille' === $altName->getKey()) {
|
||||
$found = true;
|
||||
self::assertSame('FJ', $altName->getLabel());
|
||||
}
|
||||
}
|
||||
self::assertTrue($found, 'Expected altNames to contain key "jeune_fille"');
|
||||
|
||||
$found = false;
|
||||
foreach ($person->getIdentifiers() as $identifier) {
|
||||
if (5 === $identifier->getDefinition()->getId()) {
|
||||
$found = true;
|
||||
self::assertSame(['content' => '789456'], $identifier->getValue());
|
||||
}
|
||||
}
|
||||
self::assertTrue($found, 'Expected identifiers with definition id 5');
|
||||
}
|
||||
|
||||
public function testDenormalizeRemovesEmptyIdentifier(): void
|
||||
{
|
||||
$data = [
|
||||
'type' => 'person',
|
||||
'firstName' => 'Alice',
|
||||
'lastName' => 'Smith',
|
||||
'identifiers' => [
|
||||
[
|
||||
'type' => 'person_identifier',
|
||||
'value' => ['content' => ''],
|
||||
'definition_id' => 7,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager());
|
||||
|
||||
$person = $denormalizer->denormalize($data, Person::class);
|
||||
|
||||
// The identifier with empty content must be considered empty and removed
|
||||
self::assertSame(0, $person->getIdentifiers()->count(), 'Expected no identifiers to remain on the person');
|
||||
}
|
||||
|
||||
public function testDenormalizeRemovesPreviouslyExistingIdentifierWhenIncomingValueIsEmpty(): void
|
||||
{
|
||||
// Prepare an existing Person with a pre-existing identifier (definition id = 9)
|
||||
$definition = new PersonIdentifierDefinition(['en' => 'Test'], 'dummy');
|
||||
$ref = new \ReflectionProperty(PersonIdentifierDefinition::class, 'id');
|
||||
$ref->setValue($definition, 9);
|
||||
|
||||
$existingIdentifier = new PersonIdentifier($definition);
|
||||
$existingIdentifier->setValue(['content' => 'ABC']);
|
||||
|
||||
$person = new Person();
|
||||
$person->addIdentifier($existingIdentifier);
|
||||
|
||||
// Also set the identifier's own id = 9 so that the denormalizer logic matches it
|
||||
// (the current denormalizer matches by PersonIdentifier->getId() === definition_id)
|
||||
$refId = new \ReflectionProperty(PersonIdentifier::class, 'id');
|
||||
$refId->setValue($existingIdentifier, 9);
|
||||
|
||||
// Incoming payload sets the same definition id with an empty value
|
||||
$data = [
|
||||
'type' => 'person',
|
||||
'identifiers' => [
|
||||
[
|
||||
'type' => 'person_identifier',
|
||||
'value' => ['content' => ''],
|
||||
'definition_id' => 9,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager());
|
||||
|
||||
// Use AbstractNormalizer::OBJECT_TO_POPULATE to update the existing person
|
||||
$result = $denormalizer->denormalize($data, Person::class, null, [
|
||||
AbstractNormalizer::OBJECT_TO_POPULATE => $person,
|
||||
]);
|
||||
|
||||
self::assertSame($person, $result, 'Denormalizer should update and return the provided Person instance');
|
||||
self::assertSame(0, $person->getIdentifiers()->count(), 'The previously existing identifier should be removed when incoming value is empty');
|
||||
}
|
||||
}
|
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Chill is a software for social workers
|
||||
*
|
||||
* For the full copyright and license information, please view
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Serializer\Normalizer;
|
||||
|
||||
use Chill\PersonBundle\Entity\Person;
|
||||
use Chill\PersonBundle\Repository\PersonRepository;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
final class PersonJsonNormalizerIntegrationTest extends KernelTestCase
|
||||
{
|
||||
public function testNormalizeExistingPersonFromDatabase(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$container = self::getContainer();
|
||||
|
||||
/** @var PersonRepository $repo */
|
||||
$repo = $container->get(PersonRepository::class);
|
||||
$person = $repo->findOneBy([]);
|
||||
|
||||
if (!$person instanceof Person) {
|
||||
self::markTestSkipped('No person found in test database. Load fixtures to enable this test.');
|
||||
}
|
||||
|
||||
/** @var SerializerInterface $serializer */
|
||||
$serializer = $container->get(SerializerInterface::class);
|
||||
|
||||
// Should not throw
|
||||
$data = $serializer->normalize($person, 'json');
|
||||
Assert::assertIsArray($data);
|
||||
|
||||
// Spot check some expected keys exist
|
||||
foreach ([
|
||||
'type', 'id', 'text', 'textAge', 'firstName', 'lastName', 'birthdate', 'age', 'gender', 'civility',
|
||||
] as $key) {
|
||||
Assert::assertArrayHasKey($key, $data, sprintf('Expected key %s in normalized payload', $key));
|
||||
}
|
||||
|
||||
// Minimal group should also work
|
||||
$minimal = $serializer->normalize($person, 'json', ['groups' => 'minimal']);
|
||||
Assert::assertIsArray($minimal);
|
||||
foreach ([
|
||||
'type', 'id', 'text', 'textAge', 'firstName', 'lastName',
|
||||
] as $key) {
|
||||
Assert::assertArrayHasKey($key, $minimal, sprintf('Expected key %s in minimal normalized payload', $key));
|
||||
}
|
||||
}
|
||||
}
|
@@ -11,74 +11,186 @@ declare(strict_types=1);
|
||||
|
||||
namespace Serializer\Normalizer;
|
||||
|
||||
use Chill\MainBundle\Entity\Center;
|
||||
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\Repository\PersonRepository;
|
||||
use Chill\PersonBundle\Entity\PersonAltName;
|
||||
use Chill\PersonBundle\Repository\ResidentialAddressRepository;
|
||||
use Chill\PersonBundle\Serializer\Normalizer\PersonJsonNormalizer;
|
||||
use Chill\PersonBundle\PersonIdentifier\Rendering\PersonIdRenderingInterface;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
* @covers \Chill\PersonBundle\Serializer\Normalizer\PersonJsonNormalizer
|
||||
*/
|
||||
final class PersonJsonNormalizerTest extends KernelTestCase
|
||||
final class PersonJsonNormalizerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private PersonJsonNormalizer $normalizer;
|
||||
|
||||
protected function setUp(): void
|
||||
public function testSupportsNormalization(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$normalizer = $this->createNormalizer();
|
||||
|
||||
$residentialAddressRepository = $this->prophesize(ResidentialAddressRepository::class);
|
||||
$residentialAddressRepository
|
||||
->findCurrentResidentialAddressByPerson(Argument::type(Person::class), Argument::any())
|
||||
->willReturn([]);
|
||||
|
||||
$this->normalizer = $this->buildPersonJsonNormalizer(
|
||||
self::getContainer()->get(ChillEntityRenderExtension::class),
|
||||
self::getContainer()->get(PersonRepository::class),
|
||||
self::getContainer()->get(CenterResolverManagerInterface::class),
|
||||
$residentialAddressRepository->reveal(),
|
||||
self::getContainer()->get(PhoneNumberHelperInterface::class),
|
||||
self::getContainer()->get(NormalizerInterface::class)
|
||||
);
|
||||
self::assertTrue($normalizer->supportsNormalization(new Person(), 'json'));
|
||||
self::assertFalse($normalizer->supportsNormalization(new \stdClass(), 'json'));
|
||||
self::assertFalse($normalizer->supportsNormalization(new Person(), 'xml'));
|
||||
}
|
||||
|
||||
public function testNormalization()
|
||||
public function testNormalizeWithMinimalGroupReturnsOnlyBaseKeys(): void
|
||||
{
|
||||
$person = new Person();
|
||||
$result = $this->normalizer->normalize($person, 'json', [AbstractNormalizer::GROUPS => ['read']]);
|
||||
$person = $this->createSamplePerson();
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$normalizer = $this->createNormalizer();
|
||||
$data = $normalizer->normalize($person, 'json', [AbstractNormalizer::GROUPS => 'minimal']);
|
||||
|
||||
// Expected base keys
|
||||
$expectedKeys = [
|
||||
'type',
|
||||
'id',
|
||||
'text',
|
||||
'textAge',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'current_household_address',
|
||||
'birthdate',
|
||||
'deathdate',
|
||||
'age',
|
||||
'phonenumber',
|
||||
'mobilenumber',
|
||||
'email',
|
||||
'gender',
|
||||
'civility',
|
||||
'personId',
|
||||
];
|
||||
|
||||
foreach ($expectedKeys as $key) {
|
||||
self::assertArrayHasKey($key, $data, sprintf('Key %s should be present', $key));
|
||||
}
|
||||
self::assertSame('PERSON-ID-RENDER', $data['personId']);
|
||||
|
||||
// Ensure extended keys are not present in minimal mode
|
||||
foreach (['centers', 'altNames', 'current_household_id', 'current_residential_addresses'] as $key) {
|
||||
self::assertArrayNotHasKey($key, $data, sprintf('Key %s should NOT be present in minimal group', $key));
|
||||
}
|
||||
}
|
||||
|
||||
private function buildPersonJsonNormalizer(
|
||||
ChillEntityRenderExtension $render,
|
||||
PersonRepository $repository,
|
||||
CenterResolverManagerInterface $centerResolverManager,
|
||||
ResidentialAddressRepository $residentialAddressRepository,
|
||||
PhoneNumberHelperInterface $phoneNumberHelper,
|
||||
NormalizerInterface $normalizer,
|
||||
): PersonJsonNormalizer {
|
||||
$personJsonNormalizer = new PersonJsonNormalizer(
|
||||
$render,
|
||||
$repository,
|
||||
$centerResolverManager,
|
||||
$residentialAddressRepository,
|
||||
$phoneNumberHelper
|
||||
);
|
||||
$personJsonNormalizer->setNormalizer($normalizer);
|
||||
public function testNormalizeWithoutGroupsIncludesExtendedKeys(): void
|
||||
{
|
||||
$person = $this->createSamplePerson(withAltNames: true);
|
||||
|
||||
return $personJsonNormalizer;
|
||||
$center1 = (new Center())->setName('c1');
|
||||
$center2 = (new Center())->setName('c2');
|
||||
|
||||
|
||||
$normalizer = $this->createNormalizer(
|
||||
centers: [$center1, $center2],
|
||||
currentResidentialAddresses: [['addr' => 1]],
|
||||
);
|
||||
|
||||
$data = $normalizer->normalize($person, 'json');
|
||||
|
||||
// Base keys
|
||||
$baseKeys = [
|
||||
'type', 'id', 'text', 'textAge', 'firstName', 'lastName', 'current_household_address', 'birthdate', 'deathdate', 'age', 'phonenumber', 'mobilenumber', 'email', 'gender', 'civility', 'personId',
|
||||
];
|
||||
foreach ($baseKeys as $key) {
|
||||
self::assertArrayHasKey($key, $data, sprintf('Key %s should be present', $key));
|
||||
}
|
||||
|
||||
// Extended keys
|
||||
foreach (['centers', 'altNames', 'current_household_id', 'current_residential_addresses'] as $key) {
|
||||
self::assertArrayHasKey($key, $data, sprintf('Key %s should be present', $key));
|
||||
}
|
||||
|
||||
self::assertSame(['c1', 'c2'], $data['centers']);
|
||||
self::assertIsArray($data['altNames']);
|
||||
self::assertSame([['key' => 'aka', 'label' => 'Johnny']], $data['altNames']);
|
||||
self::assertNull($data['current_household_id'], 'No household set so id should be null');
|
||||
self::assertSame([['addr' => 1]], $data['current_residential_addresses']);
|
||||
}
|
||||
|
||||
private function createNormalizer(array $centers = [], array $currentResidentialAddresses = []): PersonJsonNormalizer
|
||||
{
|
||||
$render = $this->prophesize(ChillEntityRenderExtension::class);
|
||||
$render->renderString(Argument::type(Person::class), ['addAge' => false])->willReturn('John Doe');
|
||||
$render->renderString(Argument::type(Person::class), ['addAge' => true])->willReturn('John Doe (25)');
|
||||
|
||||
$centerResolver = $this->prophesize(CenterResolverManagerInterface::class);
|
||||
$centerResolver->resolveCenters(Argument::type(Person::class))->willReturn($centers);
|
||||
|
||||
$raRepo = $this->prophesize(ResidentialAddressRepository::class);
|
||||
$raRepo->findCurrentResidentialAddressByPerson(Argument::type(Person::class))->willReturn($currentResidentialAddresses);
|
||||
|
||||
$phoneHelper = $this->prophesize(PhoneNumberHelperInterface::class);
|
||||
|
||||
$personIdRendering = $this->prophesize(PersonIdRenderingInterface::class);
|
||||
$personIdRendering->renderPersonId(Argument::type(Person::class))->willReturn('PERSON-ID-RENDER');
|
||||
|
||||
$normalizer = new PersonJsonNormalizer(
|
||||
$render->reveal(),
|
||||
$centerResolver->reveal(),
|
||||
$raRepo->reveal(),
|
||||
$phoneHelper->reveal(),
|
||||
$personIdRendering->reveal(),
|
||||
);
|
||||
|
||||
// Inner normalizer that echoes values or simple conversions
|
||||
$inner = new class () implements NormalizerInterface {
|
||||
public function supportsNormalization($data, $format = null): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function normalize($object, $format = null, array $context = [])
|
||||
{
|
||||
// For scalars and arrays, return as-is; for objects, return string or id when possible
|
||||
if (\is_scalar($object) || null === $object) {
|
||||
return $object;
|
||||
}
|
||||
if ($object instanceof \DateTimeInterface) {
|
||||
return $object->format('Y-m-d');
|
||||
}
|
||||
if ($object instanceof Center) {
|
||||
return $object->getName();
|
||||
}
|
||||
if (is_array($object)) {
|
||||
return array_map(fn ($o) => $this->normalize($o, $format, $context), $object);
|
||||
}
|
||||
|
||||
// default stub
|
||||
return (string) (method_exists($object, 'getId') ? $object->getId() : 'normalized');
|
||||
}
|
||||
};
|
||||
|
||||
$normalizer->setNormalizer($inner);
|
||||
|
||||
return $normalizer;
|
||||
}
|
||||
|
||||
private function createSamplePerson(bool $withAltNames = false): Person
|
||||
{
|
||||
$p = new Person();
|
||||
$p->setFirstName('John');
|
||||
$p->setLastName('Doe');
|
||||
$p->setBirthdate(new \DateTime('2000-01-01'));
|
||||
$p->setEmail('john@example.test');
|
||||
|
||||
if ($withAltNames) {
|
||||
$alt = new PersonAltName();
|
||||
$alt->setKey('aka');
|
||||
$alt->setLabel('Johnny');
|
||||
$p->setAltNames(new ArrayCollection([$alt]));
|
||||
}
|
||||
|
||||
return $p;
|
||||
}
|
||||
}
|
||||
|
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user