Compare commits

...

69 Commits

Author SHA1 Message Date
870907804b Refactor PersonCreate flow to introduce PersonCreateDTO
- Replaced `Person` entity binding with `PersonCreateDTO` in `CreationPersonType` to enable better data handling.
- Added `PersonCreateDTOFactory` for creating and mapping `PersonCreateDTO` instances.
- Extracted `newAction` logic into `PersonCreateController` for clearer separation of responsibilities.
- Updated `PersonIdentifiersDataMapper` and `PersonIdentifierWorker` to support default identifier values.
- Adjusted related services, configurations, and templates accordingly.
2025-10-21 14:24:43 +02:00
e9e6c05e3d Refactor PersonEdit flow to introduce PersonEditDTO
- Replaced `Person` entity binding with `PersonEditDTO` in `PersonType` to decouple data transfer and entity manipulation.
- Added `PersonEditDTOFactory` for creating and mapping `PersonEditDTO` instances.
- Simplified `PersonAltNameDataMapper` and `PersonIdentifiersDataMapper`.
- Updated `PersonEditController` to use `PersonEditDTO` for better separation of concerns.
- Adjusted related tests, configurations, and templates accordingly.
2025-10-21 13:22:04 +02:00
532f2dd842 Add message handler definition on PostTicketUPdateMessageHandler 2025-10-17 00:09:51 +02:00
d14d4d4d8f Merge branch 'ticket-app-master' into ticket/64-identifiants-person 2025-10-16 14:34:34 +02:00
a22cbe0239 Merge branch 'ticket/add-events-on-change' into 'ticket-app-master'
Add Events when a ticket is updated, and trigger asynchronously post update events

See merge request Chill-Projet/chill-bundles!902
2025-10-16 12:34:12 +00:00
98902bdeb8 Add Events when a ticket is updated, and trigger asynchronously post update events 2025-10-16 12:34:12 +00:00
592a0f3698 Remove FindCallerController 2025-10-16 12:36:44 +02:00
d469eb19ad Merge branch 'ticket-app-master' into ticket/64-identifiants-person 2025-10-16 10:48:59 +02:00
4765d4fe28 Merge branch '1677-create-ticket-list-for-user-file' into 'ticket-app-master'
Créer la page et la liste des tickets dans le dossier d'usager

See merge request Chill-Projet/chill-bundles!891
2025-10-15 11:06:04 +00:00
Boris Waaub
30bcb85549 Créer la page et la liste des tickets dans le dossier d'usager 2025-10-15 11:06:02 +00:00
189a9337b4 Search person by phonenumber: allow searching though identifier and phonenumber 2025-10-07 18:28:59 +02:00
c030232a73 Temporarily desactivate the search by phonenumber 2025-10-07 11:53:24 +02:00
d4f9726f90 Fix ExtractPhonenumberFromPattern to retain original subject in SearchExtractionResult
- Updated logic in `ExtractPhonenumberFromPattern` to ensure the original subject is preserved in the resulting extraction.
- Adjusted test cases in `ExtractPhonenumberFromPatternTest` to reflect the updated behavior.
2025-10-07 11:40:14 +02:00
8740025dbd Handle case where form is null in PersonIdentifiersDataMapper
- Added a null check for forms in `PersonIdentifiersDataMapper` to prevent potential errors: a form is not present at all steps (on creation / on edit)
- Skips processing if the form is not found.
2025-10-07 11:40:01 +02:00
6d8ef035ea Merge branch 'ticket-app-master' into ticket/64-identifiants-person 2025-10-07 11:28:04 +02:00
60eab628ee Clarify documentation in PersonIdentifierManagerInterface and fix formatting in SQL query
- Updated PHPDoc in `getWorkers` method to explicitly state that only active definitions are returned.
- Standardized number formatting in SQL query for better readability and consistency.
2025-10-07 11:24:17 +02:00
1fd559b722 Refactor validation of PersonIdentifier 2025-10-07 09:59:52 +02:00
b526e802d7 Add validation logic and tests for StringIdentifier
- Implemented `validate` method in `StringIdentifier` to enforce `only_numbers` and `fixed_length` constraints.
- Created `StringIdentifierValidationTest` to cover validation rules.
2025-10-06 15:16:20 +02:00
60937152c3 Add validate method to PersonIdentifierEngineInterface and related classes
- Introduced `validate` method in `PersonIdentifierEngineInterface`.
- Added `ValidIdentifierConstraint` to `PersonIdentifier` entity.
- Updated `PersonIdentifierWorker` to implement the new `validate` method.
2025-10-06 15:16:12 +02:00
6d2e78ce55 Fix parameter handling in MenuComposer and MenuTwig
- Corrected `routeParameters` assignment in `MenuComposer` for proper parameter usage.
- Adjusted `menus` and `routes` assignment order in `MenuTwig` for consistent handling.
2025-10-03 12:00:51 +02:00
e566f60a4a Apply minor UI update to PersonChooseModal.vue create button
- Added `btn btn-submit` CSS classes to the create button for improved styling.
- Removed outdated and commented-out `on-the-fly` implementation.
2025-10-01 10:51:58 +02:00
c06531cddb Merge branch 'ticket-app-master' into ticket/64-identifiants-person 2025-09-30 16:01:33 +02:00
61ca700bbe Merge branch '1682-1683-1684-fix-bug-mr-884' into 'ticket-app-master'
FIX des bugs du merge request 884

See merge request Chill-Projet/chill-bundles!885
2025-09-30 13:49:04 +00:00
Boris Waaub
b43aeebc3c FIX des bugs du merge request 884 2025-09-30 13:49:04 +00:00
056e2dcc5f Merge branch 'ticket/WP1617-motifs-hierarchiques' into 'ticket-app-master'
Support for parent/children motives

See merge request Chill-Projet/chill-bundles!886
2025-09-30 13:12:06 +00:00
e57d1ac696 Support for parent/children motives 2025-09-30 13:12:06 +00:00
4a1da25fee Merge branch 'ticket-app-master' into ticket/64-identifiants-person 2025-09-30 10:08:56 +02:00
0eff1d2e79 Merge branch 'ticket/improve-local-menu-builder' into 'ticket-app-master'
Refactor `MenuComposer` to improve type safety and simplify local menu builder integration

