Compare commits

..

85 Commits

Author SHA1 Message Date
3f6bbbc1b3 Display a message when no results are found in PersonChooseModal and refine search state handling.
- Added a "no results" message with dynamic translation when no suggestions are available.
- Introduced `hasPreviousQuery` state and `hasNoResult` computed property for improved search state management.
- Updated styles for "no results" display and adjusted button margin in the modal.
2025-10-29 16:52:40 +01:00
31e57d7507 Display a message when no results are found in PersonChooseModal and refine search state handling.
- Added a "no results" message with dynamic translation when no suggestions are available.
- Introduced `hasPreviousQuery` state and `hasNoResult` computed property for improved search state management.
- Updated styles for "no results" display and adjusted button margin in the modal.
2025-10-29 16:47:28 +01:00
5c098a336d Add edit functionality for ThirdParty in OnTheFly.vue and refactor ThirdPartyEdit for consistency.
- Implemented dynamic component rendering in `OnTheFly.vue` for `thirdparty` type based on the `action` (`show`, `edit`, etc.).
- Added `ThirdPartyEdit` component with API integration for editing third parties.
- Introduced `thirdpartyToWriteThirdParty` function in the API for mapping `Thirdparty` to `ThirdPartyWrite` structure.
- Centralized validation handling in `ThirdPartyEdit.vue` using `useViolationList` composable and enhanced template conditions.
- Updated and extended API functions (`patchThirdparty`, etc.) and types to support third-party editing.
2025-10-29 16:29:44 +01:00
e107d20bea Introduce edit functionality for PersonEdit and refactor OnTheFly components for improved modularity.
- Added support for editing a person entity in `PersonEdit.vue` with proper data initialization and API integration.
- Refactored `OnTheFly.vue` to dynamically render components based on the action (`show`, `edit`, etc.).
- Introduced `personToWritePerson` conversion logic for mapping `Person` to `PersonWrite` structure.
- Enhanced template conditions and loading states for `PersonEdit`.
- Updated API with `editPerson` function and adjusted related types for consistency.
2025-10-29 15:05:08 +01:00
491fd81f9b Fix PersonIdentifier lookup in PersonJsonDenormalizer to use Definition ID comparison.
- Updated lambda function to compare `personIdentifier`'s `Definition` ID instead of its own ID for correct matching.
2025-10-29 15:04:58 +01:00
8c2acbd166 Add support for serializing identifiers in PersonJsonNormalizer and improve PersonIdentifier entity serialization.
- Extended `PersonJsonNormalizer` to include `identifiers` field normalization.
- Added `Serializer` annotations to `PersonIdentifier` and `PersonIdentifierDefinition` for enhanced serialization support.
- Updated `PersonIdentifier` and `PersonIdentifierDefinition` to define serialization groups and discriminator maps.
2025-10-29 15:04:50 +01:00
e291c7abec Refactor ThirdPartyEdit.vue to improve validation handling and streamline input components.
- Replaced individual validation logic with `useViolationList` composable for centralization and consistency.
- Enhanced input components with floating labels, validation error feedback, and improved class binding for better UX.
- Updated API to include `WriteThirdPartyViolationMap` interface for structured validation error mapping.
- Refactored imports and adjusted spacing for better readability and adherence to coding standards.
2025-10-29 12:35:30 +01:00
1e186fab58 Document validation update guidelines for ThirdParty entity to align with associated Vue component logic.
- Added documentation to ensure updates in validations reflect in `ThirdPartyEdit.vue` and violation lists for consistency.
- Clarified the relationship between validation logic and UI component updates.
2025-10-29 12:35:30 +01:00
83a2c04537 Refactor violation handling in PersonEdit.vue by introducing useViolationList composable.
- Centralized violation handling logic with `useViolationList` for improved reusability and maintainability.
- Replaced local violation functions with composable methods in `PersonEdit.vue`.
- Streamlined UI binding for validation errors across multiple inputs.
2025-10-29 12:35:30 +01:00
e89b33bc1a Refactor AddPersons and related components for improved state management and prop handling.
- Replaced array-based `selected` state with `Map` for better key-based selection handling.
- Simplified `PersonSuggestion.vue` and `PersonChooseModal.vue` to align with updated state structure.
- Removed debug logs and legacy code for cleaner and more maintainable codebase.
2025-10-28 15:50:40 +01:00
6b208e9962 Refactor PersonSuggestion and PersonChooseModal for streamlined state management and improved prop handling.
- Replaced `search.suggested` and `search.selected` with dedicated reactive `suggested` and `selected` states.
- Updated `PersonSuggestion.vue` to handle selection via `onUpdateValue` and removed `v-model` usage.
- Simplified `PersonChooseModal.vue` to compute `selectedAndSuggested` directly using props.
- Adjusted `types.ts` to refine `Search.results` and standardize `Suggestion` structure.
2025-10-28 14:32:12 +01:00
b0c63fab91 Refactor AddPersons and related components for consistent state management and improved handling of selected suggestions.
- Replaced `search.selected` with a dedicated `selected` state and implemented explicit methods for adding, removing, and clearing selections.
- Updated event handling and props (`addNewPersons`, `selected`, `updateSelected`, `cleanSelected`) for better separation of concerns and type safety.
- Introduced `isSelected` utility for streamlined selection checks and replaced deprecated event usages.
- Adjusted modal behaviors in `PersonChooseModal.vue` and `AddPersons.vue` for improved integration and alignment with new state logic.
2025-10-28 12:41:52 +01:00
6d4c4d2c74 Refactor entity handling in TypeUser.vue and PickEntity.vue for improved consistency and type safety.
- Adjusted `TypeUser.vue` to correctly reference `props.item.result` and removed unused `hasParent` logic.
- Updated `PickEntity.vue` to include handling for households with new `isEntityHousehold` utility.
- Added `isEntityHousehold` function to `types.ts` for reusable type checks.
2025-10-25 01:06:29 +02:00
df6087d468 Refactor Gender and ResidentialAddress types and update associated components for improved consistency and type safety.
- Replaced `gender` string union with `Gender` type in `Person` interfaces.
- Added `ResidentialAddress` type to standardize address handling within `Person`.
- Updated `PersonRenderBox.vue` to utilize new properties and improve null safety with optional chaining.
- Corrected prop defaults and fixed typo in
2025-10-25 00:58:17 +02:00
ffac143ab9 Update address handling in third-party components and types for consistency.
- Replaced `address_id` with `id` in `ThirdPartyEdit.vue` and associated logic.
- Introduced `SetAddress` type to standardize address references in write operations.
- Updated `SetThirdParty` and related types to use `SetAddress` for better type safety.
2025-10-24 17:03:27 +02:00
f1bf6023ff Refactor third-party handling for consistency and improved data flow.
- Renamed `categories` to `category` in `Thirdparty` and related types for clarity and type safety.
- Updated `ThirdPartyRenderBox.vue` and `CreateModal.vue` to align with new type definitions.
- Enhanced `AddPersons.vue` to handle `onThirdPartyCreated` event properly and extend functionality for third-party creation.
2025-10-24 16:41:08 +02:00
71e146e4f0 fix for ThirdParty create flow.
- Added `parent` property handling in `ThirdPartyEdit.vue`, `Create.vue`, and related components for better association with parent entities.
- Updated `Thirdparty` and `ThirdPartyWrite` types to include `parent` property and ensure type safety.
- Adjusted translations and messages to reflect the new parent entity association concept.
2025-10-24 16:22:09 +02:00
4234377b7e Replace OnTheFly.js with OnTheFly.ts and introduce ThirdPartyEdit.vue for enhanced third-party entity management
- Migrated API functions in `OnTheFly.js` to `OnTheFly.ts` for improved type safety using TypeScript.
- Added `ThirdPartyEdit.vue` to streamline third-party creation and editing with a Vue component structure.
- Updated third-party-related types in `types.ts` to improve consistency and explicitly distinguish `company`, `contact`, and `child` entities.
- Enhanced `AddPersons.vue` and dependent components to support adding third-party contacts.
- Adjusted styles and removed `.btn-tpchild` for consistency in button definitions across SCSS.
2025-10-24 14:21:39 +02:00
1be82b3049 Update PersonIdentifierWorkerNormalizerTest to include presence attribute in expected normalized data
- Replaced `id` with `definition_id` in expected output for consistency.
- Ensured `presence` attribute is validated in test cases.
2025-10-21 15:29:17 +02:00
808954df42 fix implementation of PersonIdentifierEngineInterface in test 2025-10-21 15:18:50 +02:00
3f8bb6c5c0 cs fixes 2025-10-21 15:01:34 +02:00
23e1a0d36a rector fixes 2025-10-21 15:01:21 +02:00
a594d86346 fix cs and missing methods 2025-10-21 14:38:05 +02:00
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
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
4a1da25fee Merge branch 'ticket-app-master' into ticket/64-identifiants-person 2025-09-30 10:08:56 +02: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
177 changed files with 9760 additions and 5623 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,14 @@
import { Scope } from "../../types";
import {
DynamicKeys,
Scope,
ValidationExceptionInterface,
ValidationProblemFromMap,
ViolationFromMap
} from "../../types";
export type body = Record<string, boolean | string | number | null>;
export type fetchOption = Record<string, boolean | string | number | null>;
export type Primitive = string | number | boolean | null;
export type Params = Record<string, number | string>;
export interface Pagination {
@@ -25,20 +31,115 @@ export interface TransportExceptionInterface {
name: string;
}
export interface ValidationExceptionInterface
extends TransportExceptionInterface {
name: "ValidationException";
error: object;
violations: string[];
titles: string[];
propertyPaths: string[];
export class ValidationException<
M extends Record<string, Record<string, string|number>> = Record<
string,
Record<string, string|number>
>,
>
extends Error
implements ValidationExceptionInterface<M>
{
public readonly name = "ValidationException" as const;
public readonly problems: ValidationProblemFromMap<M>;
public readonly violations: string[];
public readonly violationsList: ViolationFromMap<M>[];
public readonly titles: string[];
public readonly propertyPaths: DynamicKeys<M> & string[];
public readonly byProperty: Record<Extract<keyof M, string>, string[]>;
constructor(problem: ValidationProblemFromMap<M>) {
const message = [problem.title, problem.detail].filter(Boolean).join(" — ");
super(message);
Object.setPrototypeOf(this, new.target.prototype);
this.problems = problem;
this.violationsList = problem.violations;
this.violations = problem.violations.map(
(v) => `${v.title}: ${v.propertyPath}`,
);
this.titles = problem.violations.map((v) => v.title);
this.propertyPaths = problem.violations.map(
(v) => v.propertyPath,
) as DynamicKeys<M> & string[];
this.byProperty = problem.violations.reduce(
(acc, v) => {
const key = v.propertyPath.replace('/\[\d+\]$/', "") as Extract<keyof M, string>;
(acc[key] ||= []).push(v.title);
return acc;
},
{} as Record<Extract<keyof M, string>, string[]>,
);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, ValidationException);
}
}
violationsByNormalizedProperty(property: Extract<keyof M, string>): ViolationFromMap<M>[] {
return this.violationsList.filter((v) => v.propertyPath.replace(/\[\d+\]$/, "") === property);
}
violationsByNormalizedPropertyAndParams<
P extends Extract<keyof M, string>,
K extends Extract<keyof M[P], string>
>(
property: P,
param: K,
param_value: M[P][K]
): ViolationFromMap<M>[]
{
const list = this.violationsByNormalizedProperty(property);
return list.filter(
(v): boolean =>
!!v.parameters &&
// `with_parameter in v.parameters` check indexing
param in v.parameters &&
// the cast is safe, because we have overloading that bind the types
(v.parameters as M[P])[param] === param_value
);
}
}
export interface ValidationErrorResponse extends TransportExceptionInterface {
violations: {
title: string;
propertyPath: string;
}[];
/**
* Check that the exception is a ValidationExceptionInterface
* @param x
*/
export function isValidationException<M extends Record<string, Record<string, string|number>>>(
x: unknown,
): x is ValidationExceptionInterface<M> {
return (
x instanceof ValidationException ||
(typeof x === "object" &&
x !== null &&
(x as any).name === "ValidationException")
);
}
export function isValidationProblem(x: unknown): x is {
type: string;
title: string;
violations: { propertyPath: string; title: string }[];
} {
if (!x || typeof x !== "object") return false;
const o = x as any;
return (
typeof o.type === "string" &&
typeof o.title === "string" &&
Array.isArray(o.violations) &&
o.violations.every(
(v: any) =>
v &&
typeof v === "object" &&
typeof v.propertyPath === "string" &&
typeof v.title === "string",
)
);
}
export interface AccessExceptionInterface extends TransportExceptionInterface {
@@ -65,12 +166,151 @@ export interface ConflictHttpExceptionInterface
}
/**
* Generic api method that can be adapted to any fetch request
* Generic api method that can be adapted to any fetch request.
*
* This method is suitable make a single fetch. When performing a GET to fetch a list of elements, always consider pagination
* and use of the @link{fetchResults} method.
* What this does
* - Performs a single HTTP request using fetch and returns the parsed JSON as Output.
* - Interprets common API errors and throws typed exceptions you can catch in your UI.
* - When the server returns a Symfony validation problem (HTTP 422), the error is
* rethrown as a typed ValidationException that is aware of your Violation Map (see below).
*
* Important: For GET endpoints that return lists, prefer using fetchResults, which
* handles pagination and aggregation for you.
*
* Violation Map (M): make your 422 errors strongly typed
* ------------------------------------------------------
* 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",
url: string,
body?: body | Input | null,
@@ -90,7 +330,8 @@ export const makeFetch = <Input, Output>(
if (typeof options !== "undefined") {
opts = Object.assign(opts, options);
}
return fetch(url, opts).then((response) => {
return fetch(url, opts).then(async (response) => {
if (response.status === 204) {
return Promise.resolve();
}
@@ -100,9 +341,20 @@ export const makeFetch = <Input, Output>(
}
if (response.status === 422) {
return response.json().then((response) => {
throw ValidationException(response);
});
// Unprocessable Entity -> payload de validation Symfony
const json = await response.json().catch(() => undefined);
if (isValidationProblem(json)) {
// On ré-interprète le payload selon M (ParamMap) pour typer les violations
const problem = json as unknown as ValidationProblemFromMap<M>;
throw new ValidationException<M>(problem);
}
const err = new Error(
"Validation failed but payload is not a ValidationProblem",
);
(err as any).raw = json;
throw err;
}
if (response.status === 403) {
@@ -167,12 +419,6 @@ function _fetchAction<T>(
throw NotFoundException(response);
}
if (response.status === 422) {
return response.json().then((response) => {
throw ValidationException(response);
});
}
if (response.status === 403) {
throw AccessException(response);
}
@@ -231,24 +477,6 @@ export const fetchScopes = (): Promise<Scope[]> => {
return fetchResults("/api/1.0/main/scope.json");
};
/**
* Error objects to be thrown
*/
const ValidationException = (
response: ValidationErrorResponse,
): ValidationExceptionInterface => {
const error = {} as ValidationExceptionInterface;
error.name = "ValidationException";
error.violations = response.violations.map(
(violation) => `${violation.title}: ${violation.propertyPath}`,
);
error.titles = response.violations.map((violation) => violation.title);
error.propertyPaths = response.violations.map(
(violation) => violation.propertyPath,
);
return error;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const AccessException = (response: Response): AccessExceptionInterface => {
const error = {} as AccessExceptionInterface;

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,8 +45,11 @@ class PhonenumberNormalizer implements ContextAwareNormalizerInterface, Denormal
try {
return $this->phoneNumberUtil->parse($data, $this->defaultCarrierCode);
} catch (NumberParseException $e) {
throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
} catch (NumberParseException) {
$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 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

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

View File

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

View File

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

View File

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

View File

@@ -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.
*
* @return array<string, array<string, string>> where the key is the altName's key, and the value is an array of TranslatableString
*/
public function getChoices(): array
{

View File

@@ -11,36 +11,22 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Form\CreationPersonType;
use Chill\PersonBundle\Privacy\PrivacyEvent;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Search\SimilarPersonMatcher;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\ORM\EntityManagerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Form;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function hash;
final class PersonController extends AbstractController
{
public function __construct(
private readonly AuthorizationHelperInterface $authorizationHelper,
private readonly SimilarPersonMatcher $similarPersonMatcher,
private readonly TranslatorInterface $translator,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly PersonRepository $personRepository,
private readonly ConfigPersonAltNamesHelper $configPersonAltNameHelper,
@@ -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')]
public function viewAction(int $person_id)
{
@@ -250,51 +132,4 @@ final class PersonController extends AbstractController
return $errors;
}
private function isLastPostDataChanges(Form $form, Request $request, bool $replace = false): bool
{
/** @var SessionInterface $session */
$session = $this->get('session');
if (!$session->has('last_person_data')) {
return true;
}
$newPost = $this->lastPostDataBuildHash($form, $request);
$isChanged = $session->get('last_person_data') !== $newPost;
if ($replace) {
$session->set('last_person_data', $newPost);
}
return $isChanged;
}
/**
* build the hash for posted data.
*
* For privacy reasons, the data are hashed using sha512
*/
private function lastPostDataBuildHash(Form $form, Request $request): string
{
$fields = [];
$ignoredFields = ['form_status', '_token'];
foreach ($request->request->all()[$form->getName()] as $field => $value) {
if (\in_array($field, $ignoredFields, true)) {
continue;
}
$fields[$field] = \is_array($value) ?
\implode(',', $value) : $value;
}
ksort($fields);
return \hash('sha512', \implode('&', $fields));
}
private function lastPostDataReset(): void
{
$this->get('session')->set('last_person_data', '');
}
}

View File

@@ -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;
use Chill\CustomFieldsBundle\EntityRepository\CustomFieldsDefaultGroupRepository;
use Chill\PersonBundle\Actions\PersonEdit\Service\PersonEditDTOFactory;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Form\PersonType;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
@@ -38,6 +39,7 @@ final readonly class PersonEditController
private EntityManagerInterface $entityManager,
private UrlGeneratorInterface $urlGenerator,
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.');
}
$dto = $this->personEditDTOFactory->createPersonEditDTO($person);
$form = $this->formFactory->create(
PersonType::class,
$person,
$dto,
['cFGroup' => $this->customFieldsDefaultGroupRepository->findOneByEntity(Person::class)?->getCustomFieldsGroup()]
);
@@ -62,6 +66,7 @@ final readonly class PersonEditController
$session
->getFlashBag()->add('error', new TranslatableMessage('This form contains errors'));
} elseif ($form->isSubmitted() && $form->isValid()) {
$this->personEditDTOFactory->mapPersonEditDTOtoPerson($dto, $person);
$this->entityManager->flush();
$session->getFlashBag()->add('success', new TranslatableMessage('The person data has been updated'));

View File

@@ -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 should use the PersonRepository service instead of a custom service name.
$loader->load('services/repository.yaml');
$loader->load('services/serializer.yaml');
$loader->load('services/security.yaml');
$loader->load('services/doctrineEventListener.yaml');
$loader->load('services/accompanyingPeriodConsistency.yaml');

View File

@@ -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,15 +12,24 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Entity\Identifier;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\PersonIdentifier\Validator\UniqueIdentifierConstraint;
use Chill\PersonBundle\PersonIdentifier\Validator\ValidIdentifierConstraint;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
#[ORM\Entity]
#[ORM\Table(name: 'chill_person_identifier')]
#[ORM\UniqueConstraint(name: 'chill_person_identifier_unique', columns: ['definition_id', 'canonical'])]
#[ORM\UniqueConstraint(name: 'chill_person_identifier_unique_person_definition', columns: ['definition_id', 'person_id'])]
#[UniqueIdentifierConstraint]
#[ValidIdentifierConstraint]
#[Serializer\DiscriminatorMap('type', ['person_identifier' => PersonIdentifier::class])]
class PersonIdentifier
{
#[ORM\Id]
#[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)]
#[ORM\GeneratedValue]
#[Serializer\Groups(['read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Person::class)]
@@ -28,14 +37,16 @@ class PersonIdentifier
private ?Person $person = null;
#[ORM\Column(name: 'value', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
#[Serializer\Groups(['read'])]
private array $value = [];
#[ORM\Column(name: 'canonical', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => '[]'])]
#[ORM\Column(name: 'canonical', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
private string $canonical = '';
public function __construct(
#[ORM\ManyToOne(targetEntity: PersonIdentifierDefinition::class)]
#[ORM\JoinColumn(name: 'definition_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
#[ORM\JoinColumn(name: 'definition_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')]
#[Serializer\Groups(['read'])]
private PersonIdentifierDefinition $definition,
) {}

View File

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

View File

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

View File

@@ -11,15 +11,14 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Form;
use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Form\Event\CustomizeFormEvent;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\ChillPhoneNumberType;
use Chill\MainBundle\Form\Type\PickAddressType;
use Chill\MainBundle\Form\Type\PickCenterType;
use Chill\MainBundle\Form\Type\PickCivilityType;
use Chill\PersonBundle\Actions\PersonCreate\PersonCreateDTO;
use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Form\Type\PersonAltNameType;
use Chill\PersonBundle\Form\Type\PickGenderType;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
@@ -33,6 +32,7 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class CreationPersonType extends AbstractType
{
@@ -80,15 +80,18 @@ final class CreationPersonType extends AbstractType
->add('addressForm', CheckboxType::class, [
'label' => 'Create a household and add an address',
'required' => false,
'mapped' => false,
'help' => 'A new household will be created. The person will be member of this household.',
])
->add('address', PickAddressType::class, [
'required' => false,
'mapped' => false,
'label' => false,
]);
$builder->add('identifiers', PersonIdentifiersType::class, [
'by_reference' => false,
'step' => 'on_create',
]);
if ($this->askCenters) {
$builder
->add('center', PickCenterType::class, [
@@ -112,7 +115,7 @@ final class CreationPersonType extends AbstractType
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Person::class,
'data_class' => PersonCreateDTO::class,
'constraints' => [
new Callback($this->validateCheckedAddress(...)),
],
@@ -129,10 +132,12 @@ final class CreationPersonType extends AbstractType
public function validateCheckedAddress($data, ExecutionContextInterface $context, $payload): void
{
/** @var bool $addressFrom */
$addressFrom = $context->getObject()->get('addressForm')->getData();
/** @var ?Address $address */
$address = $context->getObject()->get('address')->getData();
if (!$data instanceof PersonCreateDTO) {
throw new UnexpectedTypeException($data, PersonCreateDTO::class);
}
$addressFrom = $data->addressForm;
$address = $data->address;
if ($addressFrom && null === $address) {
$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;
use Chill\PersonBundle\Entity\PersonAltName;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Exception\UnexpectedTypeException;
class PersonAltNameDataMapper implements DataMapperInterface
{
@@ -25,62 +22,24 @@ class PersonAltNameDataMapper implements DataMapperInterface
return;
}
if (!$viewData instanceof Collection) {
throw new UnexpectedTypeException($viewData, Collection::class);
}
$mapIndexToKey = [];
foreach ($viewData->getIterator() as $key => $altName) {
/* @var PersonAltName $altName */
$mapIndexToKey[$altName->getKey()] = $key;
if (!is_array($viewData)) {
throw new \InvalidArgumentException('View data must be an array');
}
foreach ($forms as $key => $form) {
if (\array_key_exists($key, $mapIndexToKey)) {
$form->setData($viewData->get($mapIndexToKey[$key])->getLabel());
$personAltName = $viewData[$key];
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
{
$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) {
$isEmpty = empty($form->getData());
if (\array_key_exists($key, $mapIndexToKey)) {
if ($isEmpty) {
$viewData->remove($mapIndexToKey[$key]);
} else {
$viewData->get($mapIndexToKey[$key])->setLabel($form->getData());
}
} else {
if (!$isEmpty) {
$altName = (new PersonAltName())
->setKey($key)
->setLabel($form->getData());
if (\is_array($viewData)) {
$viewData[] = $altName;
} else {
$viewData->add($altName);
}
}
}
$personAltName = array_find($viewData, fn (PersonAltName $altName) => $altName->getKey() === $key);
$personAltName->setLabel($form->getData());
}
}
}

View File

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

View File

@@ -12,10 +12,12 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Form;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\Identifier\IdentifierPresenceEnum;
use Chill\PersonBundle\Form\DataMapper\PersonIdentifiersDataMapper;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class PersonIdentifiersType extends AbstractType
{
@@ -27,22 +29,34 @@ final class PersonIdentifiersType extends AbstractType
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()) {
continue;
}
// skip some on creation
if ('on_create' === $options['step']
&& IdentifierPresenceEnum::ON_EDIT === $worker->getDefinition()->getPresence()) {
continue;
}
$subBuilder = $builder->create(
'identifier_'.$worker->getDefinition()->getId(),
options: [
'compound' => true,
'label' => $this->translatableStringHelper->localize($worker->getDefinition()->getLabel()),
'error_bubbling' => false,
]
);
$subBuilder->setDataMapper($this->identifiersDataMapper);
$worker->buildForm($subBuilder);
$builder->add($subBuilder);
}
}
$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\Select2LanguageType;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Actions\PersonEdit\PersonEditDTO;
use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonPhone;
use Chill\PersonBundle\Form\Type\PersonAltNameType;
use Chill\PersonBundle\Form\Type\PersonPhoneType;
@@ -242,7 +242,7 @@ class PersonType extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Person::class,
'data_class' => PersonEditDTO::class,
]);
$resolver->setRequired([

View File

@@ -32,6 +32,7 @@ class PersonAltNameType extends AbstractType
[
'label' => $label,
'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\PersonIdentifierDefinition;
use Chill\PersonBundle\PersonIdentifier\IdentifierViolationDTO;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class StringIdentifier implements PersonIdentifierEngineInterface
{
public const NAME = 'chill-person-bundle.string-identifier';
private const ONLY_NUMBERS = 'only_numbers';
private const FIXED_LENGTH = 'fixed_length';
public static function getName(): string
{
return 'chill-person-bundle.string-identifier';
return self::NAME;
}
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string
{
return $value['content'] ?? '';
return trim($value['content'] ?? '');
}
public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void
@@ -36,6 +42,37 @@ final readonly class StringIdentifier implements PersonIdentifierEngineInterface
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string
{
return $identifier?->getValue()['content'] ?? '';
return trim($identifier?->getValue()['content'] ?? '');
}
public function isEmpty(PersonIdentifier $identifier): bool
{
return '' === trim($identifier->getValue()['content'] ?? '');
}
public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array
{
$config = $definition->getData();
$content = (string) ($identifier->getValue()['content'] ?? '');
$violations = [];
if (($config[self::ONLY_NUMBERS] ?? false) && !preg_match('/^[0-9]+$/', $content)) {
$violations[] = new IdentifierViolationDTO('person_identifier.only_number', '2a3352c0-a2b9-11f0-a767-b7a3f80e52f1');
}
if (null !== ($config[self::FIXED_LENGTH] ?? null) && strlen($content) !== $config[self::FIXED_LENGTH]) {
$violations[] = new IdentifierViolationDTO(
'person_identifier.fixed_length',
'2b02a8fe-a2b9-11f0-bfe5-033300972783',
['limit' => (string) $config[self::FIXED_LENGTH]]
);
}
return $violations;
}
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;
/**
* @phpstan-pure
*/
public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string;
public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void;
public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string;
/**
* Return true if the identifier must be considered as empty.
*
* This is in use when the identifier is validated and must be required. If the identifier is empty and is required
* by the definition, the validation will fails.
*/
public function isEmpty(PersonIdentifier $identifier): bool;
/**
* Return a list of @see{IdentifierViolationDTO} to generatie violation errors.
*
* @return list<IdentifierViolationDTO>
*/
public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array;
public function getDefaultValue(PersonIdentifierDefinition $definition): array;
}

View File

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

View File

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

View File

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

View File

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

View File

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

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,116 @@
import { fetchResults, makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import {Center, Civility, Gender, SetCenter} from "ChillMainAssets/types";
import {
AltName,
Person, PersonIdentifier,
PersonIdentifierWorker,
PersonWrite,
} from "ChillPersonAssets/types";
import person from "ChillPersonAssets/vuejs/_components/OnTheFly/Person.vue";
/*
* GET a person by id
*/
export const getPerson = async (id: number): Promise<Person> => {
const url = `/api/1.0/person/person/${id}.json`;
return fetch(url).then((response) => {
if (response.ok) {
return response.json();
}
throw Error("Error with request resource response");
});
};
export const personToWritePerson = (person: Person): PersonWrite => {
return {
type: "person",
firstName: person.firstName,
lastName: person.lastName,
altNames: person.altNames.map((altName) => ({key: altName.key, value: altName.label})),
addressId: null,
birthdate: null === person.birthdate ? null : {datetime: person.birthdate.datetime8601},
deathdate: null === person.deathdate ? null : {datetime: person.deathdate.datetime8601},
phonenumber: person.phonenumber,
mobilenumber: person.mobilenumber,
center: null === person.centers ? null : person.centers
.map((center): SetCenter => ({id: center.id, type: "center"}))
.find(() => true) || null,
email: person.email,
civility: null === person.civility ? null : {id: person.civility.id, type: "chill_main_civility"},
gender: null === person.gender ? null : {id: person.gender.id, type: "chill_main_gender"},
identifiers: person.identifiers.map((identifier: PersonIdentifier) => ({type: "person_identifier", definition_id: identifier.definition.id, value: identifier.value})),
}
}
export const getPersonAltNames = async (): Promise<AltName[]> =>
fetch("/api/1.0/person/config/alt_names.json").then((response) => {
if (response.ok) {
return response.json();
}
throw Error("Error with request resource response");
});
export const getCivilities = async (): Promise<Civility[]> =>
fetchResults("/api/1.0/main/civility.json");
export const getGenders = async (): Promise<Gender[]> =>
fetchResults("/api/1.0/main/gender.json");
export const getCentersForPersonCreation = async (): Promise<{
showCenters: boolean;
centers: Center[];
}> => makeFetch("GET", "/api/1.0/person/creation/authorized-centers", null);
export const getPersonIdentifiers = async (): Promise<
PersonIdentifierWorker[]
> => fetchResults("/api/1.0/person/identifiers/workers");
export interface WritePersonViolationMap
extends Record<string, Record<string, string>> {
firstName: {
"{{ value }}": string
};
lastName: {
"{{ value }}": string;
};
gender: {
"{{ value }}": string;
};
mobilenumber: {
"{{ types }}": string; // ex: "mobile number"
"{{ value }}": string; // ex: "+33 1 02 03 04 05"
};
phonenumber: {
"{{ types }}": string; // ex: "mobile number"
"{{ value }}": string; // ex: "+33 1 02 03 04 05"
};
email: {
"{{ value }}": string;
};
center: {
"{{ value }}": string;
};
civility: {
"{{ value }}": string;
};
birthdate: {};
identifiers: {
"{{ value }}": string;
"definition_id": string;
};
}
export const createPerson = async (person: PersonWrite): Promise<Person> => {
return makeFetch<PersonWrite, Person, WritePersonViolationMap>(
"POST",
"/api/1.0/person/person.json",
person,
);
};
export const editPerson = async (person: PersonWrite, personId: number): Promise<Person> => {
return makeFetch<PersonWrite, Person, WritePersonViolationMap>(
"PATCH",
`/api/1.0/person/person/${personId}.json`,
person,
);
}

View File

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

View File

@@ -0,0 +1,345 @@
<template>
<teleport to="body">
<modal
@close="() => emit('close')"
:modal-dialog-class="modalDialogClass"
:hide-footer="false"
>
<template #header>
<h3 class="modal-title">{{ modalTitle }}</h3>
</template>
<template #body-head>
<div class="modal-body">
<div class="search">
<label class="col-form-label" style="float: right">
{{
trans(ADD_PERSONS_SUGGESTED_COUNTER, {
count: suggestedCounter,
})
}}
</label>
<input
id="search-persons"
name="query"
v-model="query"
:placeholder="trans(ADD_PERSONS_SEARCH_SOME_PERSONS)"
ref="searchRef"
/>
<i class="fa fa-search fa-lg" />
<i
class="fa fa-times"
v-if="queryLength >= 3"
@click="resetSuggestion"
/>
</div>
</div>
<div class="modal-body" v-if="checkUniq === 'checkbox'">
<div class="count">
<span>
<a v-if="suggestedCounter > 2" @click="selectAll">
{{ trans(ACTION_CHECK_ALL) }}
</a>
<a v-if="selectedCounter > 0" @click="resetSelection">
<i v-if="suggestedCounter > 2"> </i>
{{ trans(ACTION_RESET) }}
</a>
</span>
<span v-if="selectedCounter > 0">
{{
trans(ADD_PERSONS_SELECTED_COUNTER, { count: selectedCounter })
}}
</span>
</div>
</div>
</template>
<template #body>
<div class="results">
<person-suggestion
v-for="item in selectedAndSuggested"
:key="item.key"
:item="item"
:isSelected="item.isSelected"
:type="checkUniq"
@update-selected="(payload) => emit('updateSelected', payload)"
@trigger-add-contact="triggerAddContact"
/>
<div v-if="hasNoResult">
<div class="noResult chill-no-data-statement">
{{
trans(ADD_PERSONS_SUGGESTED_COUNTER, {
count: suggestedCounter,
})
}}
</div>
</div>
<div
v-if="props.allowCreate && query.length > 0"
class="create-button"
>
<button type="button" class="btn btn-submit" @click="emit('onAskForCreate', { query })">
{{ trans(ONTHEFLY_CREATE_BUTTON, { q: query }) }}
</button>
</div>
</div>
</template>
<template #footer>
<button
type="button"
class="btn btn-create"
@click.prevent="pickEntities"
>
{{ trans(ACTION_ADD) }}
</button>
</template>
</modal>
</teleport>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick, watch, onMounted } from "vue";
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import PersonSuggestion from "./PersonSuggestion.vue";
import { searchEntities } from "ChillPersonAssets/vuejs/_api/AddPersons";
import {
trans,
ADD_PERSONS_SUGGESTED_COUNTER,
ADD_PERSONS_SEARCH_SOME_PERSONS,
ADD_PERSONS_SELECTED_COUNTER,
ONTHEFLY_CREATE_BUTTON,
ACTION_CHECK_ALL,
ACTION_RESET,
ACTION_ADD,
} from "translator";
import type {
Suggestion,
Search,
SearchOptions,
Entities,
} from "ChillPersonAssets/types";
import {ThirdpartyCompany} from "../../../../../../ChillThirdPartyBundle/Resources/public/types";
interface Props {
modalTitle: string;
options: SearchOptions;
suggested?: Suggestion[];
selected: Map<string, Suggestion>;
modalDialogClass?: string;
allowCreate?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
suggested: () => [],
modalDialogClass: "modal-dialog-scrollable modal-xl",
allowCreate: () => true,
});
const emit = defineEmits<{
(e: "close"): void;
(e: "onPickEntities"): void;
(e: "onAskForCreate", payload: { query: string }): void;
(e: "triggerAddContact", payload: { parent: ThirdpartyCompany }): void;
(e: "updateSelected", payload: {suggestion: Suggestion, isSelected: boolean}): void;
(e: "cleanSelected"): void;
}>();
const searchRef = ref<HTMLInputElement | null>(null);
onMounted(() => {
// give the focus on the search bar
searchRef.value?.focus();
});
const search = reactive({
query: "" as string,
previousQuery: "" as string,
currentSearchQueryController: null as AbortController | null,
priorSuggestion: {} as Partial<Suggestion>,
hasPreviousQuery: false,
});
/**
* Contains the suggested entities from the search results.
*
* In other words, those entities are displayed and selectable by the user
*/
const suggested = ref<Suggestion[]>([]);
const query = computed({
get: () => search.query,
set: (val: string) => setQuery(val),
});
const queryLength = computed(() => search.query.length);
const suggestedCounter = computed(() => suggested.value.length);
const selectedCounter = computed(() => props.selected.size);
const checkUniq = computed(() =>
props.options.uniq ? "radio" : "checkbox",
);
const selectedAndSuggested = computed<(Suggestion & {isSelected: boolean})[]>(() => {
const selectedAndSuggested = [];
// add selected that are not in the search results
for (const selected of props.selected.values()) {
if (!suggested.value.some((s: Suggestion) => s.key === selected.key)) {
selectedAndSuggested.push({...selected, isSelected: false});
}
}
for (const suggestion of suggested.value) {
selectedAndSuggested.push({...suggestion, isSelected: props.selected.has(suggestion.key)})
}
return selectedAndSuggested;
});
const hasNoResult = computed(() => search.hasPreviousQuery && suggested.value.length === 0);
function setQuery(q: string) {
search.query = q;
if (search.currentSearchQueryController) {
search.currentSearchQueryController.abort();
search.currentSearchQueryController = null;
}
if (q === "") {
loadSuggestions([]);
return;
}
const delay = q.length > 3 ? 300 : 700;
setTimeout(() => {
if (q !== search.query) return;
search.currentSearchQueryController = new AbortController();
searchEntities(
{ query: q, options: props.options },
search.currentSearchQueryController.signal,
)
.then((suggested: Search) => {
loadSuggestions(suggested.results);
search.hasPreviousQuery = true;
})
.catch((error: DOMException) => {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
throw error;
});
}, delay);
}
function loadSuggestions(suggestedArr: {relevance: number, result: Entities}[]): void {
suggested.value = suggestedArr.map((item) => {
return {
key: item.result.type + item.result.id,
relevance: item.relevance,
result: item.result
}
});
}
function resetSuggestion() {
search.query = "";
suggested.value = [];
}
function resetSelection() {
emit("cleanSelected");
}
function resetSearch() {
resetSelection();
resetSuggestion();
}
function selectAll() {
suggested.value.forEach((suggestion: Suggestion) => {
emit("updateSelected", {suggestion, isSelected: true})
});
}
function triggerAddContact(payload: {parent: ThirdpartyCompany}) {
emit("triggerAddContact", payload);
}
/**
* Triggered when the user clicks on the "add" button.
*/
function pickEntities(): void {
emit("onPickEntities", );
search.query = "";
emit("close");
}
defineExpose({ resetSearch });
</script>
<style lang="scss" scoped>
li.add-persons {
a {
cursor: pointer;
}
}
div.body-head {
overflow-y: unset;
div.modal-body:first-child {
margin: auto 4em;
div.search {
position: relative;
input {
width: 100%;
padding: 1.2em 1.5em 1.2em 2.5em;
}
i {
position: absolute;
opacity: 0.5;
padding: 0.65em 0;
top: 50%;
}
i.fa-search {
left: 0.5em;
}
i.fa-times {
right: 1em;
padding: 0.75em 0;
cursor: pointer;
}
}
}
div.modal-body:last-child {
padding-bottom: 0;
}
div.count {
margin: -0.5em 0 0.7em;
display: flex;
justify-content: space-between;
a {
cursor: pointer;
}
}
}
.create-button > button {
margin-top: 0.5em;
margin-left: 0.6em;
}
.noResult {
text-align: center;
margin: 2em;
font-size: large;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="action === 'show'">
<div v-if="action === 'show' && person !== null">
<div class="flex-table">
<person-render-box
render="bloc"
@@ -21,446 +21,31 @@
</div>
</div>
<div v-else-if="action === 'edit' || action === 'create'">
<div class="form-floating mb-3">
<input
class="form-control form-control-lg"
id="lastname"
v-model="lastName"
:placeholder="trans(PERSON_MESSAGES_PERSON_LASTNAME)"
@change="checkErrors"
/>
<label for="lastname">{{ trans(PERSON_MESSAGES_PERSON_LASTNAME) }}</label>
</div>
<div v-if="queryItems">
<ul class="list-suggest add-items inline">
<li
v-for="(qi, i) in queryItems"
:key="i"
@click="addQueryItem('lastName', qi)"
>
<span class="person-text">{{ qi }}</span>
</li>
</ul>
</div>
<div class="form-floating mb-3">
<input
class="form-control form-control-lg"
id="firstname"
v-model="firstName"
:placeholder="trans(PERSON_MESSAGES_PERSON_FIRSTNAME)"
@change="checkErrors"
/>
<label for="firstname">{{
trans(PERSON_MESSAGES_PERSON_FIRSTNAME)
}}</label>
</div>
<div v-if="queryItems">
<ul class="list-suggest add-items inline">
<li
v-for="(qi, i) in queryItems"
:key="i"
@click="addQueryItem('firstName', qi)"
>
<span class="person-text">{{ qi }}</span>
</li>
</ul>
</div>
<div
v-for="(a, i) in config.altNames"
:key="a.key"
class="form-floating mb-3"
>
<input
class="form-control form-control-lg"
:id="a.key"
:value="personAltNamesLabels[i]"
@input="onAltNameInput"
/>
<label :for="a.key">{{ localizeString(a.labels) }}</label>
</div>
<!-- TODO fix placeholder if undefined
-->
<div class="form-floating mb-3">
<select class="form-select form-select-lg" id="gender" v-model="gender">
<option selected disabled>
{{ trans(PERSON_MESSAGES_PERSON_GENDER_PLACEHOLDER) }}
</option>
<option v-for="g in config.genders" :value="g.id" :key="g.id">
{{ g.label }}
</option>
</select>
<label>{{ trans(PERSON_MESSAGES_PERSON_GENDER_TITLE) }}</label>
</div>
<div
class="form-floating mb-3"
v-if="showCenters && config.centers.length > 1"
>
<select class="form-select form-select-lg" id="center" v-model="center">
<option selected disabled>
{{ trans(PERSON_MESSAGES_PERSON_CENTER_PLACEHOLDER) }}
</option>
<option v-for="c in config.centers" :value="c" :key="c.id">
{{ c.name }}
</option>
</select>
<label>{{ trans(PERSON_MESSAGES_PERSON_CENTER_TITLE) }}</label>
</div>
<div class="form-floating mb-3">
<select
class="form-select form-select-lg"
id="civility"
v-model="civility"
>
<option selected disabled>
{{ trans(PERSON_MESSAGES_PERSON_CIVILITY_PLACEHOLDER) }}
</option>
<option v-for="c in config.civilities" :value="c.id" :key="c.id">
{{ localizeString(c.name) }}
</option>
</select>
<label>{{ trans(PERSON_MESSAGES_PERSON_CIVILITY_TITLE) }}</label>
</div>
<div class="input-group mb-3">
<span class="input-group-text" id="phonenumber">
<i class="fa fa-fw fa-phone"></i>
</span>
<input
class="form-control form-control-lg"
v-model="phonenumber"
:placeholder="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)"
:aria-label="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)"
aria-describedby="phonenumber"
/>
</div>
<div class="input-group mb-3">
<span class="input-group-text" id="mobilenumber">
<i class="fa fa-fw fa-mobile"></i>
</span>
<input
class="form-control form-control-lg"
v-model="mobilenumber"
:placeholder="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)"
:aria-label="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)"
aria-describedby="mobilenumber"
/>
</div>
<div class="input-group mb-3">
<span class="input-group-text" id="email">
<i class="fa fa-fw fa-at"></i>
</span>
<input
class="form-control form-control-lg"
v-model="email"
:placeholder="trans(PERSON_MESSAGES_PERSON_EMAIL)"
:aria-label="trans(PERSON_MESSAGES_PERSON_EMAIL)"
aria-describedby="email"
/>
</div>
<div v-if="action === 'create'" class="input-group mb-3 form-check">
<input
class="form-check-input"
type="checkbox"
v-model="showAddressForm"
name="showAddressForm"
/>
<label class="form-check-label">
{{ trans(PERSON_MESSAGES_PERSON_ADDRESS_SHOW_ADDRESS_FORM) }}
</label>
</div>
<div
v-if="action === 'create' && showAddressFormValue"
class="form-floating mb-3"
>
<p>{{ trans(PERSON_MESSAGES_PERSON_ADDRESS_WARNING) }}</p>
<AddAddress
:context="addAddress.context"
:options="addAddress.options"
:addressChangedCallback="submitNewAddress"
ref="addAddress"
/>
</div>
<div class="alert alert-warning" v-if="errors.length">
<ul>
<li v-for="(e, i) in errors" :key="i">{{ e }}</li>
</ul>
</div>
<div v-else-if="props.action === 'edit' || props.action === 'create'">
<PersonEdit
:id="props.id"
:type="props.type"
:action="props.action"
:query="props.query"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from "vue";
import {
getCentersForPersonCreation,
getCivilities,
getGenders,
getPerson,
getPersonAltNames,
} from "../../_api/OnTheFly";
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { getPerson } from "../../_api/OnTheFly";
import PersonRenderBox from "../Entity/PersonRenderBox.vue";
import AddAddress from "ChillMainAssets/vuejs/Address/components/AddAddress.vue";
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
import {
trans,
PERSON_MESSAGES_PERSON_LASTNAME,
PERSON_MESSAGES_PERSON_FIRSTNAME,
PERSON_MESSAGES_PERSON_GENDER_PLACEHOLDER,
PERSON_MESSAGES_PERSON_GENDER_TITLE,
PERSON_MESSAGES_PERSON_CENTER_PLACEHOLDER,
PERSON_MESSAGES_PERSON_CENTER_TITLE,
PERSON_MESSAGES_PERSON_CIVILITY_PLACEHOLDER,
PERSON_MESSAGES_PERSON_CIVILITY_TITLE,
PERSON_MESSAGES_PERSON_PHONENUMBER,
PERSON_MESSAGES_PERSON_MOBILENUMBER,
PERSON_MESSAGES_PERSON_EMAIL,
PERSON_MESSAGES_PERSON_ADDRESS_SHOW_ADDRESS_FORM,
PERSON_MESSAGES_PERSON_ADDRESS_WARNING,
} from "translator";
import PersonEdit from "./PersonEdit.vue";
import type { Person } from "ChillPersonAssets/types";
const props = defineProps({
id: [String, Number],
type: String,
action: String,
query: String,
});
const person = reactive({
type: "person",
lastName: "",
firstName: "",
altNames: [],
addressId: null,
center: null,
gender: null,
civility: null,
birthdate: null,
phonenumber: "",
mobilenumber: "",
email: "",
});
const config = reactive({
altNames: [],
civilities: [],
centers: [],
genders: [],
});
const showCenters = ref(false);
const showAddressFormValue = ref(false);
const errors = ref([]);
const addAddress = reactive({
options: {
button: {
text: { create: "person.address.create_address" },
size: "btn-sm",
},
title: { create: "person.address.create_address" },
},
context: {
target: {},
edit: false,
addressId: null,
defaults: window.addaddress,
},
});
const firstName = computed({
get: () => person.firstName,
set: (value) => {
person.firstName = value;
},
});
const lastName = computed({
get: () => person.lastName,
set: (value) => {
person.lastName = value;
},
});
const gender = computed({
get: () => (person.gender ? person.gender.id : null),
set: (value) => {
person.gender = { id: value, type: "chill_main_gender" };
},
});
const civility = computed({
get: () => (person.civility ? person.civility.id : null),
set: (value) => {
person.civility = { id: value, type: "chill_main_civility" };
},
});
const birthDate = computed({
get: () => (person.birthdate ? person.birthdate.datetime.split("T")[0] : ""),
set: (value) => {
if (person.birthdate) {
person.birthdate.datetime = value + "T00:00:00+0100";
} else {
person.birthdate = { datetime: value + "T00:00:00+0100" };
}
},
});
const phonenumber = computed({
get: () => person.phonenumber,
set: (value) => {
person.phonenumber = value;
},
});
const mobilenumber = computed({
get: () => person.mobilenumber,
set: (value) => {
person.mobilenumber = value;
},
});
const email = computed({
get: () => person.email,
set: (value) => {
person.email = value;
},
});
const showAddressForm = computed({
get: () => showAddressFormValue.value,
set: (value) => {
showAddressFormValue.value = value;
},
});
const center = computed({
get: () => {
const c = config.centers.find(
(c) => person.center !== null && person.center.id === c.id,
);
return typeof c === "undefined" ? null : c;
},
set: (value) => {
person.center = { id: value.id, type: value.type };
},
});
const genderClass = computed(() => {
switch (person.gender && person.gender.id) {
case "woman":
return "fa-venus";
case "man":
return "fa-mars";
case "both":
return "fa-neuter";
case "unknown":
return "fa-genderless";
default:
return "fa-genderless";
}
});
const genderTranslation = computed(() => {
switch (person.gender && person.gender.genderTranslation) {
case "woman":
return PERSON_MESSAGES_PERSON_GENDER_WOMAN;
case "man":
return PERSON_MESSAGES_PERSON_GENDER_MAN;
case "neutral":
return PERSON_MESSAGES_PERSON_GENDER_NEUTRAL;
case "unknown":
return PERSON_MESSAGES_PERSON_GENDER_UNKNOWN;
default:
return PERSON_MESSAGES_PERSON_GENDER_UNKNOWN;
}
});
const feminized = computed(() =>
person.gender && person.gender.id === "woman" ? "e" : "",
);
const personAltNamesLabels = computed(() =>
person.altNames.map((a) => (a ? a.label : "")),
);
const queryItems = computed(() =>
props.query ? props.query.split(" ") : null,
);
function checkErrors() {
errors.value = [];
if (person.lastName === "") {
errors.value.push("Le nom ne doit pas être vide.");
}
if (person.firstName === "") {
errors.value.push("Le prénom ne doit pas être vide.");
}
if (!person.gender) {
errors.value.push("Le genre doit être renseigné");
}
if (showCenters.value && person.center === null) {
errors.value.push("Le centre doit être renseigné");
}
interface Props {
id: number;
type?: string;
action: "show" | "edit" | "create";
query?: string;
}
function loadData() {
getPerson(props.id).then((p) => {
Object.assign(person, p);
});
}
const props = withDefaults(defineProps<Props>(), {query: ""});
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;
}
const person = ref<Person | null>(null);
function addQueryItem(field, queryItem) {
switch (field) {
case "lastName":
person.lastName = person.lastName
? (person.lastName += ` ${queryItem}`)
: queryItem;
break;
case "firstName":
person.firstName = person.firstName
? (person.firstName += ` ${queryItem}`)
: queryItem;
break;
}
}
function submitNewAddress(payload) {
person.addressId = payload.addressId;
}
onMounted(() => {
getPersonAltNames().then((altNames) => {
config.altNames = altNames;
});
getCivilities().then((civilities) => {
if ("results" in civilities) {
config.civilities = civilities.results;
}
});
getGenders().then((genders) => {
if ("results" in genders) {
config.genders = genders.results;
}
});
if (props.action !== "create") {
loadData();
} else {
getCentersForPersonCreation().then((params) => {
config.centers = params.centers.filter((c) => c.isActive);
showCenters.value = params.showCenters;
if (showCenters.value && config.centers.length === 1) {
person.center = config.centers[0];
}
});
}
});
defineExpose(genderClass, genderTranslation, feminized, birthDate);
</script>

View File

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

View File

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

View File

@@ -140,9 +140,7 @@
<fieldset>
<legend><h2>{{ 'person.Identifiers'|trans }}</h2></legend>
<div>
{% for f in form.identifiers %}
{{ form_row(f) }}
{% endfor %}
{{ form_widget(form.identifiers) }}
</div>
</fieldset>
{% 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((string) $altNameData['key'])
) {
throw new UnexpectedValueException('format for alt name is not correct');
}
$altNameKey = $altNameData['key'];
$altNameValue = $altNameData['value'];
$altName = $person->getAltNames()->findFirst(fn (int $key, PersonAltName $personAltName) => $personAltName->getKey() === $altNameKey);
if (null === $altName) {
$altName = new PersonAltName();
$person->addAltName($altName);
}
$altName->setKey($altNameKey)->setLabel($altNameValue);
}
}
if (\array_key_exists('identifiers', $data)) {
foreach ($data['identifiers'] as $identifierData) {
if (!array_key_exists('definition_id', $identifierData)
|| !array_key_exists('value', $identifierData)
|| !is_int($identifierData['definition_id'])
|| !is_array($identifierData['value'])
) {
throw new UnexpectedValueException('format for identifiers is not correct');
}
$definitionId = $identifierData['definition_id'];
$value = $identifierData['value'];
$worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($definitionId);
if (!$worker->getDefinition()->isEditableByUsers()) {
continue;
}
$personIdentifier = $person->getIdentifiers()->findFirst(fn (int $key, PersonIdentifier $personIdentifier) => $personIdentifier->getDefinition()->getId() === $definitionId);
if (null === $personIdentifier) {
$personIdentifier = new PersonIdentifier($worker->getDefinition());
$person->addIdentifier($personIdentifier);
}
$personIdentifier->setValue($value);
$personIdentifier->setCanonical($worker->canonicalizeValue($value));
if ($worker->isEmpty($personIdentifier)) {
$person->removeIdentifier($personIdentifier);
}
}
}
if (\array_key_exists('email', $data)) {
$person->setEmail($data['email']);
}
if (\array_key_exists('civility', $data) && null !== $data['civility']) {
$civility = $this->denormalizer->denormalize($data['civility'], Civility::class, $format, []);
$person->setCivility($civility);
}
return $person;
}
public function supportsDenormalization($data, $type, $format = null): bool
{
return Person::class === $type && 'person' === ($data['type'] ?? null) && !isset($data['id']);
}
}

View File

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

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

View File

@@ -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);
}
}

View File

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

View File

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

View File

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

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