Compare commits

..

5 Commits

177 changed files with 5626 additions and 9763 deletions

View File

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

View File

@@ -0,0 +1,6 @@
kind: Feature
body: Add an admin interface for Motive entity
time: 2025-10-22T11:15:52.13937955+02:00
custom:
Issue: ""
SchemaChange: Add columns or tables

View File

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

View File

@@ -66,7 +66,6 @@ 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

View File

@@ -41,7 +41,6 @@
"typescript": "^5.6.3",
"typescript-eslint": "^8.13.0",
"vue-loader": "^17.0.0",
"vue-tsc": "^3.1.1",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.1"
},

View File

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

View File

@@ -19,6 +19,7 @@ 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.
@@ -85,14 +86,14 @@ class Immersion implements \Stringable
* @Assert\NotBlank()
*/
#[ORM\Column(name: 'tuteurPhoneNumber', type: 'phone_number', nullable: true)]
#[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber]
#[PhonenumberConstraint(type: 'any')]
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)]
#[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber]
#[PhonenumberConstraint(type: 'any')]
private ?PhoneNumber $structureAccPhonenumber = null;
#[ORM\Column(name: 'structureAccEmail', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,6 +54,7 @@ $chill-theme-buttons: (
&.btn-unlink,
&.btn-action,
&.btn-edit,
&.btn-tpchild,
&.btn-wopilink,
&.btn-update {
&, &:hover {
@@ -81,6 +82,7 @@ $chill-theme-buttons: (
&.btn-remove::before,
&.btn-choose::before,
&.btn-notify::before,
&.btn-tpchild::before,
&.btn-download::before,
&.btn-search::before,
&.btn-cancel::before {
@@ -110,6 +112,7 @@ $chill-theme-buttons: (
&.btn-choose::before { content: "\f00c"; } // fa-check // f046 fa-check-square-o
&.btn-unlink::before { content: "\f127"; } // fa-chain-broken
&.btn-notify::before { content: "\f1d8"; } // fa-paper-plane
&.btn-tpchild::before { content: "\f007"; } // fa-user
&.btn-download::before { content: "\f019"; } // fa-download
&.btn-search::before { content: "\f002"; } // fa-search
}

View File

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

View File

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

View File

@@ -1,65 +1,14 @@
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
import { CreatableEntityType } from "ChillPersonAssets/types";
import {ThirdpartyCompany} from "../../../ChillThirdPartyBundle/Resources/public/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: TranslatableString;
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 {
@@ -79,18 +28,6 @@ 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 {
@@ -182,13 +119,6 @@ export interface Address {
isNoAddress: boolean;
}
/**
* Associate an existing address in write operations.
*/
export interface SetAddress {
id: number;
}
export interface AddressWithPoint extends Address {
point: Point;
}
@@ -296,63 +226,13 @@ export interface TransportExceptionInterface {
name: string;
}
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 {
export interface ValidationExceptionInterface
extends TransportExceptionInterface {
name: "ValidationException";
/** Full server payload copy */
problems: ValidationProblemFromMap<M>;
/** A list of all violations, with property key */
violationsList: ViolationFromMap<M>[];
/** Compact list "Title: path" */
error: object;
violations: string[];
/** Only titles */
titles: 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>[];
propertyPaths: string[];
}
export interface AccessExceptionInterface extends TransportExceptionInterface {
@@ -420,23 +300,3 @@ export interface TabDefinition {
icon: string | null;
counter: () => number;
}
export type CreateComponentConfigGeneral = {
action: 'create';
allowedTypes: CreatableEntityType[];
query: string;
parent: null;
}
export type CreateComponentThirdPartyAddContact = {
action: 'addContact';
allowedTypes: readonly ['thirdparty'];
query: string;
parent: ThirdpartyCompany;
}
/**
* Configuration for the CreateModal and Create component
*/
export type CreateComponentConfig = CreateComponentConfigGeneral | CreateComponentThirdPartyAddContact;

View File

@@ -12,22 +12,24 @@
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
@@ -88,7 +90,9 @@
{{ 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>
@@ -171,7 +175,9 @@
{{ 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>
@@ -242,14 +248,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";
@@ -259,17 +265,16 @@ 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,
@@ -389,9 +394,9 @@ export default {
) {
console.log("this.options.title", this.options.title);
return this.context.edit
? ACTIVITY_EDIT_ADDRESS
: ACTIVITY_CREATE_ADDRESS;
return this.context.edit
? ACTIVITY_EDIT_ADDRESS
: ACTIVITY_CREATE_ADDRESS;
}
return this.context.edit
? this.defaultz.title.edit

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,9 @@
<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
@@ -99,36 +101,34 @@
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,19 +169,17 @@ export default {
this.options.button.text.create !== null)
) {
// console.log('this.options.button.text', this.options.button.text)
return this.context.edit
? ACTIVITY_CREATE_ADDRESS
: ACTIVITY_EDIT_ADDRESS;
}
console.log("defaultz", this.defaultz);
return this.context.edit
? ACTIVITY_CREATE_ADDRESS
: ACTIVITY_EDIT_ADDRESS;
}
console.log("defaultz", this.defaultz);
return this.context.edit
? this.defaultz.button.text.edit
: this.defaultz.button.text.create;
},
getSuccessText() {
return this.context.edit
? ADDRESS_ADDRESS_EDIT_SUCCESS
: ADDRESS_ADDRESS_NEW_SUCCESS;
return this.context.edit ? ADDRESS_ADDRESS_EDIT_SUCCESS : ADDRESS_ADDRESS_NEW_SUCCESS;
},
onlyButton() {
return typeof this.options.onlyButton !== "undefined"

View File

@@ -1,6 +1,6 @@
<template>
<ul class="nav nav-tabs">
<li v-if="containsPerson" class="nav-item">
<li v-if="allowedTypes.includes('person')" 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="containsThirdParty" class="nav-item">
<li v-if="allowedTypes.includes('thirdparty')" class="nav-item">
<a class="nav-link" :class="{ active: isActive('thirdparty') }">
<label for="thirdparty">
<input
@@ -31,65 +31,49 @@
</ul>
<div class="my-4">
<PersonEdit
<on-the-fly-person
v-if="type === 'person'"
action="create"
:action="action"
:query="query"
ref="castPerson"
@onPersonCreated="onPersonCreated"
/>
<ThirdPartyEdit
<on-the-fly-thirdparty
v-if="type === 'thirdparty'"
:action="action"
:query="query"
:parent="parent"
ref="castThirdparty"
@onThirdPartyCreated="onThirdPartyCreated"
type=""
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
<script setup>
import { ref, computed, onMounted } 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";
import ThirdPartyEdit from "ChillThirdPartyAssets/vuejs/_components/OnTheFly/ThirdPartyEdit.vue";
import {Thirdparty} from "../../../../../../ChillThirdPartyBundle/Resources/public/types";
const props = withDefaults(defineProps<CreateComponentConfig>(), {
action: "create",
query: "",
parent: null,
const props = defineProps({
action: String,
allowedTypes: Array,
query: String,
});
const emit =
defineEmits<{
(e: "onPersonCreated", payload: { person: Person }): void,
(e: "onThirdPartyCreated", payload: { thirdParty: Thirdparty }): void,
}>();
const type = ref(null);
const type = ref<CreatableEntityType | null>(null);
const radioType = computed<CreatableEntityType | null>({
const radioType = computed({
get: () => type.value,
set: (val: CreatableEntityType | null) => {
set: (val) => {
type.value = val;
console.log("## type:", val, ", action:", props.action);
},
});
type PersonEditComponent = InstanceType<typeof PersonEdit>;
type ThirdPartyEditComponent = InstanceType<typeof ThirdPartyEdit>;
const castPerson = ref<PersonEditComponent|null>(null);
const castThirdparty = ref<ThirdPartyEditComponent|null>(null);
const castPerson = ref(null);
const castThirdparty = ref(null);
onMounted(() => {
type.value =
@@ -98,37 +82,30 @@ onMounted(() => {
: "person";
});
function isActive(tab: CreatableEntityType) {
function isActive(tab) {
return type.value === tab;
}
const containsThirdParty = computed<boolean>(() =>
props.allowedTypes.includes("thirdparty"),
);
const containsPerson = computed<boolean>(() => {
if (props.action === 'addContact') {
return false;
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");
}
return props.allowedTypes.includes("person");
}
defineExpose({
castDataByType,
});
function save(): void {
if (radioType.value === "person" && castPerson.value !== null) {
castPerson.value.postPerson();
} else if (radioType.value === "thirdparty" && castThirdparty.value !== null) {
castThirdparty.value.postThirdParty();
}
}
function onThirdPartyCreated(payload: { thirdParty: Thirdparty }) {
emit("onThirdPartyCreated", payload);
}
function onPersonCreated(payload: { person: Person }) {
emit("onPersonCreated", payload);
}
defineExpose({ save });
</script>
<style lang="css" scoped>

View File

@@ -1,71 +0,0 @@
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import Create from "ChillMainAssets/vuejs/OnTheFly/components/Create.vue";
import { CreateComponentConfig } from "ChillMainAssets/types";
import { trans, SAVE } from "translator";
import { useTemplateRef } from "vue";
import { Person } from "ChillPersonAssets/types";
import {Thirdparty} from "../../../../../../ChillThirdPartyBundle/Resources/public/types";
const emit = defineEmits<{
(e: "onPersonCreated", payload: { person: Person }): void;
(e: "onThirdPartyCreated", payload: { thirdParty: Thirdparty }): void;
(e: "close"): void;
}>();
const props = defineProps<CreateComponentConfig & {modalTitle: string}>();
const modalDialogClass = { "modal-xl": true, "modal-scrollable": true };
type CreateComponentType = InstanceType<typeof Create>;
const create = useTemplateRef<CreateComponentType>("create");
const onPersonCreated = ({person}: {person: Person}): void => {
emit("onPersonCreated", {person});
};
const onThirdPartyCreated = ({thirdParty}: {thirdParty: Thirdparty}): void => {
emit("onThirdPartyCreated", {thirdParty: thirdParty});
}
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"
:parent="props.parent"
@onPersonCreated="onPersonCreated"
@onThirdPartyCreated="onThirdPartyCreated"
></Create>
</div>
</template>
<template #footer>
<button class="btn btn-save" type="button" @click.prevent="save">
{{ trans(SAVE) }}
</button>
</template>
</modal>
</teleport>
</template>
<style scoped lang="scss"></style>

View File

@@ -9,7 +9,7 @@
class="btn btn-sm"
target="_blank"
:class="classAction"
:title="titleAction"
:title="trans(titleAction)"
@click="openModal"
>
{{ buttonText }}<span v-if="isDead"> ()</span>
@@ -23,14 +23,14 @@
>
<template #header>
<h3 v-if="parent" class="modal-title">
{{ titleModal }}
{{ trans(titleModal, { q: parent.text }) }}
</h3>
<h3 v-else class="modal-title">
{{ titleModal }}
{{ trans(titleModal) }}
</h3>
</template>
<template #body v-if="type === 'person' && action === 'show'">
<template #body v-if="type === 'person'">
<on-the-fly-person
:id="id"
:type="type"
@@ -40,21 +40,12 @@
<div v-if="hasResourceComment">
<h3>{{ trans(ONTHEFLY_RESOURCE_COMMENT_TITLE) }}</h3>
<blockquote class="chill-user-quote">
{{ parent?.comment }}
{{ parent.comment }}
</blockquote>
</div>
</template>
<template #body v-else-if="type === 'person' && action === 'edit'">
<PersonEdit
:id="id"
:action="'edit'"
:query="''"
ref="castEditPerson"
></PersonEdit>
</template>
<template #body v-else-if="type === 'thirdparty' && action === 'show'">
<template #body v-else-if="type === 'thirdparty'">
<on-the-fly-thirdparty
:id="id"
:type="type"
@@ -64,15 +55,11 @@
<div v-if="hasResourceComment">
<h3>{{ trans(ONTHEFLY_RESOURCE_COMMENT_TITLE) }}</h3>
<blockquote class="chill-user-quote">
{{ parent?.comment }}
{{ parent.comment }}
</blockquote>
</div>
</template>
<template #body v-else-if="type === 'thirdparty' && action === 'edit'">
<ThirdPartyEdit ref="castEditThirdParty" action="edit" :id="id"></ThirdPartyEdit>
</template>
<template #body v-else-if="parent">
<on-the-fly-thirdparty
:parent="parent"
@@ -86,7 +73,7 @@
<on-the-fly-create
:action="action"
:allowed-types="allowedTypes"
:query="query || ''"
:query="query"
ref="castNew"
/>
</template>
@@ -95,9 +82,9 @@
<a
v-if="action === 'show'"
:href="buildLocation(id, type)"
:title="titleMessage"
:title="trans(titleMessage)"
class="btn btn-show"
>{{ buttonMessage }}
>{{ trans(buttonMessage) }}
</a>
<a v-else class="btn btn-save" @click="saveAction">
{{ trans(ACTION_SAVE) }}
@@ -106,8 +93,8 @@
</modal>
</teleport>
</template>
<script setup lang="ts">
import {ref, computed, defineEmits, defineProps, useTemplateRef} from "vue";
<script setup>
import { ref, computed, defineEmits, defineProps } from "vue";
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import OnTheFlyCreate from "./Create.vue";
import OnTheFlyPerson from "ChillPersonAssets/vuejs/_components/OnTheFly/Person.vue";
@@ -117,7 +104,7 @@ import {
ACTION_SHOW,
ACTION_EDIT,
ACTION_CREATE,
ACTION_SAVE,
ACTION_ADDCONTACT,
ONTHEFLY_CREATE_TITLE_DEFAULT,
ONTHEFLY_CREATE_TITLE_PERSON,
ONTHEFLY_CREATE_TITLE_THIRDPARTY,
@@ -125,66 +112,40 @@ import {
ONTHEFLY_SHOW_THIRDPARTY,
ONTHEFLY_EDIT_PERSON,
ONTHEFLY_EDIT_THIRDPARTY,
ONTHEFLY_ADDCONTACT_TITLE,
ACTION_REDIRECT_PERSON,
ACTION_REDIRECT_THIRDPARTY,
ONTHEFLY_SHOW_FILE_PERSON,
ONTHEFLY_SHOW_FILE_THIRDPARTY,
ONTHEFLY_SHOW_FILE_DEFAULT,
ONTHEFLY_RESOURCE_COMMENT_TITLE,
THIRDPARTY_ADDCONTACT,
THIRDPARTY_ADDCONTACT_TITLE,
ACTION_SAVE,
} from "translator";
import PersonEdit from "ChillPersonAssets/vuejs/_components/OnTheFly/PersonEdit.vue";
import ThirdPartyEdit from "ChillThirdPartyAssets/vuejs/_components/OnTheFly/ThirdPartyEdit.vue";
import ThirdParty from "ChillThirdPartyAssets/vuejs/_components/OnTheFly/ThirdParty.vue";
// Types
type EntityType = "person" | "thirdparty";
type ActionType = "show" | "edit" | "create" | "addContact";
interface ParentRef {
type: string;
id: string | number;
text?: string;
comment?: string | null;
}
interface OnTheFlyComponentProps {
type: EntityType;
id: number;
action: ActionType;
buttonText?: string | null;
displayBadge?: boolean;
isDead?: boolean;
parent?: ParentRef | null;
allowedTypes?: EntityType[];
query?: string;
}
const props = withDefaults(defineProps<OnTheFlyComponentProps>(), {
buttonText: null,
displayBadge: false,
isDead: false,
parent: null,
allowedTypes: () => ["person", "thirdparty"] as EntityType[],
query: "",
const props = defineProps({
type: String,
id: [String, Number],
action: String,
buttonText: String,
displayBadge: Boolean,
isDead: Boolean,
parent: Object,
allowedTypes: Array,
query: String,
});
const emit = defineEmits<{
(e: "saveFormOnTheFly", payload: { type: string | undefined; data: any }): void;
}>();
const emit = defineEmits(["saveFormOnTheFly"]);
type castEditPersonType = InstanceType<typeof PersonEdit>;
type castEditThirdPartyType = InstanceType<typeof ThirdParty>;
const castEditPerson = useTemplateRef<castEditPersonType>('castEditPerson')
const castEditThirdParty = useTemplateRef<castEditThirdPartyType>('castEditThirdParty');
const modal = ref<{ showModal: boolean; modalDialogClass: string }>({
const modal = ref({
showModal: false,
modalDialogClass: "modal-dialog-scrollable modal-xl",
});
const hasResourceComment = computed<boolean>(() => {
const castPerson = ref();
const castThirdparty = ref();
const castNew = ref();
const hasResourceComment = computed(() => {
return (
typeof props.parent !== "undefined" &&
props.parent !== null &&
@@ -195,7 +156,7 @@ const hasResourceComment = computed<boolean>(() => {
);
});
const classAction = computed<string>(() => {
const classAction = computed(() => {
switch (props.action) {
case "show":
return "btn-show";
@@ -210,127 +171,174 @@ const classAction = computed<string>(() => {
}
});
const titleAction = computed<string>(() => {
const titleAction = computed(() => {
switch (props.action) {
case "show":
return ACTION_SHOW as unknown as string;
return ACTION_SHOW;
case "edit":
return ACTION_EDIT as unknown as string;
return ACTION_EDIT;
case "create":
return ACTION_CREATE as unknown as string;
return ACTION_CREATE;
case "addContact":
return THIRDPARTY_ADDCONTACT as unknown as string;
return ACTION_ADDCONTACT;
default:
return "";
}
});
const titleCreate = computed<string>(() => {
const titleCreate = computed(() => {
if (typeof props.allowedTypes === "undefined") {
return trans(ONTHEFLY_CREATE_TITLE_DEFAULT)
return ONTHEFLY_CREATE_TITLE_DEFAULT;
}
return props.allowedTypes.every((t: EntityType) => t === "person")
? (trans(ONTHEFLY_CREATE_TITLE_PERSON))
: props.allowedTypes.every((t: EntityType) => t === "thirdparty")
? (trans(ONTHEFLY_CREATE_TITLE_THIRDPARTY))
: (trans(ONTHEFLY_CREATE_TITLE_DEFAULT));
return props.allowedTypes.every((t) => t === "person")
? ONTHEFLY_CREATE_TITLE_PERSON
: props.allowedTypes.every((t) => t === "thirdparty")
? ONTHEFLY_CREATE_TITLE_THIRDPARTY
: ONTHEFLY_CREATE_TITLE_DEFAULT;
});
const titleModal = computed<string>(() => {
const titleModal = computed(() => {
switch (props.action) {
case "show":
if (props.type == "person") {
return trans(ONTHEFLY_SHOW_PERSON)
return ONTHEFLY_SHOW_PERSON;
} else if (props.type == "thirdparty") {
return trans(ONTHEFLY_SHOW_THIRDPARTY)
return ONTHEFLY_SHOW_THIRDPARTY;
}
break;
case "edit":
if (props.type == "person") {
return trans(ONTHEFLY_EDIT_PERSON)
return ONTHEFLY_EDIT_PERSON;
} else if (props.type == "thirdparty") {
return trans(ONTHEFLY_EDIT_THIRDPARTY)
return ONTHEFLY_EDIT_THIRDPARTY;
}
break;
case "create":
return titleCreate.value;
case "addContact":
return trans(THIRDPARTY_ADDCONTACT_TITLE)
return ONTHEFLY_ADDCONTACT_TITLE;
default:
break;
}
return "";
});
const titleMessage = computed<string>(() => {
const titleMessage = computed(() => {
switch (props.type) {
case "person":
return trans(ACTION_REDIRECT_PERSON);
return ACTION_REDIRECT_PERSON;
case "thirdparty":
return trans(ACTION_REDIRECT_THIRDPARTY);
return ACTION_REDIRECT_THIRDPARTY;
default:
return "";
}
});
const buttonMessage = computed<string>(() => {
const buttonMessage = computed(() => {
switch (props.type) {
case "person":
return trans(ONTHEFLY_SHOW_FILE_PERSON);
return ONTHEFLY_SHOW_FILE_PERSON;
case "thirdparty":
return trans(ONTHEFLY_SHOW_FILE_THIRDPARTY);
return ONTHEFLY_SHOW_FILE_THIRDPARTY;
default:
return trans(ONTHEFLY_SHOW_FILE_DEFAULT);
return ONTHEFLY_SHOW_FILE_DEFAULT;
}
});
const isDisplayBadge = computed<boolean>(() => {
return props.displayBadge && props.buttonText !== null;
const isDisplayBadge = computed(() => {
return props.displayBadge === true && props.buttonText !== null;
});
const badgeType = computed<string>(() => {
return "entity-" + (props.type ?? "") + " badge-" + (props.type ?? "");
const badgeType = computed(() => {
return "entity-" + props.type + " badge-" + props.type;
});
const getReturnPath = computed<string>(() => {
const getReturnPath = computed(() => {
return `?returnPath=${window.location.pathname}${window.location.search}${window.location.hash}`;
});
function closeModal(): void {
function closeModal() {
modal.value.showModal = false;
}
function openModal(): void {
function openModal() {
modal.value.showModal = true;
}
function buildLocation(id: string | number | undefined, type: EntityType | undefined): string | undefined {
function changeActionTo(action) {
console.log(action);
// Not reactive in setup, but you can emit or use a ref if needed
}
function saveAction() {
let type = props.type,
data = {};
switch (type) {
case "person":
data = castPerson.value?.$data.person;
break;
case "thirdparty":
data = castThirdparty.value?.$data.thirdparty;
break;
default:
if (typeof props.type === "undefined") {
if (props.action === "addContact") {
type = "thirdparty";
data = castThirdparty.value?.$data.thirdparty;
data.parent = {
type: "thirdparty",
id: props.parent.id,
};
data.civility =
data.civility !== null
? {
type: "chill_main_civility",
id: data.civility.id,
}
: null;
data.profession = data.profession !== "" ? data.profession : "";
} else {
type = castNew.value.radioType;
data = castNew.value.castDataByType();
if (typeof data.civility !== "undefined" && null !== data.civility) {
data.civility =
data.civility !== null
? {
type: "chill_main_civility",
id: data.civility.id,
}
: null;
}
if (
typeof data.profession !== "undefined" &&
"" !== data.profession
) {
data.profession = data.profession !== "" ? data.profession : "";
}
}
} else {
throw "error with object type";
}
}
emit("saveFormOnTheFly", { type: type, data: data });
}
function buildLocation(id, type) {
if (type === "person") {
return encodeURI(`/fr/person/${id}/general${getReturnPath.value}`);
} else if (type === "thirdparty") {
return encodeURI(`/fr/3party/3party/${id}/view${getReturnPath.value}`);
}
return undefined;
}
async function saveAction() {
if (props.type === "person") {
const person = await castEditPerson.value?.postPerson();
if (null !== person) {
emit("saveFormOnTheFly", {type: props.type, data: person})
}
} else if (props.type === 'thirdparty') {
const thirdParty = await castEditThirdParty.value?.postThirdParty();
if (null !== thirdParty) {
emit("saveFormOnTheFly", {type: props.type, data: thirdParty })
}
}
}
defineExpose({
openModal,
closeModal,
changeActionTo,
saveAction,
castPerson,
castThirdparty,
castNew,
hasResourceComment,
modal,
isDisplayBadge,

View File

@@ -12,20 +12,13 @@
>{{ trans(USER_CURRENT_USER) }}</span
>
<span
v-else-if="!isEntityHousehold(p)"
v-else
:class="getBadgeClass(p)"
class="chill_denomination"
:style="getBadgeStyle(p)"
>
{{ p.text }}
</span>
<span v-else
:class="getBadgeClass(p)"
class="chill_denomination"
:style="getBadgeStyle(p)"
>
Ménage n°{{ p.id }}
</span>
</li>
</ul>
<ul class="record_actions mb-0">
@@ -47,7 +40,6 @@
:key="uniqid"
:buttonTitle="translatedListOfTypes"
:modalTitle="translatedListOfTypes"
:allowCreate="true"
@addNewPersons="addNewEntity"
>
</add-persons>
@@ -61,12 +53,9 @@
@click="addNewSuggested(s)"
style="margin: 0"
>
<span v-if="!isEntityHousehold(s)" :class="getBadgeClass(s)" :style="getBadgeStyle(s)">
<span :class="getBadgeClass(s)" :style="getBadgeStyle(s)">
{{ s.text }}
</span>
<span v-else>
Ménage n°{{ s.id }}
</span>
</li>
</ul>
</div>
@@ -85,9 +74,8 @@ import AddPersons from "ChillPersonAssets/vuejs/_components/AddPersons.vue";
import {
Entities,
EntitiesOrMe,
EntityType, isEntityHousehold,
EntityType,
SearchOptions,
Suggestion,
} from "ChillPersonAssets/types";
import {
PICK_ENTITY_MODAL_TITLE,
@@ -194,7 +182,7 @@ function addNewSuggested(entity: EntitiesOrMe) {
emits("addNewEntity", { entity });
}
function addNewEntity({ selected }: { selected: Suggestion[] }) {
function addNewEntity({ selected }: addNewEntities) {
Object.values(selected).forEach((item) => {
emits("addNewEntity", { entity: item.result });
});

View File

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

View File

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

View File

@@ -1,57 +0,0 @@
import {ref} from "vue";
import {ValidationExceptionInterface} from "ChillMainAssets/types";
export function useViolationList<T extends Record<string, Record<string, string>>>() {
type ViolationKey = Extract<keyof T, string>;
const violationsList = ref<ValidationExceptionInterface<T>|null>(null);
function violationTitles<P extends ViolationKey>(property: P): string[] {
if (null === violationsList.value) {
return [];
}
const r = violationsList.value.violationsByNormalizedProperty(property).map((v) => v.title);
return r;
}
function violationTitlesWithParameter<
P extends ViolationKey,
Param extends Extract<keyof T[P], string>
>(
property: P,
with_parameter: Param,
with_parameter_value: T[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 ViolationKey>(property: P): boolean {
return violationTitles(property).length > 0;
}
function hasViolationWithParameter<
P extends ViolationKey,
Param extends Extract<keyof T[P], string>
>(
property: P,
with_parameter: Param,
with_parameter_value: T[P][Param],
): boolean {
return violationTitlesWithParameter(property, with_parameter, with_parameter_value).length > 0;
}
function setValidationException<V extends ValidationExceptionInterface<T>>(validationException: V): void {
violationsList.value = validationException;
}
function cleanException(): void {
violationsList.value = null;
}
return {violationTitles, violationTitlesWithParameter, setValidationException, cleanException, hasViolationWithParameter, hasViolation};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -136,6 +136,34 @@ 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: >-

View File

@@ -935,12 +935,11 @@ onthefly:
thirdparty: Détails du tiers
file_person: Ouvrir la fiche de l'usager
file_thirdparty: Voir le Tiers
file_default: Voir
edit:
person: Modifier un usager
thirdparty: Modifier un tiers
create:
button: Créer "q"
button: Créer {q}
title:
default: Création d'un nouvel usager ou d'un tiers professionnel
person: Création d'un nouvel usager

View File

@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Actions\PersonCreate;
use Chill\MainBundle\Entity\Address;
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\PersonAltName;
use libphonenumber\PhoneNumber;
use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as MisdPhoneNumberConstraint;
use Symfony\Component\Validator\Constraints as Assert;
use Chill\PersonBundle\Validator\Constraints\Person\Birthdate;
class PersonCreateDTO
{
#[Assert\NotBlank(message: 'The firstname cannot be empty')]
#[Assert\Length(max: 255)]
public string $firstName;
#[Assert\NotBlank(message: 'The lastname cannot be empty')]
#[Assert\Length(max: 255)]
public string $lastName;
#[Birthdate]
public ?\DateTime $birthdate = null;
#[Assert\NotNull(message: 'The gender must be set')]
public ?Gender $gender = null;
public ?Civility $civility = null;
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::FIXED_LINE, MisdPhoneNumberConstraint::VOIP, MisdPhoneNumberConstraint::PERSONAL_NUMBER])]
public ?PhoneNumber $phonenumber = null;
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::MOBILE])]
public ?PhoneNumber $mobilenumber = null;
#[Assert\Email]
public ?string $email = '';
// Checkbox that indicates whether the address form was checked in creation form
public bool $addressForm = false;
// Selected address value (unmapped in Person entity during creation)
public ?Address $address = null;
public ?Center $center = null;
/**
* @var array<string, PersonAltName> where the key is the altname's key
*/
public array $altNames = [];
/**
* @var array<string, PersonIdentifier>
*/
#[Assert\Valid(traverse: true)]
public array $identifiers = [];
}

View File

@@ -1,96 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Actions\PersonCreate\Service;
use Chill\PersonBundle\Actions\PersonCreate\PersonCreateDTO;
use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonAltName;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
class PersonCreateDTOFactory
{
public function __construct(
private readonly ConfigPersonAltNamesHelper $configPersonAltNamesHelper,
private readonly PersonIdentifierManagerInterface $personIdentifierManager,
) {}
public function createPersonCreateDTO(Person $person): PersonCreateDTO
{
$dto = new PersonCreateDTO();
$dto->firstName = $person->getFirstName();
$dto->lastName = $person->getLastName();
$dto->birthdate = $person->getBirthdate();
$dto->gender = $person->getGender();
$dto->civility = $person->getCivility();
$dto->phonenumber = $person->getPhonenumber();
$dto->mobilenumber = $person->getMobilenumber();
$dto->email = $person->getEmail();
$dto->center = $person->getCenter();
// address/addressForm are not mapped on Person entity; left to defaults
foreach ($this->configPersonAltNamesHelper->getChoices() as $key => $labels) {
$altName = $person->getAltNames()->findFirst(fn (int $k, PersonAltName $altName) => $altName->getKey() === $key);
if (null === $altName) {
$altName = new PersonAltName();
$altName->setKey($key);
}
$dto->altNames[$key] = $altName;
}
foreach ($this->personIdentifierManager->getWorkers() as $worker) {
$identifier = $person
->getIdentifiers()
->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition() === $identifier->getDefinition());
if (null === $identifier) {
$identifier = new PersonIdentifier($worker->getDefinition());
$person->addIdentifier($identifier);
}
$dto->identifiers['identifier_'.$worker->getDefinition()->getId()] = $identifier;
}
return $dto;
}
public function mapPersonCreateDTOtoPerson(PersonCreateDTO $personCreateDTO, Person $person): void
{
$person
->setFirstName($personCreateDTO->firstName)
->setLastName($personCreateDTO->lastName)
->setBirthdate($personCreateDTO->birthdate)
->setGender($personCreateDTO->gender)
->setCivility($personCreateDTO->civility)
->setPhonenumber($personCreateDTO->phonenumber)
->setMobilenumber($personCreateDTO->mobilenumber)
->setEmail($personCreateDTO->email)
->setCenter($personCreateDTO->center);
foreach ($personCreateDTO->altNames as $altName) {
if ('' === $altName->getLabel()) {
$person->removeAltName($altName);
} else {
$person->addAltName($altName);
}
}
foreach ($personCreateDTO->identifiers as $identifier) {
$worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($identifier->getDefinition());
if ($worker->isEmpty($identifier)) {
$person->removeIdentifier($identifier);
} else {
$person->addIdentifier($identifier);
}
}
// Note: address and addressForm are handled by controller/form during creation, not mapped here
}
}

View File

@@ -1,108 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Actions\PersonEdit;
use Chill\MainBundle\Entity\Civility;
use Chill\MainBundle\Entity\Country;
use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable;
use Chill\MainBundle\Entity\Gender;
use Chill\MainBundle\Entity\Language;
use Chill\PersonBundle\Entity\AdministrativeStatus;
use Chill\PersonBundle\Entity\EmploymentStatus;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\MaritalStatus;
use Chill\PersonBundle\Entity\PersonAltName;
use Chill\PersonBundle\Validator\Constraints\Person\Birthdate;
use Doctrine\Common\Collections\Collection;
use libphonenumber\PhoneNumber;
use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as MisdPhoneNumberConstraint;
use Symfony\Component\Validator\Constraints as Assert;
class PersonEditDTO
{
#[Assert\NotBlank(message: 'The firstname cannot be empty')]
#[Assert\Length(max: 255)]
public string $firstName;
#[Assert\NotBlank(message: 'The lastname cannot be empty')]
#[Assert\Length(max: 255)]
public string $lastName;
#[Birthdate]
public ?\DateTime $birthdate = null;
#[Assert\GreaterThanOrEqual(propertyPath: 'birthdate')]
#[Assert\LessThanOrEqual('today')]
public ?\DateTimeImmutable $deathdate = null;
#[Assert\NotNull(message: 'The gender must be set')]
public ?Gender $gender = null;
#[Assert\Valid]
public CommentEmbeddable $genderComment;
public ?int $numberOfChildren = null;
/**
* @var array<string, PersonAltName> where the key is the altname's key
*/
public array $altNames = [];
public string $memo = '';
public ?EmploymentStatus $employmentStatus = null;
public ?AdministrativeStatus $administrativeStatus = null;
public string $placeOfBirth = '';
public ?string $contactInfo = '';
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::FIXED_LINE, MisdPhoneNumberConstraint::VOIP, MisdPhoneNumberConstraint::PERSONAL_NUMBER])]
public ?PhoneNumber $phonenumber = null;
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::MOBILE])]
public ?PhoneNumber $mobilenumber = null;
public ?bool $acceptSms = false;
#[Assert\Valid(traverse: true)]
public Collection $otherPhonenumbers; // Collection<int, \Chill\PersonBundle\Entity\PersonPhone>
#[Assert\Email]
public ?string $email = '';
public ?bool $acceptEmail = false;
public ?Country $countryOfBirth = null;
public ?Country $nationality = null;
public Collection $spokenLanguages; // Collection<int, Language>
public ?Civility $civility = null;
public ?MaritalStatus $maritalStatus = null;
public ?\DateTimeInterface $maritalStatusDate = null;
#[Assert\Valid]
public CommentEmbeddable $maritalStatusComment;
public ?array $cFData = null;
/**
* @var array<string, PersonIdentifier>
*/
#[Assert\Valid(traverse: true)]
public array $identifiers = [];
}

View File

@@ -1,130 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Actions\PersonEdit\Service;
use Chill\PersonBundle\Actions\PersonEdit\PersonEditDTO;
use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonAltName;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
class PersonEditDTOFactory
{
public function __construct(
private readonly ConfigPersonAltNamesHelper $configPersonAltNamesHelper,
private readonly PersonIdentifierManagerInterface $personIdentifierManager,
) {}
public function createPersonEditDTO(Person $person): PersonEditDTO
{
$dto = new PersonEditDTO();
$dto->firstName = $person->getFirstName();
$dto->lastName = $person->getLastName();
$dto->birthdate = $person->getBirthdate();
$dto->deathdate = (null !== $deathDate = $person->getDeathdate()) ? \DateTimeImmutable::createFromInterface($deathDate) : null;
$dto->gender = $person->getGender();
$dto->genderComment = $person->getGenderComment();
$dto->numberOfChildren = $person->getNumberOfChildren();
$dto->memo = $person->getMemo() ?? '';
$dto->employmentStatus = $person->getEmploymentStatus();
$dto->administrativeStatus = $person->getAdministrativeStatus();
$dto->placeOfBirth = $person->getPlaceOfBirth() ?? '';
$dto->contactInfo = $person->getcontactInfo();
$dto->phonenumber = $person->getPhonenumber();
$dto->mobilenumber = $person->getMobilenumber();
$dto->acceptSms = $person->getAcceptSMS();
$dto->otherPhonenumbers = $person->getOtherPhoneNumbers();
$dto->email = $person->getEmail();
$dto->acceptEmail = $person->getAcceptEmail();
$dto->countryOfBirth = $person->getCountryOfBirth();
$dto->nationality = $person->getNationality();
$dto->spokenLanguages = $person->getSpokenLanguages();
$dto->civility = $person->getCivility();
$dto->maritalStatus = $person->getMaritalStatus();
$dto->maritalStatusDate = $person->getMaritalStatusDate();
$dto->maritalStatusComment = $person->getMaritalStatusComment();
$dto->cFData = $person->getCFData();
foreach ($this->configPersonAltNamesHelper->getChoices() as $key => $labels) {
$altName = $person->getAltNames()->findFirst(fn (int $k, PersonAltName $altName) => $altName->getKey() === $key);
if (null === $altName) {
$altName = new PersonAltName();
$altName->setKey($key);
}
$dto->altNames[$key] = $altName;
}
foreach ($this->personIdentifierManager->getWorkers() as $worker) {
$identifier = $person
->getIdentifiers()
->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition() === $identifier->getDefinition());
if (null === $identifier) {
$identifier = new PersonIdentifier($worker->getDefinition());
$person->addIdentifier($identifier);
}
$dto->identifiers['identifier_'.$worker->getDefinition()->getId()] = $identifier;
}
return $dto;
}
public function mapPersonEditDTOtoPerson(PersonEditDTO $personEditDTO, Person $person): void
{
// Copy all editable fields from the DTO back to the Person entity
$person
->setFirstName($personEditDTO->firstName)
->setLastName($personEditDTO->lastName)
->setBirthdate($personEditDTO->birthdate)
->setDeathdate($personEditDTO->deathdate)
->setGender($personEditDTO->gender)
->setGenderComment($personEditDTO->genderComment)
->setNumberOfChildren($personEditDTO->numberOfChildren)
->setMemo($personEditDTO->memo)
->setEmploymentStatus($personEditDTO->employmentStatus)
->setAdministrativeStatus($personEditDTO->administrativeStatus)
->setPlaceOfBirth($personEditDTO->placeOfBirth)
->setcontactInfo($personEditDTO->contactInfo)
->setPhonenumber($personEditDTO->phonenumber)
->setMobilenumber($personEditDTO->mobilenumber)
->setAcceptSMS($personEditDTO->acceptSms ?? false)
->setOtherPhoneNumbers($personEditDTO->otherPhonenumbers)
->setEmail($personEditDTO->email)
->setAcceptEmail($personEditDTO->acceptEmail ?? false)
->setCountryOfBirth($personEditDTO->countryOfBirth)
->setNationality($personEditDTO->nationality)
->setSpokenLanguages($personEditDTO->spokenLanguages)
->setCivility($personEditDTO->civility)
->setMaritalStatus($personEditDTO->maritalStatus)
->setMaritalStatusDate($personEditDTO->maritalStatusDate)
->setMaritalStatusComment($personEditDTO->maritalStatusComment)
->setCFData($personEditDTO->cFData);
foreach ($personEditDTO->altNames as $altName) {
if ('' === $altName->getLabel()) {
$person->removeAltName($altName);
} else {
$person->addAltName($altName);
}
}
foreach ($personEditDTO->identifiers as $identifier) {
$worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($identifier->getDefinition());
if ($worker->isEmpty($identifier)) {
$person->removeIdentifier($identifier);
} else {
$person->addIdentifier($identifier);
}
}
}
}

View File

@@ -28,8 +28,6 @@ class ConfigPersonAltNamesHelper
/**
* get the choices as key => values.
*
* @return array<string, array<string, string>> where the key is the altName's key, and the value is an array of TranslatableString
*/
public function getChoices(): array
{

View File

@@ -11,22 +11,36 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Form\CreationPersonType;
use Chill\PersonBundle\Privacy\PrivacyEvent;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Search\SimilarPersonMatcher;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\ORM\EntityManagerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Form;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function hash;
final class PersonController extends AbstractController
{
public function __construct(
private readonly AuthorizationHelperInterface $authorizationHelper,
private readonly SimilarPersonMatcher $similarPersonMatcher,
private readonly TranslatorInterface $translator,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly PersonRepository $personRepository,
private readonly ConfigPersonAltNamesHelper $configPersonAltNameHelper,
@@ -71,6 +85,110 @@ final class PersonController extends AbstractController
);
}
/**
* Method for creating a new person.
*
* The controller register data from a previous post on the form, and
* register it in the session.
*
* The next post compare the data with previous one and, if yes, show a
* review page if there are "alternate persons".
*/
#[Route(path: '/{_locale}/person/new', name: 'chill_person_new')]
public function newAction(Request $request): Response
{
$person = new Person();
$authorizedCenters = $this->authorizationHelper->getReachableCenters($this->getUser(), PersonVoter::CREATE);
if (1 === \count($authorizedCenters)) {
$person->setCenter($authorizedCenters[0]);
}
$form = $this->createForm(CreationPersonType::class, $person)
->add('editPerson', SubmitType::class, [
'label' => 'Add the person',
])->add('createPeriod', SubmitType::class, [
'label' => 'Add the person and create an accompanying period',
])->add('createHousehold', SubmitType::class, [
'label' => 'Add the person and create a household',
]);
$form->handleRequest($request);
if (Request::METHOD_GET === $request->getMethod()) {
$this->lastPostDataReset();
} elseif (
Request::METHOD_POST === $request->getMethod()
&& $form->isValid()
) {
$alternatePersons = $this->similarPersonMatcher
->matchPerson($person);
if (
false === $this->isLastPostDataChanges($form, $request, true)
|| 0 === \count($alternatePersons)
) {
$this->em->persist($person);
$this->em->flush();
$this->lastPostDataReset();
$address = $form->get('address')->getData();
$addressForm = (bool) $form->get('addressForm')->getData();
if (null !== $address && $addressForm) {
$household = new Household();
$member = new HouseholdMember();
$member->setPerson($person);
$member->setStartDate(new \DateTimeImmutable());
$household->addMember($member);
$household->setForceAddress($address);
$this->em->persist($member);
$this->em->persist($household);
$this->em->flush();
if ($form->get('createHousehold')->isClicked()) {
return $this->redirectToRoute('chill_person_household_members_editor', [
'persons' => [$person->getId()],
'household' => $household->getId(),
]);
}
}
if ($form->get('createPeriod')->isClicked()) {
return $this->redirectToRoute('chill_person_accompanying_course_new', [
'person_id' => [$person->getId()],
]);
}
if ($form->get('createHousehold')->isClicked()) {
return $this->redirectToRoute('chill_person_household_members_editor', [
'persons' => [$person->getId()],
]);
}
return $this->redirectToRoute(
'chill_person_general_edit',
['person_id' => $person->getId()]
);
}
} elseif (Request::METHOD_POST === $request->getMethod() && !$form->isValid()) {
$this->addFlash('error', $this->translator->trans('This form contains errors'));
}
return $this->render(
'@ChillPerson/Person/create.html.twig',
[
'form' => $form->createView(),
'alternatePersons' => $alternatePersons ?? [],
]
);
}
#[Route(path: '/{_locale}/person/{person_id}/general', name: 'chill_person_view')]
public function viewAction(int $person_id)
{
@@ -132,4 +250,51 @@ final class PersonController extends AbstractController
return $errors;
}
private function isLastPostDataChanges(Form $form, Request $request, bool $replace = false): bool
{
/** @var SessionInterface $session */
$session = $this->get('session');
if (!$session->has('last_person_data')) {
return true;
}
$newPost = $this->lastPostDataBuildHash($form, $request);
$isChanged = $session->get('last_person_data') !== $newPost;
if ($replace) {
$session->set('last_person_data', $newPost);
}
return $isChanged;
}
/**
* build the hash for posted data.
*
* For privacy reasons, the data are hashed using sha512
*/
private function lastPostDataBuildHash(Form $form, Request $request): string
{
$fields = [];
$ignoredFields = ['form_status', '_token'];
foreach ($request->request->all()[$form->getName()] as $field => $value) {
if (\in_array($field, $ignoredFields, true)) {
continue;
}
$fields[$field] = \is_array($value) ?
\implode(',', $value) : $value;
}
ksort($fields);
return \hash('sha512', \implode('&', $fields));
}
private function lastPostDataReset(): void
{
$this->get('session')->set('last_person_data', '');
}
}

View File

@@ -1,205 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\PersonBundle\Actions\PersonCreate\Service\PersonCreateDTOFactory;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Form\CreationPersonType;
use Chill\PersonBundle\Search\SimilarPersonMatcher;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\ClickableInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
final class PersonCreateController extends AbstractController
{
public function __construct(
private readonly AuthorizationHelperInterface $authorizationHelper,
private readonly SimilarPersonMatcher $similarPersonMatcher,
private readonly TranslatorInterface $translator,
private readonly EntityManagerInterface $em,
private readonly PersonCreateDTOFactory $personCreateDTOFactory,
) {}
/**
* Method for creating a new person.
*
* The controller registers data from a previous post on the form and
* registers it in the session.
*
* The next post compares the data with the previous one and, if yes, shows a
* review page if there are "alternate persons".
*/
#[Route(path: '/{_locale}/person/new', name: 'chill_person_new')]
public function newAction(Request $request, SessionInterface $session): Response
{
$person = new Person();
$authorizedCenters = $this->authorizationHelper->getReachableCenters($this->getUser(), PersonVoter::CREATE);
$dto = $this->personCreateDTOFactory->createPersonCreateDTO($person);
if (1 === \count($authorizedCenters)) {
$dto->center = $authorizedCenters[0];
}
$form = $this->createForm(CreationPersonType::class, $dto)
->add('editPerson', SubmitType::class, [
'label' => 'Add the person',
])->add('createPeriod', SubmitType::class, [
'label' => 'Add the person and create an accompanying period',
])->add('createHousehold', SubmitType::class, [
'label' => 'Add the person and create a household',
]);
$form->handleRequest($request);
if (Request::METHOD_GET === $request->getMethod()) {
$this->lastPostDataReset($session);
} elseif (
Request::METHOD_POST === $request->getMethod()
&& $form->isValid()
) {
$alternatePersons = $this->similarPersonMatcher
->matchPerson($person);
$createHouseholdButton = $form->get('createHousehold');
$createPeriodButton = $form->get('createPeriod');
$editPersonButton = $form->get('editPerson');
if (!$createHouseholdButton instanceof ClickableInterface) {
throw new \UnexpectedValueException();
}
if (!$createPeriodButton instanceof ClickableInterface) {
throw new \UnexpectedValueException();
}
if (!$editPersonButton instanceof ClickableInterface) {
throw new \UnexpectedValueException();
}
if (
false === $this->isLastPostDataChanges($form, $request, $session)
|| 0 === \count($alternatePersons)
) {
$this->personCreateDTOFactory->mapPersonCreateDTOtoPerson($dto, $person);
$this->em->persist($person);
$this->em->flush();
$this->lastPostDataReset($session);
$address = $dto->address;
$addressForm = $dto->addressForm;
if (null !== $address && $addressForm) {
$household = new Household();
$member = new HouseholdMember();
$member->setPerson($person);
$member->setStartDate(new \DateTimeImmutable());
$household->addMember($member);
$household->setForceAddress($address);
$this->em->persist($member);
$this->em->persist($household);
$this->em->flush();
if ($createHouseholdButton->isClicked()) {
return $this->redirectToRoute('chill_person_household_members_editor', [
'persons' => [$person->getId()],
'household' => $household->getId(),
]);
}
}
if ($createPeriodButton->isClicked()) {
return $this->redirectToRoute('chill_person_accompanying_course_new', [
'person_id' => [$person->getId()],
]);
}
if ($createHouseholdButton->isClicked()) {
return $this->redirectToRoute('chill_person_household_members_editor', [
'persons' => [$person->getId()],
]);
}
return $this->redirectToRoute(
'chill_person_general_edit',
['person_id' => $person->getId()]
);
}
} elseif (Request::METHOD_POST === $request->getMethod() && !$form->isValid()) {
$this->addFlash('error', $this->translator->trans('This form contains errors'));
}
return $this->render(
'@ChillPerson/Person/create.html.twig',
[
'form' => $form->createView(),
'alternatePersons' => $alternatePersons ?? [],
]
);
}
private function isLastPostDataChanges(FormInterface $form, Request $request, SessionInterface $session): bool
{
if (!$session->has('last_person_data')) {
return true;
}
$newPost = $this->lastPostDataBuildHash($form, $request);
$isChanged = $session->get('last_person_data') !== $newPost;
$session->set('last_person_data', $newPost);
return $isChanged;
}
/**
* build the hash for posted data.
*
* For privacy reasons, the data are hashed using sha512
*/
private function lastPostDataBuildHash(FormInterface $form, Request $request): string
{
$fields = [];
$ignoredFields = ['form_status', '_token', 'identifiers'];
foreach ($request->request->all()[$form->getName()] as $field => $value) {
if (\in_array($field, $ignoredFields, true)) {
continue;
}
$fields[$field] = \is_array($value) ?
\implode(',', $value) : $value;
}
ksort($fields);
return \hash('sha512', \implode('&', $fields));
}
private function lastPostDataReset(SessionInterface $session): void
{
$session->set('last_person_data', '');
}
}

View File

@@ -12,7 +12,6 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Controller;
use Chill\CustomFieldsBundle\EntityRepository\CustomFieldsDefaultGroupRepository;
use Chill\PersonBundle\Actions\PersonEdit\Service\PersonEditDTOFactory;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Form\PersonType;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
@@ -39,7 +38,6 @@ final readonly class PersonEditController
private EntityManagerInterface $entityManager,
private UrlGeneratorInterface $urlGenerator,
private Environment $twig,
private PersonEditDTOFactory $personEditDTOFactory,
) {}
/**
@@ -52,11 +50,9 @@ final readonly class PersonEditController
throw new AccessDeniedHttpException('You are not allowed to edit this person.');
}
$dto = $this->personEditDTOFactory->createPersonEditDTO($person);
$form = $this->formFactory->create(
PersonType::class,
$dto,
$person,
['cFGroup' => $this->customFieldsDefaultGroupRepository->findOneByEntity(Person::class)?->getCustomFieldsGroup()]
);
@@ -66,7 +62,6 @@ final readonly class PersonEditController
$session
->getFlashBag()->add('error', new TranslatableMessage('This form contains errors'));
} elseif ($form->isSubmitted() && $form->isValid()) {
$this->personEditDTOFactory->mapPersonEditDTOtoPerson($dto, $person);
$this->entityManager->flush();
$session->getFlashBag()->add('success', new TranslatableMessage('The person data has been updated'));

View File

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

View File

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

View File

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

View File

@@ -12,24 +12,15 @@ 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;
use Symfony\Component\Serializer\Annotation as Serializer;
#[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]
#[Serializer\DiscriminatorMap('type', ['person_identifier' => PersonIdentifier::class])]
class PersonIdentifier
{
#[ORM\Id]
#[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)]
#[ORM\GeneratedValue]
#[Serializer\Groups(['read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Person::class)]
@@ -37,16 +28,14 @@ class PersonIdentifier
private ?Person $person = null;
#[ORM\Column(name: 'value', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
#[Serializer\Groups(['read'])]
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: 'RESTRICT')]
#[Serializer\Groups(['read'])]
#[ORM\JoinColumn(name: 'definition_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private PersonIdentifierDefinition $definition,
) {}

View File

@@ -11,35 +11,30 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Entity\Identifier;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
#[ORM\Entity]
#[ORM\Table(name: 'chill_person_identifier_definition')]
#[Serializer\DiscriminatorMap('type', ['person_identifier_definition' => PersonIdentifierDefinition::class])]
class PersonIdentifierDefinition
{
#[ORM\Id]
#[ORM\Column(name: 'id', type: Types::INTEGER)]
#[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)]
#[ORM\GeneratedValue]
#[Serializer\Groups(['read'])]
private ?int $id = null;
#[ORM\Column(name: 'active', type: Types::BOOLEAN, nullable: false, options: ['default' => true])]
#[ORM\Column(name: 'active', type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => true])]
private bool $active = true;
public function __construct(
#[ORM\Column(name: 'label', type: Types::JSON, nullable: false, options: ['default' => '[]'])]
#[ORM\Column(name: 'label', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])]
private array $label,
#[ORM\Column(name: 'engine', type: Types::STRING, length: 100)]
#[Serializer\Groups(['read'])]
#[ORM\Column(name: 'engine', type: \Doctrine\DBAL\Types\Types::STRING, length: 100)]
private string $engine,
#[ORM\Column(name: 'is_searchable', type: Types::BOOLEAN, options: ['default' => false])]
#[ORM\Column(name: 'is_searchable', type: \Doctrine\DBAL\Types\Types::BOOLEAN, options: ['default' => false])]
private bool $isSearchable = false,
#[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])]
#[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])]
private array $data = [],
) {}
@@ -63,6 +58,11 @@ class PersonIdentifierDefinition
return $this->engine;
}
public function setEngine(string $engine): void
{
$this->engine = $engine;
}
public function isSearchable(): bool
{
return $this->isSearchable;
@@ -75,7 +75,12 @@ class PersonIdentifierDefinition
public function isEditableByUsers(): bool
{
return $this->presence->isEditableByUser();
return $this->isEditableByUsers;
}
public function setIsEditableByUsers(bool $isEditableByUsers): void
{
$this->isEditableByUsers = $isEditableByUsers;
}
public function isActive(): bool
@@ -99,16 +104,4 @@ class PersonIdentifierDefinition
{
$this->data = $data;
}
public function getPresence(): IdentifierPresenceEnum
{
return $this->presence;
}
public function setPresence(IdentifierPresenceEnum $presence): self
{
$this->presence = $presence;
return $this;
}
}

View File

@@ -27,6 +27,7 @@ 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;
@@ -35,7 +36,6 @@ 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,7 +47,6 @@ 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;
@@ -274,8 +273,6 @@ 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;
/**
@@ -322,7 +319,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* The person's mobile phone number.
*/
#[ORM\Column(type: 'phone_number', nullable: true)]
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::MOBILE])]
#[PhonenumberConstraint(type: 'mobile')]
private ?PhoneNumber $mobilenumber = null;
/**
@@ -362,7 +359,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI
* The person's phonenumber.
*/
#[ORM\Column(type: 'phone_number', nullable: true)]
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::FIXED_LINE, MisdPhoneNumberConstraint::VOIP, MisdPhoneNumberConstraint::PERSONAL_NUMBER])]
#[PhonenumberConstraint(type: 'landline')]
private ?PhoneNumber $phonenumber = null;
/**

View File

@@ -11,14 +11,15 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Form;
use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Form\Event\CustomizeFormEvent;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\ChillPhoneNumberType;
use Chill\MainBundle\Form\Type\PickAddressType;
use Chill\MainBundle\Form\Type\PickCenterType;
use Chill\MainBundle\Form\Type\PickCivilityType;
use Chill\PersonBundle\Actions\PersonCreate\PersonCreateDTO;
use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Form\Type\PersonAltNameType;
use Chill\PersonBundle\Form\Type\PickGenderType;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
@@ -32,7 +33,6 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class CreationPersonType extends AbstractType
{
@@ -80,18 +80,15 @@ final class CreationPersonType extends AbstractType
->add('addressForm', CheckboxType::class, [
'label' => 'Create a household and add an address',
'required' => false,
'mapped' => false,
'help' => 'A new household will be created. The person will be member of this household.',
])
->add('address', PickAddressType::class, [
'required' => false,
'mapped' => false,
'label' => false,
]);
$builder->add('identifiers', PersonIdentifiersType::class, [
'by_reference' => false,
'step' => 'on_create',
]);
if ($this->askCenters) {
$builder
->add('center', PickCenterType::class, [
@@ -115,7 +112,7 @@ final class CreationPersonType extends AbstractType
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => PersonCreateDTO::class,
'data_class' => Person::class,
'constraints' => [
new Callback($this->validateCheckedAddress(...)),
],
@@ -132,12 +129,10 @@ final class CreationPersonType extends AbstractType
public function validateCheckedAddress($data, ExecutionContextInterface $context, $payload): void
{
if (!$data instanceof PersonCreateDTO) {
throw new UnexpectedTypeException($data, PersonCreateDTO::class);
}
$addressFrom = $data->addressForm;
$address = $data->address;
/** @var bool $addressFrom */
$addressFrom = $context->getObject()->get('addressForm')->getData();
/** @var ?Address $address */
$address = $context->getObject()->get('address')->getData();
if ($addressFrom && null === $address) {
$context->buildViolation('person_creation.If you want to create an household, an address is required')

View File

@@ -12,7 +12,10 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Form\DataMapper;
use Chill\PersonBundle\Entity\PersonAltName;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
class PersonAltNameDataMapper implements DataMapperInterface
{
@@ -22,24 +25,62 @@ class PersonAltNameDataMapper implements DataMapperInterface
return;
}
if (!is_array($viewData)) {
throw new \InvalidArgumentException('View data must be an array');
if (!$viewData instanceof Collection) {
throw new UnexpectedTypeException($viewData, Collection::class);
}
$mapIndexToKey = [];
foreach ($viewData->getIterator() as $key => $altName) {
/* @var PersonAltName $altName */
$mapIndexToKey[$altName->getKey()] = $key;
}
foreach ($forms as $key => $form) {
$personAltName = $viewData[$key];
if (!$personAltName instanceof PersonAltName) {
throw new \InvalidArgumentException('PersonAltName must be an instance of PersonAltName');
if (\array_key_exists($key, $mapIndexToKey)) {
$form->setData($viewData->get($mapIndexToKey[$key])->getLabel());
}
$form->setData($personAltName->getLabel());
}
}
public function mapFormsToData(\Traversable $forms, &$viewData): void
{
$mapIndexToKey = [];
if (\is_array($viewData)) {
$dataIterator = $viewData;
} else {
$dataIterator = $viewData instanceof ArrayCollection ?
$viewData->toArray() : $viewData->getIterator();
}
foreach ($dataIterator as $key => $altName) {
/* @var PersonAltName $altName */
$mapIndexToKey[$altName->getKey()] = $key;
}
foreach ($forms as $key => $form) {
$personAltName = array_find($viewData, fn (PersonAltName $altName) => $altName->getKey() === $key);
$personAltName->setLabel($form->getData());
$isEmpty = empty($form->getData());
if (\array_key_exists($key, $mapIndexToKey)) {
if ($isEmpty) {
$viewData->remove($mapIndexToKey[$key]);
} else {
$viewData->get($mapIndexToKey[$key])->setLabel($form->getData());
}
} else {
if (!$isEmpty) {
$altName = (new PersonAltName())
->setKey($key)
->setLabel($form->getData());
if (\is_array($viewData)) {
$viewData[] = $altName;
} else {
$viewData->add($altName);
}
}
}
}
}
}

View File

@@ -14,51 +14,60 @@ namespace Chill\PersonBundle\Form\DataMapper;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\PersonIdentifier\Exception\UnexpectedTypeException;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Chill\PersonBundle\Repository\Identifier\PersonIdentifierDefinitionRepository;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\FormInterface;
final readonly class PersonIdentifiersDataMapper implements DataMapperInterface
{
public function __construct(
private PersonIdentifierManagerInterface $identifierManager,
private PersonIdentifierDefinitionRepository $identifierDefinitionRepository,
) {}
/**
* @pure
*/
public function mapDataToForms($viewData, \Traversable $forms): void
{
if (!$viewData instanceof PersonIdentifier) {
throw new UnexpectedTypeException($viewData, PersonIdentifier::class);
if (!$viewData instanceof Collection) {
throw new UnexpectedTypeException($viewData, Collection::class);
}
/** @var array<string, FormInterface> $formsByKey */
$formsByKey = iterator_to_array($forms);
$worker = $this->identifierManager->buildWorkerByPersonIdentifierDefinition($viewData->getDefinition());
if (!$worker->getDefinition()->isEditableByUsers()) {
return;
}
foreach ($forms as $key => $form) {
$form->setData($viewData->getValue()[$key] ?? $worker->getDefaultValue()[$key] ?? '');
foreach ($this->identifierManager->getWorkers() as $worker) {
if (!$worker->getDefinition()->isEditableByUsers()) {
continue;
}
$form = $formsByKey['identifier_'.$worker->getDefinition()->getId()];
$identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition()->getId() === $identifier->getId());
if (null === $identifier) {
$identifier = new PersonIdentifier($worker->getDefinition());
}
$form->setData($identifier->getValue());
}
}
/**
* @pure
*/
public function mapFormsToData(\Traversable $forms, &$viewData): void
{
if (!$viewData instanceof PersonIdentifier) {
throw new UnexpectedTypeException($viewData, PersonIdentifier::class);
}
$worker = $this->identifierManager->buildWorkerByPersonIdentifierDefinition($viewData->getDefinition());
if (!$worker->getDefinition()->isEditableByUsers()) {
return;
if (!$viewData instanceof Collection) {
throw new UnexpectedTypeException($viewData, Collection::class);
}
$values = [];
foreach ($forms as $name => $form) {
$values[$name] = $form->getData();
}
$identifierId = (int) substr((string) $name, 11);
$identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $identifier->getId() === $identifierId);
$definition = $this->identifierDefinitionRepository->find($identifierId);
if (null === $identifier) {
$identifier = new PersonIdentifier($definition);
$viewData->add($identifier);
}
if (!$identifier->getDefinition()->isEditableByUsers()) {
continue;
}
$viewData->setValue($values);
$viewData->setCanonical($worker->canonicalizeValue($viewData->getValue()));
$worker = $this->identifierManager->buildWorkerByPersonIdentifierDefinition($definition);
$identifier->setValue($form->getData());
$identifier->setCanonical($worker->canonicalizeValue($identifier->getValue()));
}
}
}

View File

@@ -12,12 +12,10 @@ 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
{
@@ -29,34 +27,22 @@ final class PersonIdentifiersType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
foreach ($this->identifierManager->getWorkers() as $k => $worker) {
foreach ($this->identifierManager->getWorkers() as $worker) {
if (!$worker->getDefinition()->isEditableByUsers()) {
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: [
'compound' => true,
'label' => $this->translatableStringHelper->localize($worker->getDefinition()->getLabel()),
'error_bubbling' => false,
]
);
$subBuilder->setDataMapper($this->identifiersDataMapper);
$worker->buildForm($subBuilder);
$builder->add($subBuilder);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('step', 'on_edit')
->setAllowedValues('step', ['on_edit', 'on_create']);
$builder->setDataMapper($this->identifiersDataMapper);
}
}

View File

@@ -21,8 +21,8 @@ use Chill\MainBundle\Form\Type\PickCivilityType;
use Chill\MainBundle\Form\Type\Select2CountryType;
use Chill\MainBundle\Form\Type\Select2LanguageType;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Actions\PersonEdit\PersonEditDTO;
use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonPhone;
use Chill\PersonBundle\Form\Type\PersonAltNameType;
use Chill\PersonBundle\Form\Type\PersonPhoneType;
@@ -242,7 +242,7 @@ class PersonType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => PersonEditDTO::class,
'data_class' => Person::class,
]);
$resolver->setRequired([

View File

@@ -32,7 +32,6 @@ class PersonAltNameType extends AbstractType
[
'label' => $label,
'required' => false,
'empty_data' => '',
]
);
}

View File

@@ -13,26 +13,20 @@ 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 self::NAME;
return 'chill-person-bundle.string-identifier';
}
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
{
return trim($value['content'] ?? '');
return $value['content'] ?? '';
}
public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void
@@ -42,37 +36,6 @@ final readonly class StringIdentifier implements PersonIdentifierEngineInterface
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
{
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;
}
public function getDefaultValue(PersonIdentifierDefinition $definition): array
{
return ['content' => ''];
return $identifier?->getValue()['content'] ?? '';
}
}

View File

@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier;
/**
* Data Transfer Object to create a ConstraintViolationListInterface.
*/
class IdentifierViolationDTO
{
public function __construct(
public string $message,
/**
* @var string an UUID
*/
public string $code,
/**
* @var array<string, string>
*/
public array $parameters = [],
public string $messageDomain = 'validators',
) {}
}

View File

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

View File

@@ -19,29 +19,9 @@ interface PersonIdentifierEngineInterface
{
public static function getName(): string;
/**
* @phpstan-pure
*/
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string;
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;
public function getDefaultValue(PersonIdentifierDefinition $definition): array;
}

View File

@@ -13,7 +13,6 @@ 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
@@ -45,16 +44,8 @@ final readonly class PersonIdentifierManager implements PersonIdentifierManagerI
return $workers;
}
public function buildWorkerByPersonIdentifierDefinition(int|PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker
public function buildWorkerByPersonIdentifierDefinition(PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker
{
if (is_int($personIdentifierDefinition)) {
$id = $personIdentifierDefinition;
$personIdentifierDefinition = $this->personIdentifierDefinitionRepository->find($id);
if (null === $personIdentifierDefinition) {
throw new PersonIdentifierDefinitionNotFoundException($id);
}
}
return new PersonIdentifierWorker($this->getEngine($personIdentifierDefinition->getEngine()), $personIdentifierDefinition);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute]
class ValidIdentifierConstraint extends Constraint
{
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}

View File

@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\PersonIdentifier\Validator;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
final class ValidIdentifierConstraintValidator extends ConstraintValidator
{
public function __construct(private readonly PersonIdentifierManagerInterface $personIdentifierManager) {}
public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof ValidIdentifierConstraint) {
throw new UnexpectedTypeException($constraint, ValidIdentifierConstraint::class);
}
if (!$value instanceof PersonIdentifier) {
throw new UnexpectedValueException($value, PersonIdentifier::class);
}
$worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($value->getDefinition());
$violations = $worker->validate($value, $value->getDefinition());
foreach ($violations as $violation) {
$this->context->buildViolation($violation->message)
->setParameters($violation->parameters)
->setParameter('{{ code }}', $violation->code)
->setParameter('definition_id', (string) $value->getDefinition()->getId())
->addViolation();
}
}
}

View File

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

View File

@@ -17,8 +17,6 @@ use Chill\MainBundle\Search\ParsingException;
use Chill\MainBundle\Search\SearchApiQuery;
use Chill\MainBundle\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;
@@ -29,13 +27,7 @@ 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,
private PersonIdentifierManagerInterface $personIdentifierManager,
) {}
public function __construct(private Security $security, private EntityManagerInterface $em, private CountryRepository $countryRepository, private AuthorizationHelperInterface $authorizationHelper) {}
public function buildAuthorizedQuery(
?string $default = null,
@@ -115,15 +107,6 @@ 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 = [];
@@ -141,53 +124,20 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor
'(starts_with(LOWER(UNACCENT(lastname)), UNACCENT(LOWER(?))))::int';
\array_push($pertinenceArgs, $str, $str, $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];
$andWhereSearchClause[] =
'(LOWER(UNACCENT(?)) <<% person.fullnamecanonical OR '.
"person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' )";
\array_push($andWhereSearchClauseArgs, $str, $str);
}
$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);
@@ -226,6 +176,14 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor
);
}
if (null !== $phonenumber) {
$query->andWhereClause(
"person.phonenumber LIKE '%' || ? || '%' OR person.mobilenumber LIKE '%' || ? || '%' OR pp.phonenumber LIKE '%' || ? || '%'",
[$phonenumber, $phonenumber, $phonenumber]
);
$query->setFromClause($query->getFromClause().' LEFT JOIN chill_person_phone pp ON pp.person_id = person.id');
}
if (null !== $city) {
$query->setFromClause($query->getFromClause().' '.
'JOIN view_chill_person_current_address vcpca ON vcpca.person_id = person.id '.

View File

@@ -10,40 +10,16 @@ import {
Scope,
Job,
PrivateCommentEmbeddable,
TranslatableString,
DateTimeWrite,
SetGender,
SetCenter,
SetCivility, Gender,
} 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";
/**
* An alternative name, as configured locally
*/
export interface AltName {
labels: TranslatableString;
key: string;
}
export interface PersonAltNameWrite {
key: string;
value: string;
}
/**
* An altname for a person
*/
export interface PersonAltName {
label: string;
/**
* will match a key in @link{AltName}
*/
key: string;
}
export interface Person {
id: number;
type: "person";
@@ -51,7 +27,7 @@ export interface Person {
textAge: string;
firstName: string;
lastName: string;
altNames: PersonAltName[];
altNames: AltName[];
suffixText: string;
current_household_address: Address | null;
birthdate: DateTime | null;
@@ -60,63 +36,11 @@ export interface Person {
phonenumber: string;
mobilenumber: string;
email: string;
gender: Gender;
gender: "woman" | "man" | "other";
centers: Center[];
civility: Civility | null;
current_household_id: number;
current_residential_addresses: ResidentialAddress[];
/**
* The person id as configured by the user
*/
personId: string;
identifiers: PersonIdentifier[];
}
export interface PersonIdentifier {
id: number;
type: "person_identifier";
value: object;
definition: PersonIdentifierDefinition;
}
export interface PersonIdentifierDefinition {
id: number;
type: "person_identifier_definition";
engine: string;
}
export interface ResidentialAddress {
address: Address | null;
endDate: DateTime | null;
hostPerson: Person | null;
hostThirdParty: Thirdparty | null;
startDate: DateTime | null;
}
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: PersonAltNameWrite[];
addressId: 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[];
current_residential_addresses: Address[];
}
export interface AccompanyingPeriod {
@@ -405,50 +329,22 @@ export interface AccompanyingPeriodWorkEvaluationDocument {
workflows: object[];
}
/**
* Entity types that a user can create through AddPersons component
*/
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);
export function isEntityHousehold(e: Entities): e is Household {
return e.type === "household";
}
export type Entities = (UserGroup | User | Person | Thirdparty | Household) & {
address?: Address | null;
kind?: string;
text?: string;
profession?: string;
};
export type EntitiesOrMe = "me" | Entities;
// Type guards to discriminate Suggestions by their result kind
export function isSuggestionForUserGroup(s: Suggestion): s is Suggestion & { result: UserGroup } {
return (s as any)?.result?.type === "user_group";
}
export function isSuggestionForUser(s: Suggestion): s is Suggestion & { result: User } {
return (s as any)?.result?.type === "user";
}
export function isSuggestionForPerson(s: Suggestion): s is Suggestion & { result: Person } {
return (s as any)?.result?.type === "person";
}
export function isSuggestionForThirdParty(s: Suggestion): s is Suggestion & { result: Thirdparty } {
return (s as any)?.result?.type === "thirdparty";
}
export function isSuggestionForHousehold(s: Suggestion): s is Suggestion & { result: Household } {
return (s as any)?.result?.type === "household";
}
export type AddPersonResult = Entities & {
parent?: Entities | null;
};
@@ -456,8 +352,9 @@ export type AddPersonResult = Entities & {
export interface Suggestion {
key: string;
relevance: number;
result: Entities;
result: AddPersonResult;
}
export interface SearchPagination {
first: number;
items_per_page: number;
@@ -469,13 +366,12 @@ export interface SearchPagination {
export interface Search {
count: number;
pagination: SearchPagination;
results: {relevance: number, result: Entities}[];
results: Suggestion[];
}
export interface SearchOptions {
uniq: boolean;
/** @deprecated */
type: EntityType[];
type: string[];
priority: number | null;
button: {
size: string;
@@ -485,17 +381,6 @@ export interface SearchOptions {
};
}
type PersonIdentifierPresence = 'NOT_EDITABLE' | 'ON_EDIT' | 'ON_CREATION' | 'REQUIRED';
export interface PersonIdentifierWorker {
type: "person_identifier_worker";
definition_id: number;
engine: string;
label: TranslatableString;
isActive: boolean;
presence: PersonIdentifierPresence;
}
export class MakeFetchException extends Error {
sta: number;
txt: string;

View File

@@ -322,7 +322,7 @@ export default {
}
});
},
addNewPersons({ selected }) {
addNewPersons({ selected, modal }) {
//console.log('@@@ CLICK button addNewPersons', selected);
this.$store
.dispatch("addRequestor", selected.shift())
@@ -337,6 +337,7 @@ export default {
});
this.$refs.addPersons.resetSearch(); // to cast child method
modal.showModal = false;
},
saveFormOnTheFly(payload) {
console.log(

View File

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

View File

@@ -1,116 +0,0 @@
import { fetchResults, makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import {Center, Civility, Gender, SetCenter} from "ChillMainAssets/types";
import {
AltName,
Person, PersonIdentifier,
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 personToWritePerson = (person: Person): PersonWrite => {
return {
type: "person",
firstName: person.firstName,
lastName: person.lastName,
altNames: person.altNames.map((altName) => ({key: altName.key, value: altName.label})),
addressId: null,
birthdate: null === person.birthdate ? null : {datetime: person.birthdate.datetime8601},
deathdate: null === person.deathdate ? null : {datetime: person.deathdate.datetime8601},
phonenumber: person.phonenumber,
mobilenumber: person.mobilenumber,
center: null === person.centers ? null : person.centers
.map((center): SetCenter => ({id: center.id, type: "center"}))
.find(() => true) || null,
email: person.email,
civility: null === person.civility ? null : {id: person.civility.id, type: "chill_main_civility"},
gender: null === person.gender ? null : {id: person.gender.id, type: "chill_main_gender"},
identifiers: person.identifiers.map((identifier: PersonIdentifier) => ({type: "person_identifier", definition_id: identifier.definition.id, value: identifier.value})),
}
}
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,
);
};
export const editPerson = async (person: PersonWrite, personId: number): Promise<Person> => {
return makeFetch<PersonWrite, Person, WritePersonViolationMap>(
"PATCH",
`/api/1.0/person/person/${personId}.json`,
person,
);
}

View File

@@ -3,242 +3,487 @@
class="btn"
:class="getClassButton"
:title="buttonTitle"
@click="openModalChoose"
@click="openModal"
>
<span v-if="displayTextButton">{{ buttonTitle }}</span>
</a>
<person-choose-modal
v-if="showModalChoose"
ref="personChooseModal"
:modal-title="modalTitle"
:options="options"
:selected="selected"
:modal-dialog-class="'modal-dialog-scrollable modal-xl'"
:allow-create="props.allowCreate"
@close="closeModalChoose"
@onPickEntities="onPickEntities"
@onAskForCreate="onAskForCreate"
@triggerAddContact="triggerAddContact"
@updateSelected="updateSelected"
@cleanSelected="emptySelected"
/>
<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>
<CreateModal
v-if="creatableEntityTypes.length > 0 && showModalCreate && null == thirdPartyParentAddContact"
action="create"
:allowed-types="creatableEntityTypes"
:query="query"
:parent="null"
modalTitle="test"
@close="closeModalCreate"
@onPersonCreated="onPersonCreated"
@onThirdPartyCreated="onThirdPartyCreated"
></CreateModal>
<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>
<CreateModal
v-if="showModalCreate && thirdPartyParentAddContact !== null"
:allowed-types="['thirdparty']"
action="addContact"
modalTitle="test"
:parent="thirdPartyParentAddContact"
:query="''"
@close="closeModalCreate"
@onPersonCreated="onPersonCreated"
@onThirdPartyCreated="onThirdPartyCreated"
></CreateModal>
<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>
</template>
<script setup lang="ts">
import {ref, computed, nextTick, useTemplateRef} from "vue";
import PersonChooseModal from "./AddPersons/PersonChooseModal.vue";
import type {
// 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 {
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";
import {Thirdparty, ThirdpartyCompany} from "../../../../../ChillThirdPartyBundle/Resources/public/types";
interface AddPersonsConfig {
suggested?: Suggestion[];
buttonTitle: string;
modalTitle: string;
options: SearchOptions;
allowCreate?: boolean;
types?: EntityType[] | undefined;
}
// Extend Result type to include optional addressId
type Result = OriginalResult & { addressId?: number };
const props = withDefaults(defineProps<AddPersonsConfig>(), {
suggested: () => [],
allowCreate: () => true,
types: () => ["person"],
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 emit =
defineEmits<{
(e: "addNewPersons", payload: { selected: Suggestion[] }): void;
}
>();
defineEmits(["addNewPersons"]);
type PersonChooseModalType = InstanceType<typeof PersonChooseModal>;
const personChooseModal = useTemplateRef<PersonChooseModalType>('personChooseModal');
const showModal = ref(false);
const modalDialogClass = ref("modal-dialog-scrollable modal-xl");
/**
* Flag to show/hide the modal "choose".
*/
const showModalChoose = ref(false);
const modal = shallowRef({
showModal: false,
modalDialogClass: "modal-dialog-scrollable modal-xl",
});
/**
* Flag to show/hide the modal "create".
*/
const showModalCreate = ref(false);
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>,
});
/**
* Store the previous search query, stored while going from "search" state to "create"
*/
const query = ref("");
const searchRef = ref<HTMLInputElement | null>(null);
const onTheFly = ref<InstanceType<typeof OnTheFly> | null>(null);
/**
* Temporarily store the thirdparty company when calling "addContact"
*/
const thirdPartyParentAddContact = ref<ThirdpartyCompany|null>(null);
/**
* Contains the selected elements.
*
* If the property option.uniq is true, this will contains only one element.
*
* Suggestion must be added/removed using the @link{addSuggestionToSelected} and @link{removeSuggestionFromSelected}
* methods.
*/
const selected = ref<Map<string, Suggestion>>(new Map());
const query = computed({
get: () => search.query,
set: (val) => setQuery(val),
});
const queryLength = computed(() => search.query.length);
const suggestedCounter = computed(() => search.suggested.length);
const selectedComputed = computed(() => search.selected);
const selectedCounter = computed(() => search.selected.length);
const getClassButton = computed(() => {
const size = props.options?.button?.size ?? "";
const type = props.options?.button?.type ?? "btn-create";
return size ? `${size} ${type}` : type;
let size = props.options?.button?.size ?? "";
let 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 creatableEntityTypes = computed<CreatableEntityType[]>(() => {
if (typeof props.options.type !== "undefined") {
return props.options.type.filter(
(e: EntityType) => e === "thirdparty" || e === "person",
);
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);
}
return props.types.filter(
(e: EntityType) => e === "thirdparty" || e === "person",
);
}
const selectedAndSuggested = computed(() => {
addPriorSuggestion();
const uniqBy = (a: Suggestion[], key: (item: Suggestion) => string) => [
...new Map(a.map((x) => [key(x), x])).values(),
];
let union = [
...new Set([
...search.suggested.slice().reverse(),
...search.selected.slice().reverse(),
]),
];
return uniqBy(union, (k: Suggestion) => k.key);
});
function onAskForCreate(payload: { query: string }): void {
query.value = payload.query;
closeModalChoose();
showModalCreate.value = true;
function openModal() {
showModal.value = true;
nextTick(() => {
if (searchRef.value) searchRef.value.focus();
});
}
function closeModal() {
showModal.value = false;
}
function openModalChoose(): void {
showModalChoose.value = true;
}
function setQuery(q: string) {
search.query = q;
function closeModalChoose(): void {
showModalChoose.value = false;
}
function closeModalCreate(): void {
if (null !== thirdPartyParentAddContact) {
thirdPartyParentAddContact.value = null;
// Clear previous search if any
if (search.currentSearchQueryController) {
search.currentSearchQueryController.abort();
search.currentSearchQueryController = null;
}
showModalCreate.value = false;
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);
}
/**
* Called by PersonSuggestion's updateSelection event, when an element is checked/unchecked
*/
function updateSelected(payload: {suggestion: Suggestion, isSelected: boolean}): void {
if (payload.isSelected) {
addSuggestionToSelected(payload.suggestion);
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) {
let suggestion = {
key: entity.type + entity.id,
relevance: 0.5,
result: entity,
};
search.priorSuggestion = suggestion;
} else {
removeSuggestionFromSelected(payload.suggestion);
search.priorSuggestion = {};
}
}
function addSuggestionToSelected(suggestion: Suggestion): void {
if (props.options.uniq) {
selected.value.clear();
}
selected.value.set(suggestion.key, suggestion);
}
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();
function removeSuggestionFromSelected(suggestion: Suggestion): void {
selected.value.delete(suggestion.key);
}
function emptySelected(): void {
selected.value = new Map();
}
function onPickEntities(): void {
const alls = Array.from(selected.value.values());
emit("addNewPersons", { selected: alls });
closeModalChoose();
}
function triggerAddContact({parent}: {parent: ThirdpartyCompany}): void {
closeModalChoose();
openModalChoose();
thirdPartyParentAddContact.value = parent;
showModalCreate.value = true;
}
function onPersonCreated(payload: { person: Person }): void {
showModalCreate.value = false;
const suggestion = {
result: payload.person,
relevance: 999999,
key: "person",
};
addSuggestionToSelected(suggestion);
if (props.options.uniq) {
emit("addNewPersons", { selected: [suggestion] });
} else {
openModalChoose();
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);
}
}
function onThirdPartyCreated(payload: {thirdParty: Thirdparty}): void {
showModalCreate.value = false;
const suggestion = {
result: payload.thirdParty,
relevance: 999999,
key: "thirdparty",
};
addSuggestionToSelected(suggestion);
if (props.options.uniq) {
emit("addNewPersons", { selected: [suggestion] });
} else {
openModalChoose();
}
}
watch(
() => props.selected,
(newSelected) => {
search.selected = newSelected;
},
{ deep: true },
);
function resetSearch(): void {
selected.value = new Map();
personChooseModal.value?.resetSearch();
}
watch(
() => props.suggested,
(newSuggested) => {
search.suggested = newSuggested;
},
{ deep: true },
);
defineExpose({resetSearch})
watch(
() => modal,
(val) => {
showModal.value = val.value.showModal;
modalDialogClass.value = val.value.modalDialogClass;
},
{ deep: true },
);
defineExpose({
resetSearch,
showModal,
});
</script>
<style lang="scss" scoped>
/* Button styles can remain here if needed */
<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>

View File

@@ -1,345 +0,0 @@
<template>
<teleport to="body">
<modal
@close="() => emit('close')"
:modal-dialog-class="modalDialogClass"
:hide-footer="false"
>
<template #header>
<h3 class="modal-title">{{ modalTitle }}</h3>
</template>
<template #body-head>
<div class="modal-body">
<div class="search">
<label class="col-form-label" style="float: right">
{{
trans(ADD_PERSONS_SUGGESTED_COUNTER, {
count: suggestedCounter,
})
}}
</label>
<input
id="search-persons"
name="query"
v-model="query"
:placeholder="trans(ADD_PERSONS_SEARCH_SOME_PERSONS)"
ref="searchRef"
/>
<i class="fa fa-search fa-lg" />
<i
class="fa fa-times"
v-if="queryLength >= 3"
@click="resetSuggestion"
/>
</div>
</div>
<div class="modal-body" v-if="checkUniq === 'checkbox'">
<div class="count">
<span>
<a v-if="suggestedCounter > 2" @click="selectAll">
{{ trans(ACTION_CHECK_ALL) }}
</a>
<a v-if="selectedCounter > 0" @click="resetSelection">
<i v-if="suggestedCounter > 2"> </i>
{{ trans(ACTION_RESET) }}
</a>
</span>
<span v-if="selectedCounter > 0">
{{
trans(ADD_PERSONS_SELECTED_COUNTER, { count: selectedCounter })
}}
</span>
</div>
</div>
</template>
<template #body>
<div class="results">
<person-suggestion
v-for="item in selectedAndSuggested"
:key="item.key"
:item="item"
:isSelected="item.isSelected"
:type="checkUniq"
@update-selected="(payload) => emit('updateSelected', payload)"
@trigger-add-contact="triggerAddContact"
/>
<div v-if="hasNoResult">
<div class="noResult chill-no-data-statement">
{{
trans(ADD_PERSONS_SUGGESTED_COUNTER, {
count: suggestedCounter,
})
}}
</div>
</div>
<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 { searchEntities } from "ChillPersonAssets/vuejs/_api/AddPersons";
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,
SearchOptions,
Entities,
} from "ChillPersonAssets/types";
import {ThirdpartyCompany} from "../../../../../../ChillThirdPartyBundle/Resources/public/types";
interface Props {
modalTitle: string;
options: SearchOptions;
suggested?: Suggestion[];
selected: Map<string, Suggestion>;
modalDialogClass?: string;
allowCreate?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
suggested: () => [],
modalDialogClass: "modal-dialog-scrollable modal-xl",
allowCreate: () => true,
});
const emit = defineEmits<{
(e: "close"): void;
(e: "onPickEntities"): void;
(e: "onAskForCreate", payload: { query: string }): void;
(e: "triggerAddContact", payload: { parent: ThirdpartyCompany }): void;
(e: "updateSelected", payload: {suggestion: Suggestion, isSelected: boolean}): void;
(e: "cleanSelected"): void;
}>();
const searchRef = ref<HTMLInputElement | 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,
priorSuggestion: {} as Partial<Suggestion>,
hasPreviousQuery: false,
});
/**
* Contains the suggested entities from the search results.
*
* In other words, those entities are displayed and selectable by the user
*/
const suggested = ref<Suggestion[]>([]);
const query = computed({
get: () => search.query,
set: (val: string) => setQuery(val),
});
const queryLength = computed(() => search.query.length);
const suggestedCounter = computed(() => suggested.value.length);
const selectedCounter = computed(() => props.selected.size);
const checkUniq = computed(() =>
props.options.uniq ? "radio" : "checkbox",
);
const selectedAndSuggested = computed<(Suggestion & {isSelected: boolean})[]>(() => {
const selectedAndSuggested = [];
// add selected that are not in the search results
for (const selected of props.selected.values()) {
if (!suggested.value.some((s: Suggestion) => s.key === selected.key)) {
selectedAndSuggested.push({...selected, isSelected: false});
}
}
for (const suggestion of suggested.value) {
selectedAndSuggested.push({...suggestion, isSelected: props.selected.has(suggestion.key)})
}
return selectedAndSuggested;
});
const hasNoResult = computed(() => search.hasPreviousQuery && suggested.value.length === 0);
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);
search.hasPreviousQuery = true;
})
.catch((error: DOMException) => {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
throw error;
});
}, delay);
}
function loadSuggestions(suggestedArr: {relevance: number, result: Entities}[]): void {
suggested.value = suggestedArr.map((item) => {
return {
key: item.result.type + item.result.id,
relevance: item.relevance,
result: item.result
}
});
}
function resetSuggestion() {
search.query = "";
suggested.value = [];
}
function resetSelection() {
emit("cleanSelected");
}
function resetSearch() {
resetSelection();
resetSuggestion();
}
function selectAll() {
suggested.value.forEach((suggestion: Suggestion) => {
emit("updateSelected", {suggestion, isSelected: true})
});
}
function triggerAddContact(payload: {parent: ThirdpartyCompany}) {
emit("triggerAddContact", payload);
}
/**
* Triggered when the user clicks on the "add" button.
*/
function pickEntities(): void {
emit("onPickEntities", );
search.query = "";
emit("close");
}
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 > button {
margin-top: 0.5em;
margin-left: 0.6em;
}
.noResult {
text-align: center;
margin: 2em;
font-size: large;
}
</style>

View File

@@ -1,38 +1,39 @@
<template>
<div class="list-item" :class="{ checked: props.isSelected }">
<div class="list-item" :class="{ checked: isChecked }">
<label>
<div>
<input
:type="type"
:value="props.item.key"
v-model="selected"
name="item"
:id="props.item.key"
:checked="props.isSelected"
@click="onUpdateValue"
:id="item.key"
:value="setValueByType(item, type)"
/>
</div>
<suggestion-person
v-if="isSuggestionForPerson(item)"
v-if="item.result.type === 'person'"
:item="item"
></suggestion-person>
<suggestion-third-party
v-if="isSuggestionForThirdParty(item)"
@trigger-add-contact="triggerAddContact"
v-if="item.result.type === 'thirdparty'"
@newPriorSuggestion="newPriorSuggestion"
:item="item"
></suggestion-third-party>
<suggestion-user
v-if="isSuggestionForUser(item)"
v-if="item.result.type === 'user'"
:item="item"
></suggestion-user>
<suggestion-user-group
v-if="isSuggestionForUserGroup(item)"
v-if="item.result.type === 'user_group'"
:item="item"
></suggestion-user-group>
<suggestion-household
v-if="isSuggestionForHousehold(item)"
v-if="item.result.type === 'household'"
:item="item"
></suggestion-household>
</label>
@@ -50,38 +51,31 @@ import SuggestionHousehold from "./TypeHousehold.vue";
import SuggestionUserGroup from "./TypeUserGroup.vue";
// Types
import {
isSuggestionForHousehold,
isSuggestionForPerson,
isSuggestionForThirdParty, isSuggestionForUser,
isSuggestionForUserGroup,
Suggestion
} from "ChillPersonAssets/types";
import {ThirdpartyCompany} from "../../../../../../ChillThirdPartyBundle/Resources/public/types";
import { Result, Suggestion } from "ChillPersonAssets/types";
const props = defineProps<{
item: Suggestion;
isSelected: boolean;
type: "radio"|"checkbox";
}>();
const emit = defineEmits<{
(e: "updateSelected", payload: {suggestion: Suggestion, isSelected: boolean}): void;
(e: "triggerAddContact", payload: { parent: ThirdpartyCompany }): void;
search: { selected: Suggestion[] };
type: string;
}>();
const emit = defineEmits(["updateSelected", "newPriorSuggestion"]);
const isChecked = computed<boolean>(() => props.isSelected)
// v-model for selected
const selected = computed({
get: () => props.search.selected,
set: (value) => emit("updateSelected", value),
});
const onUpdateValue = (event: Event) => {
const target = event?.target;
if (!(target instanceof HTMLInputElement)) {
console.error("the value of checked is not an HTMLInputElement");
return;
}
emit("updateSelected", {suggestion: props.item, isSelected: props.type === "radio" ? true : target.checked});
const isChecked = computed(
() => props.search.selected.indexOf(props.item) !== -1,
);
function setValueByType(value: Suggestion, type: string) {
return type === "radio" ? [value] : value;
}
function triggerAddContact(payload: {parent: ThirdpartyCompany}) {
emit("triggerAddContact", payload);
function newPriorSuggestion(response: Result) {
emit("newPriorSuggestion", response);
}
</script>

View File

@@ -16,10 +16,9 @@ import { defineProps } from "vue";
import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue";
import HouseholdRenderBox from "ChillPersonAssets/vuejs/_components/Entity/HouseholdRenderBox.vue";
import { Suggestion } from "ChillPersonAssets/types";
import {Household} from "ChillMainAssets/types";
interface TypeHouseholdProps {
item: Suggestion & {result: Household};
item: Suggestion;
}
defineProps<TypeHouseholdProps>();

View File

@@ -23,7 +23,7 @@ import { computed, defineProps } from "vue";
import OnTheFly from "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue";
import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue";
import PersonText from "ChillPersonAssets/vuejs/_components/Entity/PersonText.vue";
import {Person, Suggestion} from "ChillPersonAssets/types";
import { Person } from "ChillPersonAssets/types";
function formatDate(dateString: string | undefined, format: string) {
if (!dateString) return "";
@@ -36,7 +36,9 @@ function formatDate(dateString: string | undefined, format: string) {
}
const props = defineProps<{
item: Suggestion & { result: Person },
item: {
result: Person; // add other fields as needed
};
}>();
const hasBirthdate = computed(() => props.item.result.birthdate !== null);

View File

@@ -1,7 +1,7 @@
<template>
<div class="container tpartycontainer">
<div class="tparty-identification">
<span v-if="(isThirdpartyChild(item.result) || isThirdpartyContact(item.result)) && item.result.profession" class="profession">{{
<span v-if="item.result.profession" class="profession">{{
item.result.profession
}}</span>
<span class="name"> {{ item.result.text }}&nbsp; </span>
@@ -12,18 +12,20 @@
</template>
</span>
</div>
<div class="tpartyparent" v-if="isThirdpartyChild(item.result) && null !== item.result.parent">
<span class="name"> &gt; {{ item.result.parent.text }} </span>
<div class="tpartyparent" v-if="hasParent">
<span class="name"> &gt; {{ item.result.parent?.text }} </span>
</div>
</div>
<div class="right_actions">
<badge-entity :entity="item.result" :options="{ displayLong: true }" />
<a
v-if="item.result.type === 'thirdparty' && item.result.kind === 'company'"
class="btn btn-tpchild"
@click="emit('triggerAddContact', {parent: item.result})"
><i class="bi bi-person-fill-add"></i></a>
<on-the-fly
v-if="item.result.kind === 'company'"
:parent="item.result"
@save-form-on-the-fly="saveFormOnTheFly"
action="addContact"
ref="onTheFly"
/>
<on-the-fly type="thirdparty" :id="item.result.id" action="show" />
</div>
</template>
@@ -35,19 +37,15 @@ import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import { useToast } from "vue-toast-notification";
import { Result, Suggestion } from "ChillPersonAssets/types";
import {
isThirdpartyChild,
isThirdpartyContact,
Thirdparty, ThirdpartyCompany
} from "./../../../../../../ChillThirdPartyBundle/Resources/public/types";
import { Thirdparty } from "src/Bundle/ChillThirdPartyBundle/Resources/public/types";
interface TypeThirdPartyProps {
item: Suggestion & {result: Thirdparty};
item: Suggestion;
}
const props = defineProps<TypeThirdPartyProps>();
const emit = defineEmits<(e: "triggerAddContact", payload: {parent: ThirdpartyCompany}) => void>();
const emit = defineEmits<(e: "newPriorSuggestion", payload: unknown) => void>();
const onTheFly = ref<InstanceType<typeof OnTheFly> | null>(null);
const toast = useToast();
@@ -56,23 +54,47 @@ const hasAddress = computed(() => {
if (props.item.result.address !== null) {
return true;
}
if (isThirdpartyChild(props.item.result) && props.item.result.parent !== null) {
return props.item.result.parent.address !== null;
if (props.item.result.parent !== null) {
if (props.item.result.parent) {
return props.item.result.parent.address !== null;
}
}
return false;
});
const hasParent = computed(() => {
return props.item.result.parent !== null;
});
const getAddress = computed(() => {
if (props.item.result.address !== null) {
return props.item.result.address;
}
if (isThirdpartyChild(props.item.result) && props.item.result.parent !== null && props.item.result.parent.address !== null) {
if (props.item.result.parent && props.item.result.parent.address !== null) {
return props.item.result.parent.address;
}
return null;
});
function saveFormOnTheFly({ data }: { data: Thirdparty }) {
makeFetch("POST", "/api/1.0/thirdparty/thirdparty.json", data)
.then((response: unknown) => {
const result = response as Result;
emit("newPriorSuggestion", result);
if (onTheFly.value) onTheFly.value.closeModal();
})
.catch((error: unknown) => {
const errorResponse = error as { name: string; violations: string[] };
if (errorResponse.name === "ValidationException") {
for (let v of errorResponse.violations) {
if (toast) toast.open({ message: v });
}
} else {
if (toast) toast.open({ message: "An error occurred" });
}
});
}
// i18n config (if needed elsewhere)
const i18n = {
messages: {

View File

@@ -1,11 +1,11 @@
<template>
<div class="container usercontainer">
<div class="user-identification">
<UserRenderBoxBadge :user="props.item.result" />
<UserRenderBoxBadge :user="item.result" />
</div>
</div>
<div class="right_actions">
<BadgeEntity :entity="props.item.result" :options="{ displayLong: true }" />
<BadgeEntity :entity="item.result" :options="{ displayLong: true }" />
</div>
</template>
@@ -14,14 +14,18 @@ import { computed, defineProps } from "vue";
import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue";
import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue";
import { Suggestion } from "ChillPersonAssets/types";
import {User} from "ChillMainAssets/types";
interface TypeUserProps {
item: Suggestion & {result: User};
item: Suggestion;
}
const props = defineProps<TypeUserProps>();
const hasParent = computed(() => props.item.result.parent !== null);
defineExpose({
hasParent,
});
</script>
<style lang="scss" scoped>

View File

@@ -15,7 +15,7 @@ import UserGroupRenderBox from "ChillMainAssets/vuejs/_components/Entity/UserGro
import { Suggestion } from "ChillPersonAssets/types";
interface TypeUserGroupProps {
item: Suggestion & {result: UserGroup};
item: Suggestion;
}
const props = defineProps<TypeUserGroupProps>();

View File

@@ -5,16 +5,46 @@
<div class="item-col">
<div class="entity-label">
<div :class="'denomination h' + options.hLevel">
<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>
<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"
>&nbsp;{{ 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"
>&nbsp;{{ 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
>
<badge-entity
v-if="options.addEntity === true"
:entity="person"
@@ -22,36 +52,61 @@
/>
</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"
/> <span
v-if="person.birthdate"
/>
<time
v-if="person.birthdate && !person.deathdate"
:datetime="person.birthdate"
:title="birthdate"
>
{{ trans(RENDERBOX_BIRTHDAY_STATEMENT, {gender: toGenderTranslation(person.gender), birthdate: ISOToDate(person.birthdate?.datetime)}) }}
</span>
{{
trans(birthdateTranslation) +
" " +
new Intl.DateTimeFormat("fr-FR", {
dateStyle: "long",
}).format(birthdate)
}}
</time>
<time
v-else-if="person.birthdate && person.deathdate"
:datetime="person.deathdate"
:title="person.deathdate"
>
{{
new Intl.DateTimeFormat("fr-FR", {
dateStyle: "long",
}).format(birthdate)
}}
-
{{
new Intl.DateTimeFormat("fr-FR", {
dateStyle: "long",
}).format(deathdate)
}}
</time>
<time
v-else-if="person.deathdate"
:datetime="person.deathdate"
:title="person.deathdate"
>
{{
trans(RENDERBOX_DEATHDATE) +
" " +
new Intl.DateTimeFormat("fr-FR", {
dateStyle: "long",
}).format(deathdate)
}}
</time>
<span v-if="options.addAge && person.birthdate" class="age">
({{ 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>
@@ -59,11 +114,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"
@@ -75,6 +130,11 @@
<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
@@ -84,7 +144,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>
@@ -100,7 +160,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) }}:
@@ -120,10 +180,9 @@
:person="addr.hostPerson"
/>
</span>
<address-render-box
v-if="addr.hostPerson?.current_household_address"
:address="addr.hostPerson.current_household_address"
v-if="addr.hostPerson.address"
:address="addr.hostPerson.address"
:is-multiline="isMultiline"
/>
</div>
@@ -145,36 +204,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>
@@ -187,25 +246,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>
@@ -219,11 +278,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
@@ -231,18 +290,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 lang="ts">
import {computed} from "vue";
import {ISOToDate} from "ChillMainAssets/chill/js/date";
<script setup>
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";
@@ -252,70 +311,108 @@ import {
trans,
RENDERBOX_HOLDER,
RENDERBOX_NO_DATA,
RENDERBOX_DEATHDATE_STATEMENT,
RENDERBOX_DEATHDATE,
RENDERBOX_HOUSEHOLD_WITHOUT_ADDRESS,
RENDERBOX_RESIDENTIAL_ADDRESS,
RENDERBOX_LOCATED_AT,
RENDERBOX_BIRTHDAY_STATEMENT,
// PERSONS_ASSOCIATED_SHOW_HOUSEHOLD_NUMBER,
RENDERBOX_BIRTHDAY_MAN,
RENDERBOX_BIRTHDAY_WOMAN,
RENDERBOX_BIRTHDAY_UNKNOWN,
RENDERBOX_BIRTHDAY_NEUTRAL,
PERSONS_ASSOCIATED_SHOW_HOUSEHOLD_NUMBER,
RENDERBOX_YEARS_OLD,
} from "translator";
import {Person} from "ChillPersonAssets/types";
import {toGenderTranslation} from "ChillMainAssets/lib/api/genderHelper";
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;
}
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,
entityDisplayLong: true,
addCenter: true,
addNoData: true,
isMultiline: true,
isHolder: false,
addHouseholdLink: true
}),
const props = defineProps({
person: {
required: true,
},
options: {
type: Object,
required: false,
},
render: {
type: String,
},
returnPath: {
type: String,
},
showResidentialAddresses: {
type: Boolean,
default: false,
},
});
const isMultiline = computed<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;
}
});
const isMultiline = computed(() => {
return props.options?.isMultiline || false;
});
const getUrl = computed<string>(() => {
const birthdate = computed(() => {
if (
props.person.birthdate !== null &&
props.person.birthdate !== undefined &&
props.person.birthdate.datetime
) {
return ISOToDate(props.person.birthdate.datetime);
} else {
return "";
}
});
const deathdate = computed(() => {
if (
props.person.deathdate !== null &&
props.person.deathdate !== undefined &&
props.person.deathdate.datetime
) {
return new Date(props.person.deathdate.datetime);
} else {
return "";
}
});
const altNameLabel = computed(() => {
let altNameLabel = "";
(props.person.altNames || []).forEach(
(altName) => (altNameLabel += altName.label),
);
return altNameLabel;
});
const altNameKey = computed(() => {
let altNameKey = "";
(props.person.altNames || []).forEach(
(altName) => (altNameKey += altName.key),
);
return altNameKey;
});
const getUrl = computed(() => {
return `/fr/person/${props.person.id}/general`;
});
const getCurrentHouseholdUrl = computed<string>(() => {
const returnPath = props.returnPath ? `?returnPath=${props.returnPath}` : ``;
const getCurrentHouseholdUrl = computed(() => {
let returnPath = props.returnPath ? `?returnPath=${props.returnPath}` : ``;
return `/fr/person/household/${props.person.current_household_id}/summary${returnPath}`;
});
</script>

View File

@@ -1,7 +1,13 @@
<template>
<span v-if="isCut">{{ cutText }}</span>
<span v-else class="person-text">
<span>{{ person.text }}</span>
<span class="firstname">{{ person.firstName }}</span>
<span class="lastname">&nbsp;{{ person.lastName }}</span>
<span v-if="person.altNames && person.altNames.length > 0" class="altnames">
<span :class="'altname altname-' + altNameKey"
>&nbsp;({{ altNameLabel }})</span
>
</span>
<span v-if="person.suffixText" class="suffixtext"
>&nbsp;{{ person.suffixText }}</span
>
@@ -27,6 +33,16 @@ 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 ? "…" : "";

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="action === 'show' && person !== null">
<div v-if="action === 'show'">
<div class="flex-table">
<person-render-box
render="bloc"
@@ -21,31 +21,446 @@
</div>
</div>
<div v-else-if="props.action === 'edit' || props.action === 'create'">
<PersonEdit
:id="props.id"
:type="props.type"
:action="props.action"
:query="props.query"
/>
<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>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { getPerson } from "../../_api/OnTheFly";
<script setup>
import { ref, reactive, computed, onMounted } from "vue";
import {
getCentersForPersonCreation,
getCivilities,
getGenders,
getPerson,
getPersonAltNames,
} from "../../_api/OnTheFly";
import PersonRenderBox from "../Entity/PersonRenderBox.vue";
import PersonEdit from "./PersonEdit.vue";
import type { Person } from "ChillPersonAssets/types";
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";
interface Props {
id: number;
type?: string;
action: "show" | "edit" | "create";
query?: string;
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é");
}
}
const props = withDefaults(defineProps<Props>(), {query: ""});
function loadData() {
getPerson(props.id).then((p) => {
Object.assign(person, p);
});
}
const person = ref<Person | null>(null);
function onAltNameInput(event) {
const key = event.target.id;
const label = event.target.value;
let updateAltNames = person.altNames.filter((a) => a.key !== key);
updateAltNames.push({ key: key, label: label });
person.altNames = updateAltNames;
}
function addQueryItem(field, queryItem) {
switch (field) {
case "lastName":
person.lastName = person.lastName
? (person.lastName += ` ${queryItem}`)
: queryItem;
break;
case "firstName":
person.firstName = person.firstName
? (person.firstName += ` ${queryItem}`)
: queryItem;
break;
}
}
function submitNewAddress(payload) {
person.addressId = payload.addressId;
}
onMounted(() => {
getPersonAltNames().then((altNames) => {
config.altNames = altNames;
});
getCivilities().then((civilities) => {
if ("results" in civilities) {
config.civilities = civilities.results;
}
});
getGenders().then((genders) => {
if ("results" in genders) {
config.genders = genders.results;
}
});
if (props.action !== "create") {
loadData();
} else {
getCentersForPersonCreation().then((params) => {
config.centers = params.centers.filter((c) => c.isActive);
showCenters.value = params.showCenters;
if (showCenters.value && config.centers.length === 1) {
person.center = config.centers[0];
}
});
}
});
defineExpose(genderClass, genderTranslation, feminized, birthDate);
</script>

View File

@@ -1,695 +0,0 @@
<template>
<div v-if="action === 'create' || (action === 'edit' && dataLoaded)">
<div class="mb-3">
<div class="input-group has-validation">
<div class="form-floating">
<input
class="form-control form-control-lg"
:class="{ 'is-invalid': violations.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 violations.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': violations.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 violations.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>
<template v-if="action === 'create'">
<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>
</template>
<template v-if="action === 'create'">
<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': violations.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 violations.violationTitlesWithParameter('identifiers', 'definition_id', worker.definition_id.toString())"
class="invalid-feedback was-validated-force"
>
{{ err }}
</div>
</div>
</div>
</template>
<div class="mb-3">
<div class="input-group has-validation">
<div class="form-floating">
<select
class="form-select form-select-lg"
:class="{ 'is-invalid': violations.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 violations.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': violations.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 violations.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': violations.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 violations.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': violations.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 violations.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': violations.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 violations.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': violations.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 violations.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': violations.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 violations.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>
<div v-else>
</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, editPerson,
getCentersForPersonCreation,
getCivilities,
getGenders,
getPerson,
getPersonAltNames,
getPersonIdentifiers,
personToWritePerson,
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,
} from "ChillMainAssets/types";
import {
AltName,
Person,
PersonWrite,
PersonIdentifierWorker,
} from "ChillPersonAssets/types";
import {
isValidationException,
} from "ChillMainAssets/lib/api/apiMethods";
import {useToast} from "vue-toast-notification";
import {getTimezoneOffsetString, ISOToDate} from "ChillMainAssets/chill/js/date";
import {useViolationList} from "ChillMainAssets/vuejs/_composables/violationList";
interface PersonEditComponentConfig {
id?: number | null;
action: "edit" | "create";
query: string;
}
const props = withDefaults(defineProps<PersonEditComponentConfig>(), {
id: null,
});
const emit =
defineEmits<(e: "onPersonCreated", payload: { person: Person }) => void>();
defineExpose({ postPerson });
const toast = useToast();
const person = reactive<PersonWrite>({
type: "person",
firstName: "",
lastName: "",
altNames: [],
addressId: 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 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()));
});
const dataLoaded = ref<boolean>(false);
async function loadData() {
if (props.id !== undefined && props.id !== null) {
const p = await getPerson(props.id);
const w = personToWritePerson(p);
person.firstName = w.firstName;
person.lastName = w.lastName;
person.altNames.push(...w.altNames)
person.civility = w.civility;
person.addressId = w.addressId;
person.birthdate = w.birthdate;
person.deathdate = w.deathdate;
person.phonenumber = w.phonenumber;
person.mobilenumber = w.mobilenumber;
person.email = w.email;
person.gender = w.gender;
person.center = w.center;
person.civility = w.civility;
person.identifiers.push(...w.identifiers);
dataLoaded.value = true;
}
}
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;
}
}
const violations = useViolationList<WritePersonViolationMap>();
function submitNewAddress(payload: { addressId: number }) {
person.addressId = payload.addressId;
}
async function postPerson(): Promise<Person> {
try {
if (props.action === 'create') {
const createdPerson = await createPerson(person);
emit("onPersonCreated", { person: createdPerson });
return Promise.resolve(createdPerson);
} else if (props.id !== null) {
const updatedPerson = await editPerson(person, props.id);
emit("onPersonCreated", { person: updatedPerson });
return Promise.resolve(updatedPerson);
}
} catch (e: unknown) {
if (isValidationException<WritePersonViolationMap>(e)) {
violations.setValidationException(e);
} else {
toast.error(trans(PERSON_EDIT_ERROR_WHILE_SAVING));
}
}
throw "'action' is not create, or edit with a not-null id";
}
onMounted(() => {
getPersonAltNames().then((altNames) => {
config.altNames = altNames;
});
getCivilities().then((civilities) => {
config.civilities = civilities;
});
getGenders().then((genders) => {
config.genders = genders;
});
getPersonIdentifiers().then((identifiers) => {
config.identifiers = identifiers.filter(
(w: PersonIdentifierWorker) =>
w.presence === 'ON_CREATION' || w.presence === 'REQUIRED'
);
});
if (props.action !== "create") {
loadData();
} else {
getCentersForPersonCreation().then((params) => {
config.centers = params.centers.filter((c: Center) => c.isActive);
showCenters.value = params.showCenters;
if (showCenters.value && config.centers.length === 1) {
// if there is only one center, preselect it
person.center = {
id: config.centers[0].id,
type: config.centers[0].type ?? "center",
};
}
});
}
});
</script>
<style lang="scss" scoped>
.was-validated-force {
display: block;
}
</style>

View File

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

View File

@@ -140,7 +140,9 @@
<fieldset>
<legend><h2>{{ 'person.Identifiers'|trans }}</h2></legend>
<div>
{{ form_widget(form.identifiers) }}
{% for f in form.identifiers %}
{{ form_row(f) }}
{% endfor %}
</div>
</fieldset>
{% else %}

View File

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

View File

@@ -11,33 +11,169 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Serializer\Normalizer;
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\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait;
/**
* Serialize a Person entity.
*/
class PersonJsonNormalizer implements NormalizerAwareInterface, NormalizerInterface
class PersonJsonNormalizer implements DenormalizerAwareInterface, NormalizerAwareInterface, PersonJsonNormalizerInterface
{
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
@@ -68,8 +204,6 @@ class PersonJsonNormalizer implements NormalizerAwareInterface, NormalizerInterf
'email' => $person->getEmail(),
'gender' => $this->normalizer->normalize($person->getGender(), $format, $context),
'civility' => $this->normalizer->normalize($person->getCivility(), $format, $context),
'personId' => $this->personIdRendering->renderPersonId($person),
'identifiers' => $this->normalizer->normalize($person->getIdentifiers(), $format, $context),
];
if (\in_array('minimal', $groups, true) && 1 === \count($groups)) {
@@ -81,6 +215,11 @@ class PersonJsonNormalizer implements NormalizerAwareInterface, NormalizerInterf
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;

View File

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

View File

@@ -1,146 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Action\PersonEdit\Service;
use Chill\MainBundle\Entity\Civility;
use Chill\MainBundle\Entity\Country;
use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable;
use Chill\MainBundle\Entity\Gender;
use Chill\MainBundle\Entity\Language;
use Chill\PersonBundle\Actions\PersonEdit\PersonEditDTO;
use Chill\PersonBundle\Actions\PersonEdit\Service\PersonEditDTOFactory;
use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper;
use Chill\PersonBundle\Entity\AdministrativeStatus;
use Chill\PersonBundle\Entity\EmploymentStatus;
use Chill\PersonBundle\Entity\MaritalStatus;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonAltName;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Doctrine\Common\Collections\ArrayCollection;
use libphonenumber\PhoneNumber;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
/**
* @internal
*
* @coversNothing
*/
class PersonEditDTOFactoryTest extends TestCase
{
use ProphecyTrait;
public function testMapPersonEditDTOtoPersonCopiesAllFields(): void
{
$configHelper = $this->createMock(ConfigPersonAltNamesHelper::class);
$identifierManager = $this->createMock(PersonIdentifierManagerInterface::class);
$factory = new PersonEditDTOFactory($configHelper, $identifierManager);
$dto = new PersonEditDTO();
$dto->firstName = 'John';
$dto->lastName = 'Doe';
$dto->birthdate = new \DateTime('1980-05-10');
$dto->deathdate = new \DateTimeImmutable('2050-01-01');
$dto->gender = new Gender();
$dto->genderComment = new CommentEmbeddable('gender comment');
$dto->numberOfChildren = 2;
$dto->memo = 'Some memo';
$dto->employmentStatus = new EmploymentStatus();
$dto->administrativeStatus = new AdministrativeStatus();
$dto->placeOfBirth = 'Cityville';
$dto->contactInfo = 'Some contact info';
$phone = new PhoneNumber();
$dto->phonenumber = $phone;
$mobile = new PhoneNumber();
$dto->mobilenumber = $mobile;
$dto->acceptSms = true;
$dto->otherPhonenumbers = new ArrayCollection();
$dto->email = 'john.doe@example.org';
$dto->acceptEmail = true;
$dto->countryOfBirth = new Country();
$dto->nationality = new Country();
$dto->spokenLanguages = new ArrayCollection([new Language()]);
$dto->civility = new Civility();
$dto->maritalStatus = new MaritalStatus();
$dto->maritalStatusDate = new \DateTime('2010-01-01');
$dto->maritalStatusComment = new CommentEmbeddable('married');
$dto->cFData = ['foo' => 'bar'];
$person = new Person();
$factory->mapPersonEditDTOtoPerson($dto, $person);
self::assertSame('John', $person->getFirstName());
self::assertSame('Doe', $person->getLastName());
self::assertSame($dto->birthdate, $person->getBirthdate());
self::assertSame($dto->deathdate, $person->getDeathdate());
self::assertSame($dto->gender, $person->getGender());
self::assertSame($dto->genderComment, $person->getGenderComment());
self::assertSame($dto->numberOfChildren, $person->getNumberOfChildren());
self::assertSame('Some memo', $person->getMemo());
self::assertSame($dto->employmentStatus, $person->getEmploymentStatus());
self::assertSame($dto->administrativeStatus, $person->getAdministrativeStatus());
self::assertSame('Cityville', $person->getPlaceOfBirth());
self::assertSame('Some contact info', $person->getcontactInfo());
self::assertSame($phone, $person->getPhonenumber());
self::assertSame($mobile, $person->getMobilenumber());
self::assertTrue($person->getAcceptSMS());
self::assertSame($dto->otherPhonenumbers, $person->getOtherPhoneNumbers());
self::assertSame('john.doe@example.org', $person->getEmail());
self::assertTrue($person->getAcceptEmail());
self::assertSame($dto->countryOfBirth, $person->getCountryOfBirth());
self::assertSame($dto->nationality, $person->getNationality());
self::assertSame($dto->spokenLanguages, $person->getSpokenLanguages());
self::assertSame($dto->civility, $person->getCivility());
self::assertSame($dto->maritalStatus, $person->getMaritalStatus());
self::assertSame($dto->maritalStatusDate, $person->getMaritalStatusDate());
self::assertSame($dto->maritalStatusComment, $person->getMaritalStatusComment());
self::assertSame($dto->cFData, $person->getCFData());
}
public function testAltNamesHandlingWithConfigHelper(): void
{
$configHelper = $this->createMock(ConfigPersonAltNamesHelper::class);
$configHelper->method('getChoices')->willReturn([
'aka' => ['en' => 'Also Known As'],
'nickname' => ['en' => 'Nickname'],
]);
$identifierManager = $this->createMock(PersonIdentifierManagerInterface::class);
$identifierManager->method('getWorkers')->willReturn([]);
$factory = new PersonEditDTOFactory($configHelper, $identifierManager);
$person = new Person();
$dto = $factory->createPersonEditDTO($person);
// Assert DTO has two altNames keys from helper
self::assertCount(2, $dto->altNames);
self::assertContainsOnlyInstancesOf(PersonAltName::class, $dto->altNames);
self::assertSame(['aka', 'nickname'], array_keys($dto->altNames));
self::assertSame(['aka' => 'aka', 'nickname' => 'nickname'], array_map(fn (PersonAltName $altName) => $altName->getKey(), $dto->altNames));
// Fill only one label and leave the other empty
$dto->altNames['aka']->setLabel('The Boss');
// 'nickname' remains empty by default
// Map DTO back to person
$factory->mapPersonEditDTOtoPerson($dto, $person);
// Assert only the filled alt name is persisted on the person
$altNames = $person->getAltNames();
self::assertCount(1, $altNames);
$altNameArray = $altNames->toArray();
self::assertSame('aka', $altNameArray[0]->getKey());
self::assertSame('The Boss', $altNameArray[0]->getLabel());
}
}

View File

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

View File

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

View File

@@ -1,133 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Tests\PersonIdentifier\Normalizer;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Chill\PersonBundle\PersonIdentifier\Normalizer\PersonIdentifierWorkerNormalizer;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
/**
* @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 '';
}
public function isEmpty(PersonIdentifier $identifier): bool
{
return false;
}
public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array
{
return [];
}
public function getDefaultValue(PersonIdentifierDefinition $definition): array
{
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 isEmpty(PersonIdentifier $identifier): bool
{
return false;
}
public function getDefaultValue(PersonIdentifierDefinition $definition): array
{
return [];
}
public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array
{
return [];
}
};
$definition = new PersonIdentifierDefinition(label: ['en' => 'SSN'], engine: 'string');
$definition->setActive(false);
$worker = new PersonIdentifierWorker($engine, $definition);
$normalizer = new PersonIdentifierWorkerNormalizer();
$normalized = $normalizer->normalize($worker);
self::assertSame([
'type' => 'person_identifier_worker',
'definition_id' => null,
'engine' => 'string',
'label' => ['en' => 'SSN'],
'isActive' => false,
'presence' => 'ON_EDIT',
], $normalized);
}
public function testNormalizeThrowsOnInvalidObject(): void
{
$normalizer = new PersonIdentifierWorkerNormalizer();
$this->expectException(UnexpectedValueException::class);
$normalizer->normalize(new \stdClass());
}
}

View File

@@ -71,21 +71,6 @@ class PersonIdRenderingTest extends TestCase
// same behavior as StringIdentifier::renderAsString
return $identifier?->getValue()['content'] ?? '';
}
public function isEmpty(PersonIdentifier $identifier): bool
{
return false;
}
public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array
{
return [];
}
public function getDefaultValue(PersonIdentifierDefinition $definition): array
{
return [];
}
};
return new PersonIdentifierWorker($engine, $definition);

View File

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

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