See merge request Chill-Projet/chill-bundles!890
2025-09-29 15:03:05 +00:00
3928b2cc7a Refactor MenuComposer to improve type safety and simplify local menu builder integration 2025-09-29 15:03:05 +00:00
02783e5391 Merge branch 'ticket-app-master' into ticket/64-identifiants-person 2025-09-29 13:21:06 +02:00
be3b9f0f56 Fix issue in PersonIdentifierDataMapper: find the PersonIdentifier against his definition 2025-09-26 15:03:02 +02:00
ee006f55d6 Add unique constraint for definition_id and person_id in PersonIdentifier
- Update entity to include the new database-level unique constraint.
- Add migration script to apply the unique constraint and handle rollback.
2025-09-26 14:44:31 +02:00
13b1c45271 Simplify and modernize entity components and translations for better performance and consistency
- Replace fragmented name rendering with unified `person.text` in Vue components.
- Migrate `GenderIconRenderBox` to use Bootstrap icons and TypeScript.
- Introduce `GenderTranslation` type and helper for gender rendering.
- Refactor `PersonRenderBox` to streamline rendering logic and improve maintainability. Migrate to typescript
- Update French translations for consistency with new gender rendering.
2025-09-26 14:25:38 +02:00
ad2b6d63ac Handle identifier uniqueness validation for same Person in UniqueIdentifierConstraintValidator
- Add logic to skip validation errors for duplicate identifiers belonging to the same Person.
- Update test case to verify no violation is raised for such duplicates.
- Refactor repository query logic to support the new validation scenario.
2025-09-26 13:55:40 +02:00
bfbde078b7 Add personId serialization to PersonJsonNormalizer
- Inject `PersonIdRenderingInterface` into `PersonJsonNormalizer` for generating `personId`.
- Update `PersonJsonNormalizer` to include `personId` in serialized output.
- Extend TypeScript definitions to support `personId` property.
- Enhance unit tests to cover `personId` serialization.
2025-09-25 15:00:11 +02:00
d42a1296c4 Add integration and unit tests for PersonJsonNormalizer to verify normalization behavior
- Introduce `PersonJsonNormalizerIntegrationTest` to test database-driven normalization scenarios.
- Expand `PersonJsonNormalizerTest` with cases covering minimal group normalization and extended keys.
- Refactor test setup to use mock objects and improve coverage of normalization logic.
2025-09-25 14:51:39 +02:00
4b7e3c1601 Restrict deletion of identifier_definition to prevent errors and ensure data integrity
- Updated foreign key constraint on `chill_person_identifier.definition_id` to use `ON DELETE RESTRICT` instead of `ON DELETE CASCADE`.
- Adjusted migration script to handle both the upgrade and downgrade paths for the new restriction.
2025-09-24 12:40:37 +02:00
6ea9af588b Replace value with canonical in PersonIdentifier unique constraint, repository logic, and tests
- Update unique constraint on `PersonIdentifier` to use `canonical` instead of `value`.
- Refactor repository method `findByDefinitionAndValue` to `findByDefinitionAndCanonical`, updating logic accordingly.
- Adjust validation logic in `UniqueIdentifierConstraintValidator` to align with the new canonical-based approach.
- Modify related integration and unit tests to support the changes.
- Inject `PersonIdentifierManagerInterface` into the repository to handle canonical value generation.
2025-09-24 12:40:16 +02:00
0fd76d3fa8 Add PersonIdentifierManagerInterface to PersonACLAwareRepository and enhance search logic for identifiers
- Inject `PersonIdentifierManagerInterface` into `PersonACLAwareRepository` for improved identifier handling.
- Update search queries to include logic for filtering and matching `PersonIdentifier` values.
- Modify test cases to support the new dependency and ensure proper coverage.
2025-09-24 00:01:28 +02:00
34af53130b Refactor and enhance ValidationException handling across types and components
- Simplify and extend type definitions in `types.ts` for dynamic and normalized keys.
- Update `ValidationExceptionInterface` to include new methods for filtering violations.
- Refactor `apiMethods.ts` to leverage updated exception types and key parsing.
- Adjust `WritePersonViolationMap` for stricter type definitions.
- Enhance `PersonEdit.vue` to use refined violation methods, improving validation error handling.
2025-09-23 21:26:12 +02:00
a1fd395868 Add unique constraint for PersonIdentifier, implement UniqueIdentifierConstraint with validation logic, and include supporting tests
- Introduce `UniqueIdentifierConstraint` and its validator for ensuring identifier uniqueness.
- Add a database-level unique constraint on `PersonIdentifier` (`definition_id`, `value`).
- Implement repository method to fetch identifiers by definition and value.
- Include integration and unit tests for validation and repository functionality.
- Update `Person` entity with `Assert\Valid` annotation for `identifiers`.
2025-09-23 12:36:30 +02:00
b8a7cbb321 Add identifiers field in CreationPersonType and handle on_create logic in PersonIdentifiersType
- Introduce `identifiers` field to `CreationPersonType` with a dedicated form type.
- Update `PersonIdentifiersType` to support `step` option (`on_create` and `on_edit`).
- Skip certain identifiers in `on_create` step based on presence configuration.
- Adjust Twig template to display `identifiers` conditionally.
2025-09-22 14:03:59 +02:00
6124eb9e34 Fix isEmpty logic in StringIdentifier: Correct boolean comparison for trimmed content. 2025-09-22 14:03:58 +02:00
a5b06de92a Refactor validation handling in PersonEdit.vue: Replace hasValidationError and validationError with hasViolation and violationTitles. Introduce hasViolationWithParameter and violationTitlesWithParameter for enhanced field validation. Update RequiredIdentifierConstraint messages, improve API error mapping, and refine ValidationException structure with violationsList. Add tests and translations for identifier validation. 2025-09-22 14:03:58 +02:00
52404956d2 Trim PersonIdentifier values during denormalization, implement RequiredIdentifierConstraint and validator, and add tests for empty value validation. 2025-09-22 14:03:57 +02:00
4207efd6bf Remove empty PersonIdentifier values during denormalization and add isEmpty logic to PersonIdentifierWorker. Include tests for empty value handling. 2025-09-22 14:03:57 +02:00
840fde4ad4 Filter PersonIdentifierWorker by presence during initialization and update type definitions. Add presence field to PersonIdentifierWorkerNormalizer. 2025-09-22 14:03:56 +02:00
3611ea2518 Refactor PersonIdentifierDefinition: Replace fully qualified \Doctrine\DBAL\Types\Types references with simplified Types aliases. 2025-09-22 14:03:55 +02:00
bbd4292cb9 Enhance PersonEdit form: Add birthdate input with validation, improve field error handling using hasValidationError, refactor birthDate to respect timezone offsets, and update translations for better user feedback. Replace DateTimeCreate with DateTimeWrite across types and components. 2025-09-22 14:03:55 +02:00
54f8c92240 Update DateNormalizer: Add return type hints for denormalize and normalize methods 2025-09-22 14:03:54 +02:00
5330befc8f eslint fixes 2025-09-22 14:03:54 +02:00
c19206be0c Enhance validation in PersonEdit: Introduce hasValidationError and validationError helpers for form inputs. Improve error feedback for fields such as firstName, lastName, gender, and others. Refactor postPerson to handle validation exceptions and map errors to specific fields. Update related methods, styles, and API error type definitions. 2025-09-22 14:03:53 +02:00
5ff374d2fa Refactor validation handling in apiMethods: Introduce strongly-typed ValidationException and ViolationFromMap. Replace generic validation logic with stricter, type-safe mappings. Update makeFetch to handle Symfony validation problems with enhanced error taxonomy. 2025-09-22 14:03:53 +02:00
4a73aaae94 Replace PhonenumberConstraint with MisdPhoneNumberConstraint across entities, deprecate outdated validation logic, and remove unused methods for improved phone number validation. 2025-09-22 14:03:53 +02:00
ff2c567d05 Update default center type fallback in PersonEdit.vue to "center" for consistency. 2025-09-22 14:03:52 +02:00
a734e84f28 Remove unused Person.vue import from types.ts for cleanup and improved code maintainability. 2025-09-22 14:03:51 +02:00
4367ed086e Enhance person creation workflow: Add onPersonCreated event handling in Create, CreateModal, and AddPersons. Update type definitions and integrate event emission for streamlined person management. 2025-09-22 14:03:51 +02:00
3227bfcd3a Remove serializer.yaml configuration, update PersonJsonNormalizer and PersonJsonDenormalizer for improved logic handling, adjust type hints in closures, and rename id to definition_id in PersonIdentifierWorkerNormalizer. 2025-09-22 14:03:50 +02:00
8d29fb260a Add validation and support for identifiers in PersonJsonDenormalizer, enhance altNames handling, and update tests for improved coverage. Adjust PersonIdentifierManager to handle identifier definitions by ID. 2025-09-22 14:03:50 +02:00
bda0743c63 Update test run guidelines to use the symfony command for executing PHPUnit tests 2025-09-22 14:03:49 +02:00
d9b730627f Introduce PersonJsonReadDenormalizer and PersonJsonDenormalizer to separate responsibilities for handling person denormalization. Add corresponding test classes for improved coverage. Refactor PersonJsonNormalizer to remove denormalization logic. 2025-09-22 14:03:49 +02:00
27548ad654 Add support for person identifiers workflow: update PersonEdit component, API methods, and modals for identifier handling during person creation. Adjust related types for improved consistency. 2025-09-22 14:03:48 +02:00
bec7297039 Add an api list of available person identifiers 2025-09-22 14:03:48 +02:00
852523e644 Refactor person management workflow: Introduce SetGender, SetCivility, and SetCenter lightweight interfaces. Replace PersonState with PersonEdit for streamlined type usage. Enhance queryItems logic and API methods for better consistency. Adjust AddPersons modal to handle query input. 2025-09-22 14:03:47 +02:00
c05d0aad47 Refactor person creation workflow: Introduce PersonEdit component and integrate it across Create, Person.vue, and modals for improved modularity. Update type definitions and API methods for consistency. 2025-09-22 14:03:47 +02:00
1c0ed9abc8 Enhance entity creation: Add CreateModal and integrate with AddPersons workflow. 2025-09-22 14:03:42 +02:00
9aed5cc216 Fix type hinting in PickEntity.vue for addNewEntity function 2025-09-22 14:03:25 +02:00
e4fe5bff68 Allow creating new entities directly from AddPersons modal 2025-09-22 14:03:25 +02:00
4c73c4d9d0 Refactor AddPersons modal into a separate PersonChooseModal component for improved modularity and reusability. 2025-09-22 14:03:24 +02:00
184 changed files with 9453 additions and 4661 deletions

View File

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

View File

@@ -1,7 +1,12 @@
import { trans, setLocale, setLocaleFallbacks } from "./ux-translator"; import {
trans,
setLocale,
getLocale,
setLocaleFallbacks,
} from "./ux-translator";
setLocaleFallbacks({"en": "fr", "nl": "fr", "fr": "en"}); setLocaleFallbacks({ en: "fr", nl: "fr", fr: "en" });
setLocale('fr'); setLocale("fr");
export { trans }; export { trans, getLocale };
export * from '../var/translations'; export * from "../var/translations";

View File

@@ -66,6 +66,7 @@ framework:
'Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage': priority 'Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage': priority
'Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage': async 'Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage': async
'Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage': async 'Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage': async
'Chill\TicketBundle\Messenger\PostTicketUpdateMessage': async
# end of routes added by chill-bundles recipes # end of routes added by chill-bundles recipes
# Route your messages to the transports # Route your messages to the transports
# 'App\Message\YourMessage': async # 'App\Message\YourMessage': async

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ namespace Chill\MainBundle;
use Chill\MainBundle\Cron\CronJobInterface; use Chill\MainBundle\Cron\CronJobInterface;
use Chill\MainBundle\CRUD\CompilerPass\CRUDControllerCompilerPass; use Chill\MainBundle\CRUD\CompilerPass\CRUDControllerCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\ACLFlagsCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\ACLFlagsCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\MenuCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\NotificationCounterCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\NotificationCounterCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompilerPass; use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompilerPass;
use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass; use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass;
@@ -70,7 +69,6 @@ class ChillMainBundle extends Bundle
$container->addCompilerPass(new TimelineCompilerClass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); $container->addCompilerPass(new TimelineCompilerClass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new WidgetsCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); $container->addCompilerPass(new WidgetsCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new NotificationCounterCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); $container->addCompilerPass(new NotificationCounterCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new MenuCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new ACLFlagsCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); $container->addCompilerPass(new ACLFlagsCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
$container->addCompilerPass(new CRUDControllerCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0); $container->addCompilerPass(new CRUDControllerCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { TranslatableString } from "ChillMainAssets/types"; import { DateTime, TranslatableString } from "ChillMainAssets/types";
import { getLocale } from "translator";
/** /**
* Localizes a translatable string object based on the current locale. * Localizes a translatable string object based on the current locale.
@@ -17,11 +18,10 @@ import { TranslatableString } from "ChillMainAssets/types";
* @returns The localized URL * @returns The localized URL
*/ */
export function localizedUrl(url: string): string { export function localizedUrl(url: string): string {
const lang = const locale = getLocale();
document.documentElement.lang || navigator.language.split("-")[0] || "fr";
// Ensure url starts with a slash and does not already start with /{lang}/ // Ensure url starts with a slash and does not already start with /{lang}/
const normalizedUrl = url.startsWith("/") ? url : `/${url}`; const normalizedUrl = url.startsWith("/") ? url : `/${url}`;
const langPrefix = `/${lang}`; const langPrefix = `/${locale}`;
if (normalizedUrl.startsWith(langPrefix + "/")) { if (normalizedUrl.startsWith(langPrefix + "/")) {
return normalizedUrl; return normalizedUrl;
} }
@@ -36,7 +36,7 @@ export function localizeString(
return ""; return "";
} }
const currentLocale = locale || navigator.language.split("-")[0] || "fr"; const currentLocale = locale || getLocale();
if (translatableString[currentLocale]) { if (translatableString[currentLocale]) {
return translatableString[currentLocale]; return translatableString[currentLocale];
@@ -59,3 +59,47 @@ export function localizeString(
return ""; return "";
} }
const datetimeFormats: Record<
string,
Record<string, Intl.DateTimeFormatOptions>
> = {
fr: {
short: {
year: "numeric",
month: "numeric",
day: "numeric",
},
text: {
year: "numeric",
month: "long",
day: "numeric",
},
long: {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: false,
},
hoursOnly: {
hour: "numeric",
minute: "numeric",
hour12: false,
},
},
};
export function localizeDateTimeFormat(
dateTime: DateTime,
format: keyof typeof datetimeFormats.fr = "short",
): string {
const locale = getLocale();
const options =
datetimeFormats[locale]?.[format] || datetimeFormats.fr[format];
return new Intl.DateTimeFormat(locale, options).format(
new Date(dateTime.datetime),
);
}
export default datetimeFormats;

View File

@@ -1,14 +1,64 @@
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc"; import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
import { CreatableEntityType } from "ChillPersonAssets/types";
export interface DateTime { export interface DateTime {
datetime: string; datetime: string;
datetime8601: string; datetime8601: string;
} }
/**
* A date representation to use when we create or update a date
*/
export interface DateTimeWrite {
/**
* Must be a string in format Y-m-d\TH:i:sO
*/
datetime: string;
}
export interface Civility { export interface Civility {
type: "chill_main_civility";
id: number;
abbreviation: TranslatableString;
active: boolean;
name: TranslatableString;
}
/**
* Lightweight reference to Civility, to use in POST or PUT requests.
*/
export interface SetCivility {
type: "chill_main_civility";
id: number;
}
/**
* Gender translation.
*
* Match the GenderEnum in PHP code.
*/
export type GenderTranslation = "male" | "female" | "neutral" | "unknown";
/**
* A gender
*
* See also
*/
export interface Gender {
type: "chill_main_gender";
id: number;
label: string;
genderTranslation: GenderTranslation;
}
/**
* Lightweight reference to a Gender, used in POST / PUT requests.
*/
export interface SetGender {
type: "chill_main_gender";
id: number; id: number;
// TODO
} }
export interface Household { export interface Household {
@@ -28,6 +78,18 @@ export interface Center {
id: number; id: number;
type: "center"; type: "center";
name: string; name: string;
isActive: boolean;
}
/**
* SetCenter is a lightweight reference used in POST/PUT requests to associate an existing center with a resource.
* It links by id only and does not create or modify centers.
* Expected shape: { type: "center", id: number }.
* Requests will fail if the id is invalid, the center doesn't exist, or permissions are insufficient.
*/
export interface SetCenter {
id: number;
type: "center";
} }
export interface Scope { export interface Scope {
@@ -226,13 +288,63 @@ export interface TransportExceptionInterface {
name: string; name: string;
} }
export interface ValidationExceptionInterface type IndexedKey<Base extends string> = `${Base}[${number}]`;
extends TransportExceptionInterface { type BaseKeys<M> = Extract<keyof M, string>;
export type DynamicKeys<M extends Record<string, Record<string, unknown>>> =
| BaseKeys<M>
| { [K in BaseKeys<M> as IndexedKey<K>]: K }[IndexedKey<BaseKeys<M>>];
type NormalizeKey<K extends string> = K extends `${infer B}[${number}]` ? B : K;
export type ViolationFromMap<M extends Record<string, Record<string, unknown>>> = {
[K in DynamicKeys<M> & string]: { // <- note le "& string" ici
propertyPath: K;
title: string;
parameters?: M[NormalizeKey<K>];
type?: string;
}
}[DynamicKeys<M> & string];
export type ValidationProblemFromMap<
M extends Record<string, Record<string, string|number>>,
> = {
type: string;
title: string;
detail?: string;
violations: ViolationFromMap<M>[];
} & Record<string, unknown>;
export interface ValidationExceptionInterface<
M extends Record<string, Record<string, string|number>> = Record<
string,
Record<string, string|number>
>,
> extends Error {
name: "ValidationException"; name: "ValidationException";
error: object; /** Full server payload copy */
problems: ValidationProblemFromMap<M>;
/** A list of all violations, with property key */
violationsList: ViolationFromMap<M>[];
/** Compact list "Title: path" */
violations: string[]; violations: string[];
/** Only titles */
titles: string[]; titles: string[];
propertyPaths: string[]; /** Only property paths */
propertyPaths: DynamicKeys<M> & string[];
/** Indexing by property (useful for display by field) */
byProperty: Record<Extract<keyof M, string>, string[]>;
violationsByNormalizedProperty(property: Extract<keyof M, string>): ViolationFromMap<M>[];
violationsByNormalizedPropertyAndParams<
P extends Extract<keyof M, string>,
K extends Extract<keyof M[P], string>
>(
property: P,
param: K,
param_value: M[P][K]
): ViolationFromMap<M>[];
} }
export interface AccessExceptionInterface extends TransportExceptionInterface { export interface AccessExceptionInterface extends TransportExceptionInterface {
@@ -300,3 +412,12 @@ export interface TabDefinition {
icon: string | null; icon: string | null;
counter: () => number; counter: () => number;
} }
/**
* Configuration for the CreateModal and Create component
*/
export interface CreateComponentConfig {
action?: string;
allowedTypes: CreatableEntityType[];
query?: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -122,7 +122,6 @@ const tabDefinitions: TabDefinition[] = [
]; ];
const displayedTabs = computed(() => { const displayedTabs = computed(() => {
// Always show MyCustoms first if present
const tabs = [] as TabDefinition[]; const tabs = [] as TabDefinition[];
for (const tabEnum of homepageConfig.value.displayTabs) { for (const tabEnum of homepageConfig.value.displayTabs) {
const def = tabDefinitions.find( const def = tabDefinitions.find(
@@ -137,10 +136,7 @@ const activeTab = ref(Number(HomepageTabs[homepageConfig.value.defaultTab]));
const loading = computed(() => store.state.loading); const loading = computed(() => store.state.loading);
function selectTab(tab: HomepageTabs) { async function selectTab(tab: HomepageTabs) {
if (tab !== HomepageTabs.MyCustoms) {
store.dispatch("getByTab", { tab: tab });
}
activeTab.value = tab; activeTab.value = tab;
} }

View File

@@ -2,7 +2,9 @@
<li> <li>
<h2>{{ props.item.title }}</h2> <h2>{{ props.item.title }}</h2>
<time class="createdBy" datetime="{{item.startDate.datetime}}">{{ <time class="createdBy" datetime="{{item.startDate.datetime}}">{{
$d(newsItemStartDate(), "text") props.item?.startDate
? localizeDateTimeFormat(props.item?.startDate, "text")
: ""
}}</time> }}</time>
<div class="content" v-if="shouldTruncate(item.content)"> <div class="content" v-if="shouldTruncate(item.content)">
<div v-html="prepareContent(item.content)"></div> <div v-html="prepareContent(item.content)"></div>
@@ -26,7 +28,9 @@
<template #body> <template #body>
<p class="news-date"> <p class="news-date">
<time class="createdBy" datetime="{{item.startDate.datetime}}">{{ <time class="createdBy" datetime="{{item.startDate.datetime}}">{{
$d(newsItemStartDate(), "text") props.item?.startDate
? localizeDateTimeFormat(props.item?.startDate, "text")
: ""
}}</time> }}</time>
</p> </p>
<div v-html="convertMarkdownToHtml(item.content)"></div> <div v-html="convertMarkdownToHtml(item.content)"></div>
@@ -42,7 +46,7 @@ import DOMPurify from "dompurify";
import { NewsItemType } from "../../../types"; import { NewsItemType } from "../../../types";
import type { PropType } from "vue"; import type { PropType } from "vue";
import { ref } from "vue"; import { ref } from "vue";
import { ISOToDatetime } from "../../../chill/js/date"; import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
const props = defineProps({ const props = defineProps({
item: { item: {
@@ -133,7 +137,7 @@ const preprocess = (markdown: string): string => {
}; };
const postprocess = (html: string): string => { const postprocess = (html: string): string => {
DOMPurify.addHook("afterSanitizeAttributes", (node: any) => { DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
if ("target" in node) { if ("target" in node) {
node.setAttribute("target", "_blank"); node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer"); node.setAttribute("rel", "noopener noreferrer");
@@ -159,10 +163,6 @@ const prepareContent = (content: string): string => {
const htmlContent = convertMarkdownToHtml(content); const htmlContent = convertMarkdownToHtml(content);
return truncateContent(htmlContent); return truncateContent(htmlContent);
}; };
const newsItemStartDate = (): null | Date => {
return ISOToDatetime(props.item?.startDate.datetime);
};
</script> </script>
<style scoped> <style scoped>

View File

@@ -21,7 +21,7 @@
</template> </template>
<template #tbody> <template #tbody>
<tr v-for="(c, i) in accompanyingCourses.results" :key="`course-${i}`"> <tr v-for="(c, i) in accompanyingCourses.results" :key="`course-${i}`">
<td>{{ $d(new Date(c.openingDate.datetime), "short") }}</td> <td>{{ localizeDateTimeFormat(c.openingDate, "short") }}</td>
<td> <td>
<span <span
v-for="(issue, index) in c.socialIssues" v-for="(issue, index) in c.socialIssues"
@@ -82,6 +82,8 @@ import {
CONFIDENTIAL, CONFIDENTIAL,
trans, trans,
} from "translator"; } from "translator";
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
const store = useStore(); const store = useStore();
const accompanyingCourses: ComputedRef<PaginationResponse<AccompanyingCourse>> = const accompanyingCourses: ComputedRef<PaginationResponse<AccompanyingCourse>> =

View File

@@ -1,62 +1,59 @@
<template> <template>
<span v-if="noResults" class="chill-no-data-statement"> <div id="dashboards" class="container g-3">
{{ trans(NO_DASHBOARD) }}
</span>
<div v-else id="dashboards" class="container g-3">
<div class="row"> <div class="row">
<div class="mbloc col-xs-12 col-sm-4"> <div class="mbloc col-xs-12 col-sm-4">
<div class="custom1"> <div class="custom1">
<ul class="list-unstyled"> <ul class="list-unstyled">
<li v-if="(counter.value?.notifications || 0) > 0"> <li v-if="counter.notifications > 0">
<span :class="counterClass"> <span :class="counterClass">
{{ {{
trans(COUNTER_UNREAD_NOTIFICATIONS, { trans(COUNTER_UNREAD_NOTIFICATIONS, {
n: counter.value?.notifications || 0, n: counter.notifications,
}) })
}} }}
</span> </span>
</li> </li>
<li v-if="(counter.value?.accompanyingCourses || 0) > 0"> <li v-if="counter.accompanyingCourses > 0">
<span :class="counterClass"> <span :class="counterClass">
{{ {{
trans(COUNTER_ASSIGNATED_COURSES, { trans(COUNTER_ASSIGNATED_COURSES, {
n: counter.value?.accompanyingCourses || 0, n: counter.accompanyingCourses,
}) })
}} }}
</span> </span>
</li> </li>
<li v-if="(counter.value?.works || 0) > 0"> <li v-if="counter.works > 0">
<span :class="counterClass"> <span :class="counterClass">
{{ {{
trans(COUNTER_ASSIGNATED_ACTIONS, { trans(COUNTER_ASSIGNATED_ACTIONS, {
n: counter.value?.works || 0, n: counter.works,
}) })
}} }}
</span> </span>
</li> </li>
<li v-if="(counter.value?.evaluations || 0) > 0"> <li v-if="counter.evaluations > 0">
<span :class="counterClass"> <span :class="counterClass">
{{ {{
trans(COUNTER_ASSIGNATED_EVALUATIONS, { trans(COUNTER_ASSIGNATED_EVALUATIONS, {
n: counter.value?.evaluations || 0, n: counter.evaluations,
}) })
}} }}
</span> </span>
</li> </li>
<li v-if="(counter.value?.tasksAlert || 0) > 0"> <li v-if="counter.tasksAlert > 0">
<span :class="counterClass"> <span :class="counterClass">
{{ {{
trans(COUNTER_ALERT_TASKS, { trans(COUNTER_ALERT_TASKS, {
n: counter.value?.tasksAlert || 0, n: counter.tasksAlert,
}) })
}} }}
</span> </span>
</li> </li>
<li v-if="(counter.value?.tasksWarning || 0) > 0"> <li v-if="counter.tasksWarning > 0">
<span :class="counterClass"> <span :class="counterClass">
{{ {{
trans(COUNTER_WARNING_TASKS, { trans(COUNTER_WARNING_TASKS, {
n: counter.value?.tasksWarning || 0, n: counter.tasksWarning,
}) })
}} }}
</span> </span>
@@ -85,7 +82,6 @@ import { useStore } from "vuex";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import News from "./DashboardWidgets/News.vue"; import News from "./DashboardWidgets/News.vue";
import { import {
NO_DASHBOARD,
COUNTER_UNREAD_NOTIFICATIONS, COUNTER_UNREAD_NOTIFICATIONS,
COUNTER_ASSIGNATED_COURSES, COUNTER_ASSIGNATED_COURSES,
COUNTER_ASSIGNATED_ACTIONS, COUNTER_ASSIGNATED_ACTIONS,
@@ -105,14 +101,19 @@ interface MyCustom {
const store = useStore(); const store = useStore();
const counter = computed(() => store.getters.counter); const counter = computed(() => ({
notifications: store.state.homepage.notifications?.count ?? 0,
accompanyingCourses: store.state.homepage.accompanyingCourses?.count ?? 0,
works: store.state.homepage.works?.count ?? 0,
evaluations: store.state.homepage.evaluations?.count ?? 0,
tasksAlert: store.state.homepage.tasksAlert?.count ?? 0,
tasksWarning: store.state.homepage.tasksWarning?.count ?? 0,
}));
const counterClass = { counter: true }; const counterClass = { counter: true };
const dashboardItems = ref<MyCustom[]>([]); const dashboardItems = ref<MyCustom[]>([]);
const noResults = computed(() => false);
const hasDashboardItems = computed(() => dashboardItems.value.length > 0); const hasDashboardItems = computed(() => dashboardItems.value.length > 0);
onMounted(async () => { onMounted(async () => {

View File

@@ -22,11 +22,7 @@
<template #tbody> <template #tbody>
<tr v-for="(e, i) in evaluations.results" :key="`evaluation-${i}`"> <tr v-for="(e, i) in evaluations.results" :key="`evaluation-${i}`">
<td> <td>
{{ {{ e.maxDate ? localizeDateTimeFormat(e.maxDate, "short") : "" }}
e.maxDate?.datetime
? $d(new Date(e.maxDate.datetime), "short")
: ""
}}
</td> </td>
<td> <td>
{{ localizeString(e.evaluation?.title ?? null) }} {{ localizeString(e.evaluation?.title ?? null) }}
@@ -115,6 +111,8 @@ import {
NO_DATA, NO_DATA,
trans, trans,
} from "translator"; } from "translator";
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
const evaluations: ComputedRef< const evaluations: ComputedRef<
PaginationResponse<AccompanyingPeriodWorkEvaluation> PaginationResponse<AccompanyingPeriodWorkEvaluation>
> = computed(() => store.state.homepage.evaluations); > = computed(() => store.state.homepage.evaluations);

View File

@@ -20,7 +20,7 @@
</template> </template>
<template #tbody> <template #tbody>
<tr v-for="(n, i) in notifications.results" :key="`notify-${i}`"> <tr v-for="(n, i) in notifications.results" :key="`notify-${i}`">
<td>{{ $d(new Date(n.date.datetime), "long") }}</td> <td>{{ localizeDateTimeFormat(n.date, "long") }}</td>
<td> <td>
<span class="unread"> <span class="unread">
<i class="fa fa-envelope-o" /> <i class="fa fa-envelope-o" />
@@ -65,6 +65,8 @@ import {
trans, trans,
} from "translator"; } from "translator";
import { PaginationResponse } from "ChillMainAssets/lib/api/apiMethods"; import { PaginationResponse } from "ChillMainAssets/lib/api/apiMethods";
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
const store = useStore(); const store = useStore();
const notifications: ComputedRef<PaginationResponse<Notification>> = computed( const notifications: ComputedRef<PaginationResponse<Notification>> = computed(

View File

@@ -21,12 +21,12 @@
<template #tbody> <template #tbody>
<tr v-for="(t, i) in tasks.alert.results" :key="`task-alert-${i}`"> <tr v-for="(t, i) in tasks.alert.results" :key="`task-alert-${i}`">
<td v-if="t.warningDate !== null"> <td v-if="t.warningDate !== null">
{{ $d(new Date(t.warningDate.datetime), "short") }} {{ localizeDateTimeFormat(t.warningDate, "short") }}
</td> </td>
<td v-else /> <td v-else />
<td> <td>
<span class="outdated">{{ <span class="outdated">{{
$d(new Date(t.endDate.datetime), "short") localizeDateTimeFormat(t.endDate, "short")
}}</span> }}</span>
</td> </td>
<td>{{ t.title }}</td> <td>{{ t.title }}</td>
@@ -62,10 +62,10 @@
<tr v-for="(t, i) in tasks.warning.results" :key="`task-warning-${i}`"> <tr v-for="(t, i) in tasks.warning.results" :key="`task-warning-${i}`">
<td> <td>
<span class="outdated">{{ <span class="outdated">{{
$d(new Date(t.warningDate.datetime), "short") localizeDateTimeFormat(t.warningDate, "short")
}}</span> }}</span>
</td> </td>
<td>{{ $d(new Date(t.endDate.datetime), "short") }}</td> <td>{{ localizeDateTimeFormat(t.endDate, "short") }}</td>
<td>{{ t.title }}</td> <td>{{ t.title }}</td>
<td> <td>
<a class="btn btn-sm btn-show" :href="getUrl(t)"> <a class="btn btn-sm btn-show" :href="getUrl(t)">
@@ -94,6 +94,7 @@ import {
} from "translator"; } from "translator";
import { TasksState } from "./store/modules/homepage"; import { TasksState } from "./store/modules/homepage";
import { Alert, Warning } from "ChillPersonAssets/types"; import { Alert, Warning } from "ChillPersonAssets/types";
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
const store = useStore(); const store = useStore();

View File

@@ -21,7 +21,7 @@
</template> </template>
<template #tbody> <template #tbody>
<tr v-for="(w, i) in works.value.results" :key="`works-${i}`"> <tr v-for="(w, i) in works.value.results" :key="`works-${i}`">
<td>{{ $d(w.startDate.datetime, "short") }}</td> <td>{{ localizeDateTimeFormat(w.startDate.datetime, "short") }}</td>
<td> <td>
<span class="chill-entity entity-social-issue"> <span class="chill-entity entity-social-issue">
<span class="badge bg-chill-l-gray text-dark"> <span class="badge bg-chill-l-gray text-dark">
@@ -90,6 +90,7 @@ import {
trans, trans,
} from "translator"; } from "translator";
import { Workflow } from "ChillPersonAssets/types"; import { Workflow } from "ChillPersonAssets/types";
import { localizeDateTimeFormat } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
const store = useStore(); const store = useStore();

View File

@@ -11,6 +11,7 @@ import {
Warning, Warning,
Workflow, Workflow,
WorflowCc, WorflowCc,
Notification,
} from "ChillPersonAssets/types"; } from "ChillPersonAssets/types";
import { RootState } from ".."; import { RootState } from "..";
import { HomepageTabs } from "ChillMainAssets/types"; import { HomepageTabs } from "ChillMainAssets/types";
@@ -191,6 +192,7 @@ export const moduleHomepage: Module<State, RootState> = {
if (!getters.isNotificationsLoaded) { if (!getters.isNotificationsLoaded) {
commit("setLoading", true); commit("setLoading", true);
const url = `/api/1.0/main/notification/my/unread${"?" + param}`; const url = `/api/1.0/main/notification/my/unread${"?" + param}`;
makeFetch("GET", url) makeFetch("GET", url)
.then((response) => { .then((response) => {
commit("addNotifications", response); commit("addNotifications", response);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,34 +1,16 @@
{#
* Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS,
<info@champs-libres.coop> / <http://www.champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
<li class="nav-item dropdown btn btn-primary nav-section"> <li class="nav-item dropdown btn btn-primary nav-section">
<a id="menu-section" <a id="menu-section"
class="nav-link dropdown-toggle" class="nav-link dropdown-toggle"
type="button" type="button"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false"> aria-expanded="false">
{{ 'Sections'|trans }} {{ 'Sections'|trans }}
</a> </a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="menu-section"> <div class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="menu-section">
{% for menu in menus %} {% for menu in menus %}
<a class="dropdown-item list-group-item bg-dark text-white" <a class="dropdown-item list-group-item bg-dark text-white"
href="{{ menu.uri }}"> href="{{ menu.uri }}">
{{ menu.label }} {{ menu.label }}
{% apply spaceless %} {% apply spaceless %}

View File

@@ -13,39 +13,37 @@ namespace Chill\MainBundle\Routing;
use Knp\Menu\FactoryInterface; use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface; use Knp\Menu\ItemInterface;
use Symfony\Component\Routing\RouteCollection; use Knp\Menu\MenuItem;
use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
/** /**
* This class permit to build menu from the routing information * This class permit to build menu from the routing information
* stored in each bundle. * stored in each bundle.
*
* how to must come here FIXME
*/ */
class MenuComposer final readonly class MenuComposer
{ {
private array $localMenuBuilders = []; public function __construct(
private RouterInterface $router,
private FactoryInterface $menuFactory,
private TranslatorInterface $translator,
/**
* @var iterable<LocalMenuBuilderInterface>
*/
private iterable $localMenuBuilders,
) {}
private RouteCollection $routeCollection; public function getMenuFor($menuId, array $parameters = []): ItemInterface
public function __construct(private readonly RouterInterface $router, private readonly FactoryInterface $menuFactory, private readonly TranslatorInterface $translator) {}
public function addLocalMenuBuilder(LocalMenuBuilderInterface $menuBuilder, $menuId)
{ {
$this->localMenuBuilders[$menuId][] = $menuBuilder; $routes = $this->getRoutesForInternal($menuId, $parameters);
} /** @var MenuItem $menu */
public function getMenuFor($menuId, array $parameters = [])
{
$routes = $this->getRoutesFor($menuId, $parameters);
$menu = $this->menuFactory->createItem($menuId); $menu = $this->menuFactory->createItem($menuId);
// build menu from routes // build menu from routes
foreach ($routes as $order => $route) { foreach ($routes as $order => $route) {
$menu->addChild($this->translator->trans($route['label']), [ $menu->addChild($this->translator->trans($route['label']), [
'route' => $route['key'], 'route' => $route['key'],
'routeParameters' => $parameters['args'], 'routeParameters' => $parameters,
'order' => $order, 'order' => $order,
]) ])
->setExtras([ ->setExtras([
@@ -55,10 +53,9 @@ class MenuComposer
]); ]);
} }
if ($this->hasLocalMenuBuilder($menuId)) { foreach ($this->localMenuBuilders as $builder) {
foreach ($this->localMenuBuilders[$menuId] as $builder) { if (in_array($menuId, $builder::getMenuIds(), true)) {
/* @var $builder LocalMenuBuilderInterface */ $builder->buildMenu($menuId, $menu, $parameters);
$builder->buildMenu($menuId, $menu, $parameters['args']);
} }
} }
@@ -71,12 +68,16 @@ class MenuComposer
* Return an array of routes added to $menuId, * Return an array of routes added to $menuId,
* The array is aimed to build route with MenuTwig. * The array is aimed to build route with MenuTwig.
* *
* @param string $menuId * @deprecated
* @param array $parameters see https://redmine.champs-libres.coop/issues/179
* *
* @return array * @param array $parameters see https://redmine.champs-libres.coop/issues/179
*/ */
public function getRoutesFor($menuId, array $parameters = []) public function getRoutesFor(string $menuId, array $parameters = []): array
{
return $this->getRoutesForInternal($menuId, $parameters);
}
private function getRoutesForInternal(string $menuId, array $parameters = []): array
{ {
$routes = []; $routes = [];
$routeCollection = $this->router->getRouteCollection(); $routeCollection = $this->router->getRouteCollection();
@@ -108,22 +109,17 @@ class MenuComposer
* should be used, or `getRouteFor`. The method `getMenuFor` should be used * should be used, or `getRouteFor`. The method `getMenuFor` should be used
* if the result is true (it **does** exists at least one menu builder. * if the result is true (it **does** exists at least one menu builder.
* *
* @param string $menuId * @deprecated
*/ */
public function hasLocalMenuBuilder($menuId): bool public function hasLocalMenuBuilder(string $menuId): bool
{ {
return \array_key_exists($menuId, $this->localMenuBuilders); foreach ($this->localMenuBuilders as $localMenuBuilder) {
} if (in_array($menuId, $localMenuBuilder::getMenuIds(), true)) {
return true;
}
}
/** return false;
* Set the route Collection
* This function is needed for testing purpose: routeCollection is not
* available as a service (RouterInterface is provided as a service and
* added to this class as paramater in __construct).
*/
public function setRouteCollection(RouteCollection $routeCollection)
{
$this->routeCollection = $routeCollection;
} }
private function reorderMenu(ItemInterface $menu) private function reorderMenu(ItemInterface $menu)

View File

@@ -18,7 +18,7 @@ use Twig\TwigFunction;
/** /**
* Add the filter 'chill_menu'. * Add the filter 'chill_menu'.
*/ */
class MenuTwig extends AbstractExtension final class MenuTwig extends AbstractExtension
{ {
/** /**
* the default parameters for chillMenu. * the default parameters for chillMenu.
@@ -43,22 +43,16 @@ class MenuTwig extends AbstractExtension
* *
* @deprecated link: see https://redmine.champs-libres.coop/issues/179 for more informations * @deprecated link: see https://redmine.champs-libres.coop/issues/179 for more informations
* *
* @param string $menuId * @param array{layout?: string, activeRouteKey?: string|null, args?: array<array-key, mixed>} $params
* @param mixed[] $params
*/ */
public function chillMenu(Environment $env, $menuId, array $params = []) public function chillMenu(Environment $env, string $menuId, array $params = []): string
{ {
$resolvedParams = array_merge($this->defaultParams, $params); $resolvedParams = array_merge($this->defaultParams, $params);
$layout = $resolvedParams['layout']; $layout = $resolvedParams['layout'];
unset($resolvedParams['layout']); unset($resolvedParams['layout']);
$resolvedParams['routes'] = $this->menuComposer->getMenuFor($menuId, $resolvedParams['args']);
if (false === $this->menuComposer->hasLocalMenuBuilder($menuId)) { $resolvedParams['menus'] = $resolvedParams['routes'];
$resolvedParams['routes'] = $this->menuComposer->getRoutesFor($menuId, $resolvedParams);
return $env->render($layout, $resolvedParams);
}
$resolvedParams['menus'] = $this->menuComposer->getMenuFor($menuId, $resolvedParams);
return $env->render($layout, $resolvedParams); return $env->render($layout, $resolvedParams);
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,44 +11,107 @@ declare(strict_types=1);
namespace Chill\MainBundle\Tests\Services; namespace Chill\MainBundle\Tests\Services;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Chill\MainBundle\Routing\MenuComposer;
use Knp\Menu\MenuFactory;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\RouteCollection;
use Symfony\Contracts\Translation\TranslatorInterface;
/** /**
* This class provide functional test for MenuComposer. * Tests for MenuComposer methods.
*
* We only verify that items provided by local menu builders are present
* when getRoutesFor() yields no routes, and that hasLocalMenuBuilder behaves
* as expected with the configured builders.
* *
* @internal * @internal
* *
* @coversNothing * @coversNothing
*/ */
final class MenuComposerTest extends KernelTestCase final class MenuComposerTest extends TestCase
{ {
/** use ProphecyTrait;
* @var \Symfony\Bundle\FrameworkBundle\Routing\DelegatingLoader;
*/
private $loader;
/** private function buildMenuComposerWithDefaultBuilder(): array
* @var \Chill\MainBundle\DependencyInjection\Services\MenuComposer;
*/
private $menuComposer;
protected function setUp(): void
{ {
self::bootKernel(['environment' => 'test']); // Router: returns an empty RouteCollection so getRoutesFor() yields []
$this->menuComposer = self::getContainer() $routerProphecy = $this->prophesize(RouterInterface::class);
->get('chill.main.menu_composer'); $routerProphecy->getRouteCollection()->willReturn(new RouteCollection());
$router = $routerProphecy->reveal();
// Menu factory from Knp\Menu
$menuFactory = new MenuFactory();
// Translator: identity translator
$translator = new class () implements TranslatorInterface {
public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string
{
return $id;
}
public function getLocale(): string
{
return 'en';
}
};
// Local builder that adds two items to the requested menu
$builder = new class () implements LocalMenuBuilderInterface {
public static function getMenuIds(): array
{
return ['main'];
}
public function buildMenu($menuId, \Knp\Menu\MenuItem $menu, array $parameters)
{
// Ensure we can use parameters passed to getMenuFor
$suffix = $parameters['suffix'] ?? '';
$menu->addChild('local_item_one', [
'label' => 'Local Item One'.$suffix,
])->setExtras(['order' => 1]);
$menu->addChild('local_item_two', [
'label' => 'Local Item Two'.$suffix,
])->setExtras(['order' => 2]);
}
};
$composer = new MenuComposer(
$router,
$menuFactory,
$translator,
[$builder]
);
return [$composer, $builder];
} }
/** public function testGetMenuForReturnsItemsFromLocalBuildersOnly(): void
* @covers \Chill\MainBundle\Routing\MenuComposer
*/
public function testMenuComposer()
{ {
$collection = new RouteCollection(); [$composer] = $this->buildMenuComposerWithDefaultBuilder();
$routes = $this->menuComposer->getRoutesFor('dummy0'); $menu = $composer->getMenuFor('main', []);
$this->assertIsArray($routes); // No routes were added, only local builder items should be present
$children = $menu->getChildren();
self::assertCount(2, $children, 'Menu should contain exactly the items provided by local builders');
// Assert the two expected items exist with their names
self::assertNotNull($menu->getChild('local_item_one'));
self::assertNotNull($menu->getChild('local_item_two'));
// And that their labels include the parameter suffix
self::assertSame('Local Item One', $menu->getChild('local_item_one')->getLabel());
self::assertSame('Local Item Two', $menu->getChild('local_item_two')->getLabel());
}
public function testHasLocalMenuBuilder(): void
{
[$composer] = $this->buildMenuComposerWithDefaultBuilder();
self::assertTrue($composer->hasLocalMenuBuilder('main'));
self::assertFalse($composer->hasLocalMenuBuilder('secondary'));
} }
} }

View File

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

View File

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

View File

@@ -6,9 +6,8 @@ services:
chill.main.menu_composer: chill.main.menu_composer:
class: Chill\MainBundle\Routing\MenuComposer class: Chill\MainBundle\Routing\MenuComposer
arguments: arguments:
- '@Symfony\Component\Routing\RouterInterface' $localMenuBuilders: !tagged_iterator 'chill.menu_builder'
- '@Knp\Menu\FactoryInterface'
- '@Symfony\Contracts\Translation\TranslatorInterface'
Chill\MainBundle\Routing\MenuComposer: '@chill.main.menu_composer' Chill\MainBundle\Routing\MenuComposer: '@chill.main.menu_composer'
chill.main.routes_loader: chill.main.routes_loader:

View File

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

View File

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

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

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

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\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,6 +28,8 @@ class ConfigPersonAltNamesHelper
/** /**
* get the choices as key => values. * 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 public function getChoices(): array
{ {

View File

@@ -11,36 +11,22 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Controller; namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper; 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\Entity\Person;
use Chill\PersonBundle\Form\CreationPersonType;
use Chill\PersonBundle\Privacy\PrivacyEvent; use Chill\PersonBundle\Privacy\PrivacyEvent;
use Chill\PersonBundle\Repository\PersonRepository; use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Search\SimilarPersonMatcher;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; 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\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function hash;
final class PersonController extends AbstractController final class PersonController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly AuthorizationHelperInterface $authorizationHelper,
private readonly SimilarPersonMatcher $similarPersonMatcher,
private readonly TranslatorInterface $translator,
private readonly EventDispatcherInterface $eventDispatcher, private readonly EventDispatcherInterface $eventDispatcher,
private readonly PersonRepository $personRepository, private readonly PersonRepository $personRepository,
private readonly ConfigPersonAltNamesHelper $configPersonAltNameHelper, private readonly ConfigPersonAltNamesHelper $configPersonAltNameHelper,
@@ -85,110 +71,6 @@ 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')] #[Route(path: '/{_locale}/person/{person_id}/general', name: 'chill_person_view')]
public function viewAction(int $person_id) public function viewAction(int $person_id)
{ {
@@ -250,51 +132,4 @@ final class PersonController extends AbstractController
return $errors; 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,10 +12,7 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Form\DataMapper; namespace Chill\PersonBundle\Form\DataMapper;
use Chill\PersonBundle\Entity\PersonAltName; use Chill\PersonBundle\Entity\PersonAltName;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Form\DataMapperInterface; use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
class PersonAltNameDataMapper implements DataMapperInterface class PersonAltNameDataMapper implements DataMapperInterface
{ {
@@ -25,62 +22,24 @@ class PersonAltNameDataMapper implements DataMapperInterface
return; return;
} }
if (!$viewData instanceof Collection) { if (!is_array($viewData)) {
throw new UnexpectedTypeException($viewData, Collection::class); throw new \InvalidArgumentException('View data must be an array');
}
$mapIndexToKey = [];
foreach ($viewData->getIterator() as $key => $altName) {
/* @var PersonAltName $altName */
$mapIndexToKey[$altName->getKey()] = $key;
} }
foreach ($forms as $key => $form) { foreach ($forms as $key => $form) {
if (\array_key_exists($key, $mapIndexToKey)) { $personAltName = $viewData[$key];
$form->setData($viewData->get($mapIndexToKey[$key])->getLabel()); if (!$personAltName instanceof PersonAltName) {
throw new \InvalidArgumentException('PersonAltName must be an instance of PersonAltName');
} }
$form->setData($personAltName->getLabel());
} }
} }
public function mapFormsToData(\Traversable $forms, &$viewData): void 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) { foreach ($forms as $key => $form) {
$isEmpty = empty($form->getData()); $personAltName = array_find($viewData, fn (PersonAltName $altName) => $altName->getKey() === $key);
$personAltName->setLabel($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,60 +14,51 @@ namespace Chill\PersonBundle\Form\DataMapper;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\PersonIdentifier\Exception\UnexpectedTypeException; use Chill\PersonBundle\PersonIdentifier\Exception\UnexpectedTypeException;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; 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\DataMapperInterface;
use Symfony\Component\Form\FormInterface;
final readonly class PersonIdentifiersDataMapper implements DataMapperInterface final readonly class PersonIdentifiersDataMapper implements DataMapperInterface
{ {
public function __construct( public function __construct(
private PersonIdentifierManagerInterface $identifierManager, private PersonIdentifierManagerInterface $identifierManager,
private PersonIdentifierDefinitionRepository $identifierDefinitionRepository,
) {} ) {}
/**
* @pure
*/
public function mapDataToForms($viewData, \Traversable $forms): void public function mapDataToForms($viewData, \Traversable $forms): void
{ {
if (!$viewData instanceof Collection) { if (!$viewData instanceof PersonIdentifier) {
throw new UnexpectedTypeException($viewData, Collection::class); throw new UnexpectedTypeException($viewData, PersonIdentifier::class);
} }
/** @var array<string, FormInterface> $formsByKey */
$formsByKey = iterator_to_array($forms);
foreach ($this->identifierManager->getWorkers() as $worker) { $worker = $this->identifierManager->buildWorkerByPersonIdentifierDefinition($viewData->getDefinition());
if (!$worker->getDefinition()->isEditableByUsers()) { if (!$worker->getDefinition()->isEditableByUsers()) {
continue; return;
} }
$form = $formsByKey['identifier_'.$worker->getDefinition()->getId()]; foreach ($forms as $key => $form) {
$identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition()->getId() === $identifier->getId()); $form->setData($viewData->getValue()[$key] ?? $worker->getDefaultValue()[$key] ?? '');
if (null === $identifier) {
$identifier = new PersonIdentifier($worker->getDefinition());
}
$form->setData($identifier->getValue());
} }
} }
/**
* @pure
*/
public function mapFormsToData(\Traversable $forms, &$viewData): void public function mapFormsToData(\Traversable $forms, &$viewData): void
{ {
if (!$viewData instanceof Collection) { if (!$viewData instanceof PersonIdentifier) {
throw new UnexpectedTypeException($viewData, Collection::class); throw new UnexpectedTypeException($viewData, PersonIdentifier::class);
}
$worker = $this->identifierManager->buildWorkerByPersonIdentifierDefinition($viewData->getDefinition());
if (!$worker->getDefinition()->isEditableByUsers()) {
return;
} }
$values = [];
foreach ($forms as $name => $form) { foreach ($forms as $name => $form) {
$identifierId = (int) substr((string) $name, 11); $values[$name] = $form->getData();
$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;
}
$worker = $this->identifierManager->buildWorkerByPersonIdentifierDefinition($definition);
$identifier->setValue($form->getData());
$identifier->setCanonical($worker->canonicalizeValue($identifier->getValue()));
} }
$viewData->setValue($values);
$viewData->setCanonical($worker->canonicalizeValue($viewData->getValue()));
} }
} }

View File

@@ -12,10 +12,12 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Form; namespace Chill\PersonBundle\Form;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\Identifier\IdentifierPresenceEnum;
use Chill\PersonBundle\Form\DataMapper\PersonIdentifiersDataMapper; use Chill\PersonBundle\Form\DataMapper\PersonIdentifiersDataMapper;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class PersonIdentifiersType extends AbstractType final class PersonIdentifiersType extends AbstractType
{ {
@@ -27,22 +29,34 @@ final class PersonIdentifiersType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
foreach ($this->identifierManager->getWorkers() as $worker) { foreach ($this->identifierManager->getWorkers() as $k => $worker) {
if (!$worker->getDefinition()->isEditableByUsers()) { if (!$worker->getDefinition()->isEditableByUsers()) {
continue; continue;
} }
// skip some on creation
if ('on_create' === $options['step']
&& IdentifierPresenceEnum::ON_EDIT === $worker->getDefinition()->getPresence()) {
continue;
}
$subBuilder = $builder->create( $subBuilder = $builder->create(
'identifier_'.$worker->getDefinition()->getId(), 'identifier_'.$worker->getDefinition()->getId(),
options: [ options: [
'compound' => true, 'compound' => true,
'label' => $this->translatableStringHelper->localize($worker->getDefinition()->getLabel()), 'label' => $this->translatableStringHelper->localize($worker->getDefinition()->getLabel()),
'error_bubbling' => false,
] ]
); );
$subBuilder->setDataMapper($this->identifiersDataMapper);
$worker->buildForm($subBuilder); $worker->buildForm($subBuilder);
$builder->add($subBuilder); $builder->add($subBuilder);
} }
}
$builder->setDataMapper($this->identifiersDataMapper); public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('step', 'on_edit')
->setAllowedValues('step', ['on_edit', 'on_create']);
} }
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\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

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

View File

@@ -19,9 +19,29 @@ interface PersonIdentifierEngineInterface
{ {
public static function getName(): string; public static function getName(): string;
/**
* @phpstan-pure
*/
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string; public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string;
public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void; public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void;
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string; public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string;
/**
* Return true if the identifier must be considered as empty.
*
* This is in use when the identifier is validated and must be required. If the identifier is empty and is required
* by the definition, the validation will fails.
*/
public function isEmpty(PersonIdentifier $identifier): bool;
/**
* Return a list of @see{IdentifierViolationDTO} to generatie violation errors.
*
* @return list<IdentifierViolationDTO>
*/
public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array;
public function getDefaultValue(PersonIdentifierDefinition $definition): array;
} }

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
final readonly class PersonIdentifierWorker readonly class PersonIdentifierWorker
{ {
public function __construct( public function __construct(
private PersonIdentifierEngineInterface $identifierEngine, private PersonIdentifierEngineInterface $identifierEngine,
@@ -46,4 +46,25 @@ final readonly class PersonIdentifierWorker
{ {
return $this->identifierEngine->renderAsString($identifier, $this->definition); return $this->identifierEngine->renderAsString($identifier, $this->definition);
} }
/**
* Return true if the identifier must be considered as empty.
*/
public function isEmpty(PersonIdentifier $identifier): bool
{
return $this->identifierEngine->isEmpty($identifier);
}
/**
* @return list<IdentifierViolationDTO>
*/
public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array
{
return $this->identifierEngine->validate($identifier, $definition);
}
public function getDefaultValue(): array
{
return $this->identifierEngine->getDefaultValue($this->definition);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,46 +5,16 @@
<div class="item-col"> <div class="item-col">
<div class="entity-label"> <div class="entity-label">
<div :class="'denomination h' + options.hLevel"> <div :class="'denomination h' + options.hLevel">
<a v-if="options.addLink === true" :href="getUrl"> <template v-if="options.addLink === true">
<!-- use person-text here to avoid code duplication ? TODO --> <a v-if="options.addLink === true" :href="getUrl">
<span class="firstname">{{ person.firstName }}</span> <span>{{ person.text }}</span>
<span class="lastname">{{ person.lastName }}</span> <span v-if="person.deathdate" class="deathdate"> ()</span>
<span v-if="person.suffixText" class="suffixtext" </a>
>&nbsp;{{ person.suffixText }}</span </template>
> <template v-else>
<span <span>{{ person.text }}</span>
v-if="person.altNames && options.addAltNames == true" <span v-if="person.deathdate" class="deathdate"> ()</span>
class="altnames" </template>
>
<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 <badge-entity
v-if="options.addEntity === true" v-if="options.addEntity === true"
:entity="person" :entity="person"
@@ -52,61 +22,36 @@
/> />
</div> </div>
<p>
<span
v-if="options.addId == true"
:title="person.personId"
><i class="bi bi-info-circle"></i> {{ person.personId }}</span
>
</p>
<p v-if="options.addInfo === true" class="moreinfo"> <p v-if="options.addInfo === true" class="moreinfo">
<gender-icon-render-box <gender-icon-render-box
v-if="person.gender" v-if="person.gender"
:gender="person.gender" :gender="person.gender"
/> /> <span
<time v-if="person.birthdate"
v-if="person.birthdate && !person.deathdate"
:datetime="person.birthdate"
:title="birthdate"
> >
{{ {{ trans(RENDERBOX_BIRTHDAY_STATEMENT, {gender: toGenderTranslation(person.gender), birthdate: ISOToDate(person.birthdate?.datetime)}) }}
trans(birthdateTranslation) + </span>
" " +
new Intl.DateTimeFormat("fr-FR", {
dateStyle: "long",
}).format(birthdate)
}}
</time>
<time
v-else-if="person.birthdate && person.deathdate"
:datetime="person.deathdate"
:title="person.deathdate"
>
{{
new Intl.DateTimeFormat("fr-FR", {
dateStyle: "long",
}).format(birthdate)
}}
-
{{
new Intl.DateTimeFormat("fr-FR", {
dateStyle: "long",
}).format(deathdate)
}}
</time>
<time
v-else-if="person.deathdate"
:datetime="person.deathdate"
:title="person.deathdate"
>
{{
trans(RENDERBOX_DEATHDATE) +
" " +
new Intl.DateTimeFormat("fr-FR", {
dateStyle: "long",
}).format(deathdate)
}}
</time>
<span v-if="options.addAge && person.birthdate" class="age"> <span v-if="options.addAge && person.birthdate" class="age">
({{ trans(RENDERBOX_YEARS_OLD, { n: person.age }) }}) ({{ trans(RENDERBOX_YEARS_OLD, {n: person.age}) }})
</span> </span>
</p> </p>
<p>
<span
v-if="person.deathdate"
>
{{ trans(RENDERBOX_DEATHDATE_STATEMENT, {gender: toGenderTranslation(person.gender), deathdate: ISOToDate(person.deathdate?.datetime)}) }}
</span>
</p>
</div> </div>
</div> </div>
@@ -114,11 +59,11 @@
<div class="float-button bottom"> <div class="float-button bottom">
<div class="box"> <div class="box">
<div class="action"> <div class="action">
<slot name="record-actions" /> <slot name="record-actions"/>
</div> </div>
<ul class="list-content fa-ul"> <ul class="list-content fa-ul">
<li v-if="person.current_household_id"> <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 <address-render-box
v-if="person.current_household_address" v-if="person.current_household_address"
:address="person.current_household_address" :address="person.current_household_address"
@@ -130,11 +75,6 @@
<a <a
v-if="options.addHouseholdLink === true" v-if="options.addHouseholdLink === true"
:href="getCurrentHouseholdUrl" :href="getCurrentHouseholdUrl"
:title="
trans(PERSONS_ASSOCIATED_SHOW_HOUSEHOLD_NUMBER, {
id: person.current_household_id,
})
"
> >
<span class="badge rounded-pill bg-chill-beige"> <span class="badge rounded-pill bg-chill-beige">
<i <i
@@ -144,7 +84,7 @@
</a> </a>
</li> </li>
<li v-else-if="options.addNoData"> <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"> <p class="chill-no-data-statement">
{{ trans(RENDERBOX_NO_DATA) }} {{ trans(RENDERBOX_NO_DATA) }}
</p> </p>
@@ -160,7 +100,7 @@
v-for="(addr, i) in person.current_residential_addresses" v-for="(addr, i) in person.current_residential_addresses"
:key="i" :key="i"
> >
<i class="fa fa-li fa-map-marker" /> <i class="fa fa-li fa-map-marker"/>
<div v-if="addr.address"> <div v-if="addr.address">
<span class="item-key"> <span class="item-key">
{{ trans(RENDERBOX_RESIDENTIAL_ADDRESS) }}: {{ trans(RENDERBOX_RESIDENTIAL_ADDRESS) }}:
@@ -180,6 +120,7 @@
:person="addr.hostPerson" :person="addr.hostPerson"
/> />
</span> </span>
<address-render-box <address-render-box
v-if="addr.hostPerson.address" v-if="addr.hostPerson.address"
:address="addr.hostPerson.address" :address="addr.hostPerson.address"
@@ -204,36 +145,36 @@
</template> </template>
<li v-if="person.email"> <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> <a :href="'mailto: ' + person.email">{{ person.email }}</a>
</li> </li>
<li v-else-if="options.addNoData"> <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"> <p class="chill-no-data-statement">
{{ trans(RENDERBOX_NO_DATA) }} {{ trans(RENDERBOX_NO_DATA) }}
</p> </p>
</li> </li>
<li v-if="person.mobilenumber"> <li v-if="person.mobilenumber">
<i class="fa fa-li fa-mobile" /> <i class="fa fa-li fa-mobile"/>
<a :href="'tel: ' + person.mobilenumber"> <a :href="'tel: ' + person.mobilenumber">
{{ person.mobilenumber }} {{ person.mobilenumber }}
</a> </a>
</li> </li>
<li v-else-if="options.addNoData"> <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"> <p class="chill-no-data-statement">
{{ trans(RENDERBOX_NO_DATA) }} {{ trans(RENDERBOX_NO_DATA) }}
</p> </p>
</li> </li>
<li v-if="person.phonenumber"> <li v-if="person.phonenumber">
<i class="fa fa-li fa-phone" /> <i class="fa fa-li fa-phone"/>
<a :href="'tel: ' + person.phonenumber"> <a :href="'tel: ' + person.phonenumber">
{{ person.phonenumber }} {{ person.phonenumber }}
</a> </a>
</li> </li>
<li v-else-if="options.addNoData"> <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"> <p class="chill-no-data-statement">
{{ trans(RENDERBOX_NO_DATA) }} {{ trans(RENDERBOX_NO_DATA) }}
</p> </p>
@@ -246,25 +187,25 @@
options.addCenter 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"> <template v-for="c in person.centers">
{{ c.name }} {{ c.name }}
</template> </template>
</li> </li>
<li v-else-if="options.addNoData"> <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"> <p class="chill-no-data-statement">
{{ trans(RENDERBOX_NO_DATA) }} {{ trans(RENDERBOX_NO_DATA) }}
</p> </p>
</li> </li>
<slot name="custom-zone" /> <slot name="custom-zone"/>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<slot name="end-bloc" /> <slot name="end-bloc"/>
</section> </section>
</div> </div>
@@ -278,11 +219,11 @@
class="fa-stack fa-holder" class="fa-stack fa-holder"
:title="trans(RENDERBOX_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> <i class="fa fa-stack-1x">T</i>
</span> </span>
<person-text :person="person" /> <person-text :person="person"/>
</a> </a>
<span v-else> <span v-else>
<span <span
@@ -290,18 +231,18 @@
class="fa-stack fa-holder" class="fa-stack fa-holder"
:title="trans(RENDERBOX_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> <i class="fa fa-stack-1x">T</i>
</span> </span>
<person-text :person="person" /> <person-text :person="person"/>
</span> </span>
<slot name="post-badge" /> <slot name="post-badge"/>
</span> </span>
</template> </template>
<script setup> <script setup lang="ts">
import { computed } from "vue"; import {computed} from "vue";
import { ISOToDate } from "ChillMainAssets/chill/js/date"; import {ISOToDate} from "ChillMainAssets/chill/js/date";
import AddressRenderBox from "ChillMainAssets/vuejs/_components/Entity/AddressRenderBox.vue"; import AddressRenderBox from "ChillMainAssets/vuejs/_components/Entity/AddressRenderBox.vue";
import GenderIconRenderBox from "ChillMainAssets/vuejs/_components/Entity/GenderIconRenderBox.vue"; import GenderIconRenderBox from "ChillMainAssets/vuejs/_components/Entity/GenderIconRenderBox.vue";
import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue";
@@ -311,108 +252,69 @@ import {
trans, trans,
RENDERBOX_HOLDER, RENDERBOX_HOLDER,
RENDERBOX_NO_DATA, RENDERBOX_NO_DATA,
RENDERBOX_DEATHDATE, RENDERBOX_DEATHDATE_STATEMENT,
RENDERBOX_HOUSEHOLD_WITHOUT_ADDRESS, RENDERBOX_HOUSEHOLD_WITHOUT_ADDRESS,
RENDERBOX_RESIDENTIAL_ADDRESS, RENDERBOX_RESIDENTIAL_ADDRESS,
RENDERBOX_LOCATED_AT, RENDERBOX_LOCATED_AT,
RENDERBOX_BIRTHDAY_MAN, RENDERBOX_BIRTHDAY_STATEMENT,
RENDERBOX_BIRTHDAY_WOMAN, // PERSONS_ASSOCIATED_SHOW_HOUSEHOLD_NUMBER,
RENDERBOX_BIRTHDAY_UNKNOWN,
RENDERBOX_BIRTHDAY_NEUTRAL,
PERSONS_ASSOCIATED_SHOW_HOUSEHOLD_NUMBER,
RENDERBOX_YEARS_OLD, RENDERBOX_YEARS_OLD,
} from "translator"; } from "translator";
import {Person} from "ChillPersonAssets/types";
import {toGenderTranslation} from "ChillMainAssets/lib/api/genderHelper";
const props = defineProps({ interface RenderOptions {
person: { addInfo?: boolean;
required: true, addEntity?: boolean;
}, addAltNames?: boolean;
options: { addAge?: boolean;
type: Object, addId?: boolean;
required: false, addLink?: boolean;
}, hLevel?: number;
render: { entityDisplayLong?: boolean;
type: String, addCenter?: boolean;
}, addNoData?: boolean;
returnPath: { isMultiline?: boolean;
type: String, isHolder?: boolean;
}, addHouseholdLink?: boolean;
showResidentialAddresses: { }
type: Boolean,
default: false,
},
});
const birthdateTranslation = computed(() => { interface Props {
if (props.person.gender) { person: Person;
const { genderTranslation } = props.person.gender; options?: RenderOptions;
switch (genderTranslation) { render?: "bloc" | "badge";
case "man": returnPath?: string;
return RENDERBOX_BIRTHDAY_MAN; showResidentialAddresses?: boolean;
case "woman": }
return RENDERBOX_BIRTHDAY_WOMAN;
case "neutral": const props = withDefaults(defineProps<Props>(), {
return RENDERBOX_BIRTHDAY_NEUTRAL; render: "bloc", options: {
case "unknown": addInfo: true,
return RENDERBOX_BIRTHDAY_UNKNOWN; addEntity: false,
default: addAltNames: true,
return RENDERBOX_BIRTHDAY_UNKNOWN; addAge: true,
} addId: true,
} else { addLink: false,
return RENDERBOX_BIRTHDAY_UNKNOWN; hLevel: 3,
entityDisplayingLong: true,
addCenter: true,
addNoData: true,
isMultiline: true,
isHolder: false,
addHouseholdLink: true
} }
}); });
const isMultiline = computed(() => { const isMultiline = computed<boolean>(() => {
return props.options?.isMultiline || false; return props.options?.isMultiline || false;
}); });
const birthdate = computed(() => { const getUrl = computed<string>(() => {
if (
props.person.birthdate !== null &&
props.person.birthdate !== undefined &&
props.person.birthdate.datetime
) {
return ISOToDate(props.person.birthdate.datetime);
} else {
return "";
}
});
const deathdate = computed(() => {
if (
props.person.deathdate !== null &&
props.person.deathdate !== undefined &&
props.person.deathdate.datetime
) {
return new Date(props.person.deathdate.datetime);
} else {
return "";
}
});
const altNameLabel = computed(() => {
let altNameLabel = "";
(props.person.altNames || []).forEach(
(altName) => (altNameLabel += altName.label),
);
return altNameLabel;
});
const altNameKey = computed(() => {
let altNameKey = "";
(props.person.altNames || []).forEach(
(altName) => (altNameKey += altName.key),
);
return altNameKey;
});
const getUrl = computed(() => {
return `/fr/person/${props.person.id}/general`; return `/fr/person/${props.person.id}/general`;
}); });
const getCurrentHouseholdUrl = computed(() => { const getCurrentHouseholdUrl = computed<string>(() => {
let returnPath = props.returnPath ? `?returnPath=${props.returnPath}` : ``; const returnPath = props.returnPath ? `?returnPath=${props.returnPath}` : ``;
return `/fr/person/household/${props.person.current_household_id}/summary${returnPath}`; return `/fr/person/household/${props.person.current_household_id}/summary${returnPath}`;
}); });
</script> </script>

View File

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

View File

@@ -1,5 +1,5 @@
<template> <template>
<div v-if="action === 'show'"> <div v-if="action === 'show' && person !== null">
<div class="flex-table"> <div class="flex-table">
<person-render-box <person-render-box
render="bloc" render="bloc"
@@ -22,445 +22,48 @@
</div> </div>
<div v-else-if="action === 'edit' || action === 'create'"> <div v-else-if="action === 'edit' || action === 'create'">
<div class="form-floating mb-3"> <PersonEdit
<input :id="props.id"
class="form-control form-control-lg" :type="props.type"
id="lastname" :action="props.action"
v-model="lastName" :query="props.query"
: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> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { import { getPerson } from "../../_api/OnTheFly";
getCentersForPersonCreation,
getCivilities,
getGenders,
getPerson,
getPersonAltNames,
} from "../../_api/OnTheFly";
import PersonRenderBox from "../Entity/PersonRenderBox.vue"; import PersonRenderBox from "../Entity/PersonRenderBox.vue";
import AddAddress from "ChillMainAssets/vuejs/Address/components/AddAddress.vue"; import PersonEdit from "./PersonEdit.vue";
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; import type { Person } from "ChillPersonAssets/types";
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";
const props = defineProps({ interface Props {
id: [String, Number], id: string | number;
type: String, type?: string;
action: String, action: "show" | "edit" | "create";
query: 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é");
}
} }
function loadData() { const props = defineProps<Props>();
getPerson(props.id).then((p) => {
Object.assign(person, p); const person = ref<Person | null>(null);
function loadData(): void {
if (props.id === undefined || props.id === null) {
return;
}
const idNum = typeof props.id === "string" ? Number(props.id) : props.id;
if (!Number.isFinite(idNum)) {
return;
}
getPerson(idNum as number).then((p) => {
person.value = p;
}); });
} }
function onAltNameInput(event) {
const key = event.target.id;
const label = event.target.value;
let updateAltNames = person.altNames.filter((a) => a.key !== key);
updateAltNames.push({ key: key, label: label });
person.altNames = updateAltNames;
}
function addQueryItem(field, queryItem) {
switch (field) {
case "lastName":
person.lastName = person.lastName
? (person.lastName += ` ${queryItem}`)
: queryItem;
break;
case "firstName":
person.firstName = person.firstName
? (person.firstName += ` ${queryItem}`)
: queryItem;
break;
}
}
function submitNewAddress(payload) {
person.addressId = payload.addressId;
}
onMounted(() => { 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") { if (props.action !== "create") {
loadData(); loadData();
} else {
getCentersForPersonCreation().then((params) => {
config.centers = params.centers.filter((c) => c.isActive);
showCenters.value = params.showCenters;
if (showCenters.value && config.centers.length === 1) {
person.center = config.centers[0];
}
});
} }
}); });
defineExpose(genderClass, genderTranslation, feminized, birthDate);
</script> </script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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