Compare commits

...

39 Commits

Author SHA1 Message Date
6380fdd9a4 Normalize address search terms by adding UNACCENT and LOWER transformation to AddressReferenceRepository. 2025-10-05 21:04:40 +02:00
fcd5080e6f [wip] Enhance AddressPicker to auto-select position when a single address with one position is found. 2025-10-05 21:04:39 +02:00
086d418aa3 [wip] Refactor AddressDetailsMap to use vue-use-leaflet and enhance AddressPicker layout with dynamic styling adjustments. 2025-10-05 21:04:39 +02:00
4e61821e5b [WIP] refactorization to show details of an address 2025-10-05 21:04:39 +02:00
e3aeab315f [WIP] Create Address button that will open AddressPicker in a modal 2025-10-05 21:04:38 +02:00
8ec1063ef8 [WIP] Add postal code search integration to AddressPicker
Implemented `getPostalCodes` function in `local-search` driver and connected it with `AddressPicker.vue`. Introduced UI changes to display postal codes alongside addresses and ensured search requests are abortable.
2025-10-05 21:04:38 +02:00
aad9c984b1 [WIP] Add postal code search endpoint and controller integration
Introduced a new API endpoint `/api/1.0/main/address-reference/postal-code/search` for searching postal codes matching a query string. Implemented `PostalCodeForAddressReferenceApiController` to handle requests and integrated with `PostalCodeForAddressReferenceRepository`. Enhanced repository to include `country_name` in results by decoding JSON data. Updated API specifications accordingly.
2025-10-05 21:04:38 +02:00
34b3e290e1 [WIP] Add PostalCodeForAddressReferenceRepository and associated tests
Introduced `PostalCodeForAddressReferenceRepository` and its interface to support optimized postal code search using materialized views. Updated `AddressReferenceRepository` to improve query handling. Added test coverage for the new repository functionality.
2025-10-05 21:04:37 +02:00
0987b575ab [WIP] Refactor AddressReferenceRepository to use interface and add tests for AddressReferenceAggregatedApiController 2025-10-05 21:04:37 +02:00
d960578c5f [WIP] Integrate local aggregated address search in AddressPicker
Added a `local-search` driver to support aggregated address fetching. Integrated the `getAddressesAggregated` function with `AddressPicker.vue` for dynamic search suggestions and abortable fetch requests.
2025-10-05 21:04:37 +02:00
176048bce6 [WIP] Add aggregated address search API endpoint
Introduced a new API endpoint `/api/1.0/main/address-reference/aggregated/search` for aggregated address reference search with support for query filtering. Extended repository with `findAggregatedBySearchString` method and updated materialized view `view_chill_main_address_reference`. Added test coverage and API specification details.
2025-10-05 21:04:36 +02:00
be210a6dd6 [WIP] initialize search bar 2025-10-05 21:04:36 +02:00
4323773595 [WIP] initialize an app for address-picker + a demo page 2025-10-05 21:04:32 +02:00
6d432ca2cb Add materialized view and repository methods for address search
Introduced a materialized view `view_chill_main_address_reference` to optimize address search queries and added corresponding repository methods `findBySearchString` and `countBySearchString`. Also included test coverage for the repository to validate the new functionality.
2025-10-05 21:04:15 +02:00
c2d9c73fd4 Release v4.5.1 2025-10-03 14:11:41 +02:00
0d6d15fcf7 Merge branch 'fix/conversion-exception' into 'master'
Introduce `ConversionWithSameMimeTypeException` for improved error handling in document conversion.

See merge request Chill-Projet/chill-bundles!892
2025-10-03 12:10:24 +00:00
f9ad96c78b Introduce ConversionWithSameMimeTypeException for improved error handling in document conversion.
- Added the `ConversionWithSameMimeTypeException` to handle cases where document conversion is requested for the same MIME type.
- Updated `StoredObjectToPdfConverter` to throw the new exception when encountering such cases.
- Enhanced error logging in `PostSendExternalMessageHandler` to capture these specific conversion errors.
2025-10-03 13:57:06 +02:00
fcc9529a20 Add missing javascript dependency in package.json 2025-10-03 13:56:20 +02:00
955cb817c4 Release v4.5.0 2025-10-03 12:09:17 +02:00
823f9546b9 Merge branch '421-signature-fixes' into 'master'
Signature fixes

Closes #421

See merge request Chill-Projet/chill-bundles!887
2025-10-03 09:49:34 +00:00
be39fa16e7 Signature fixes 2025-10-03 09:49:33 +00:00
c8bb7575e7 Merge branch '426-increase_nb_chars_to_14_chill_password' into 'master'
#426 Increased the number of required characters when setting a new password in Chill

Closes #426

See merge request Chill-Projet/chill-bundles!883
2025-09-19 07:03:51 +00:00
juminet
80a3734171 #426 Increased the number of required characters when setting a new password in Chill 2025-09-19 07:03:51 +00:00
ab98f3a102 Release v4.4.2 2025-09-12 12:47:06 +02:00
7516e68d77 Merge branch 'fix/docgen-after-accp-work-refacto' into 'master'
Fix document generation and workflow generation do not work on accompanying period work documents

See merge request Chill-Projet/chill-bundles!880
2025-09-12 10:42:34 +00:00
7b60b7a8af Fix document generation and workflow generation do not work on accompanying period work documents 2025-09-12 10:42:34 +00:00
d984dec7db Release v4.4.1 2025-09-11 16:26:51 +02:00
46a4dedab8 Merge branch 'missing_commit_duplicate_evaluation' into 'master'
Fix translations and close button modal for duplicate evaluation document

See merge request Chill-Projet/chill-bundles!878
2025-09-11 14:21:05 +00:00
db98519e65 Fix translations and close button modal for duplicate evaluation document 2025-09-11 14:21:05 +00:00
c39637180a Release v4.4.0 2025-09-11 13:04:50 +02:00
15f9409bc8 Merge branch '369-duplicate-evaluation-document' into 'master'
Resolve "Dupliquer une document d'une évaluation vers une autre" + "Déplacer un document vers une autre évaluation"

Closes #369

See merge request Chill-Projet/chill-bundles!813
2025-09-11 11:01:16 +00:00
5b90d23367 Resolve "Dupliquer une document d'une évaluation vers une autre" + "Déplacer un document vers une autre évaluation" 2025-09-11 11:01:16 +00:00
c48625d1cd Merge branch 'bug/1607-the-user-preferences-for-notification-in-profile-are-not-shown-correctly' into 'master'
Resolve "user notification preferences are not displayed correctly"

See merge request Chill-Projet/chill-bundles!877
2025-09-10 16:28:45 +00:00
1195b54a68 Resolve "user notification preferences are not displayed correctly" 2025-09-10 16:28:45 +00:00
2a280b814f Refactor view templates: relocate 'merge' action block and standardize 'duplicate link' block handling 2025-09-09 17:36:46 +02:00
230c758255 Update bundles to v4.3.0 2025-09-08 16:05:09 +02:00
eafda987ae Merge branch '412-absence-enddate' into 'master'
Resolve "Absence user: add end date"

Closes #412

See merge request Chill-Projet/chill-bundles!865
2025-09-08 13:47:14 +00:00
7db8a371fc Resolve "Absence user: add end date" 2025-09-08 13:47:14 +00:00
0d0649dd31 Change route URL to avoid clash with person duplicate controller method 2025-09-08 14:51:54 +02:00
138 changed files with 5032 additions and 1242 deletions

View File

@@ -1,6 +0,0 @@
kind: Feature
body: Add a command to generate a list of permissions
time: 2025-09-04T18:10:32.334524026+02:00
custom:
Issue: ""
SchemaChange: No schema change

10
.changes/v4.3.0.md Normal file
View File

@@ -0,0 +1,10 @@
## v4.3.0 - 2025-09-08
### Feature
* ([#409](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/409)) Add 45 and 60 min calendar ranges
* Add a command to generate a list of permissions
* ([#412](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/412)) Add an absence end date
**Schema Change**: Add columns or tables
### Fixed
* fix date formatting in calendar range display
* Change route URL to avoid clash with person duplicate controller method

8
.changes/v4.4.0.md Normal file
View File

@@ -0,0 +1,8 @@
## v4.4.0 - 2025-09-11
### Feature
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Allow the merge of two accompanying period works
* ([#369](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/369)) Duplication of a document to another accompanying period work evaluation
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Fusion of two accompanying period works
### Fixed
* Fix display of 'duplicate' and 'merge' buttons in CRUD templates
* Fix saving notification preferences in user's profile

3
.changes/v4.4.1.md Normal file
View File

@@ -0,0 +1,3 @@
## v4.4.1 - 2025-09-11
### Fixed
* fix translations in duplicate evaluation document modal and realign close modal button

3
.changes/v4.4.2.md Normal file
View File

@@ -0,0 +1,3 @@
## v4.4.2 - 2025-09-12
### Fixed
* Fix document generation and workflow generation do not work on accompanying period work documents

13
.changes/v4.5.0.md Normal file
View File

@@ -0,0 +1,13 @@
## v4.5.0 - 2025-10-03
### Feature
* Only allow delete of attachment on workflows that are not final
* Move up signature buttons on index workflow page for easier access
* Filter out document from attachment list if it is the same as the workflow document
* Block edition on attached document on workflow, if the workflow is finalized or sent external
* Convert workflow's attached document to pdf while sending them external
* After a signature is canceled or rejected, going to a waiting page until the post-process routines apply a workflow transition
### Fixed
* ([#426](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/426)) Increased the number of required characters when setting a new password in Chill from 9 to 14 - GDPR compliance
* Fix permissions on storedObject which are subject by a workflow
### DX
* Introduce a WaitingScreen component to display a waiting screen

4
.changes/v4.5.1.md Normal file
View File

@@ -0,0 +1,4 @@
## v4.5.1 - 2025-10-03
### Fixed
* Add missing javascript dependency
* Add exception handling for conversion of attachment on sending external, when documens are already in pdf

View File

@@ -6,6 +6,53 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie). and is generated by [Changie](https://github.com/miniscruff/changie).
## v4.5.1 - 2025-10-03
### Fixed
* Add missing javascript dependency
* Add exception handling for conversion of attachment on sending external, when documens are already in pdf
## v4.5.0 - 2025-10-03
### Feature
* Only allow delete of attachment on workflows that are not final
* Move up signature buttons on index workflow page for easier access
* Filter out document from attachment list if it is the same as the workflow document
* Block edition on attached document on workflow, if the workflow is finalized or sent external
* Convert workflow's attached document to pdf while sending them external
* After a signature is canceled or rejected, going to a waiting page until the post-process routines apply a workflow transition
### Fixed
* ([#426](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/426)) Increased the number of required characters when setting a new password in Chill from 9 to 14 - GDPR compliance
* Fix permissions on storedObject which are subject by a workflow
### DX
* Introduce a WaitingScreen component to display a waiting screen
## v4.4.2 - 2025-09-12
### Fixed
* Fix document generation and workflow generation do not work on accompanying period work documents
## v4.4.1 - 2025-09-11
### Fixed
* fix translations in duplicate evaluation document modal and realign close modal button
## v4.4.0 - 2025-09-11
### Feature
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Allow the merge of two accompanying period works
* ([#369](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/369)) Duplication of a document to another accompanying period work evaluation
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Fusion of two accompanying period works
### Fixed
* Fix display of 'duplicate' and 'merge' buttons in CRUD templates
* Fix saving notification preferences in user's profile
## v4.3.0 - 2025-09-08
### Feature
* ([#409](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/409)) Add 45 and 60 min calendar ranges
* Add a command to generate a list of permissions
* ([#412](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/412)) Add an absence end date
**Schema Change**: Add columns or tables
### Fixed
* fix date formatting in calendar range display
* Change route URL to avoid clash with person duplicate controller method
## v4.2.1 - 2025-09-03 ## v4.2.1 - 2025-09-03
### Fixed ### Fixed
* Fix exports to work with DirectExportInterface * Fix exports to work with DirectExportInterface

View File

@@ -17,3 +17,9 @@ when@dev:
defaults: defaults:
template: '@ChillMain/Dev/dev.assets.test2.html.twig' template: '@ChillMain/Dev/dev.assets.test2.html.twig'
sass_address_picker:
path: /_dev/address-picker
controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
defaults:
template: '@ChillMain/Dev/dev.address-picker.html.twig'

View File

@@ -45,6 +45,7 @@
"webpack-cli": "^5.0.1" "webpack-cli": "^5.0.1"
}, },
"dependencies": { "dependencies": {
"@fragaria/address-formatter": "^6.6.1",
"@fullcalendar/core": "^6.1.4", "@fullcalendar/core": "^6.1.4",
"@fullcalendar/daygrid": "^6.1.4", "@fullcalendar/daygrid": "^6.1.4",
"@fullcalendar/interaction": "^6.1.4", "@fullcalendar/interaction": "^6.1.4",
@@ -55,6 +56,7 @@
"@tsconfig/node20": "^20.1.4", "@tsconfig/node20": "^20.1.4",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/leaflet": "^1.9.3", "@types/leaflet": "^1.9.3",
"@vueuse/core": "^13.9.0",
"bootstrap-icons": "^1.11.3", "bootstrap-icons": "^1.11.3",
"dropzone": "^5.7.6", "dropzone": "^5.7.6",
"es6-promise": "^4.2.8", "es6-promise": "^4.2.8",
@@ -69,6 +71,7 @@
"vue-i18n": "^9.1.6", "vue-i18n": "^9.1.6",
"vue-multiselect": "3.0.0-alpha.2", "vue-multiselect": "3.0.0-alpha.2",
"vue-toast-notification": "^3.1.2", "vue-toast-notification": "^3.1.2",
"vue-use-leaflet": "^0.1.7",
"vuex": "^4.0.0" "vuex": "^4.0.0"
}, },
"browserslist": [ "browserslist": [

View File

@@ -55,5 +55,6 @@
</dl> </dl>
{% endblock %} {% endblock %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% endembed %} {% endembed %}
{% endblock %} {% endblock %}

View File

@@ -70,6 +70,8 @@
<option value="00:10:00">10 minutes</option> <option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option> <option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option> <option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select> </select>
<label class="input-group-text" for="slotMinTime">De</label> <label class="input-group-text" for="slotMinTime">De</label>
<select <select

View File

@@ -32,6 +32,8 @@
<option value="00:10:00">10 minutes</option> <option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option> <option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option> <option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select> </select>
<label class="input-group-text" for="slotMinTime">De</label> <label class="input-group-text" for="slotMinTime">De</label>
<select <select
@@ -102,7 +104,8 @@
event.title event.title
}}</b> }}</b>
<b v-else-if="event.extendedProps.is === 'range'" <b v-else-if="event.extendedProps.is === 'range'"
>{{ formatDate(event.startStr) }} - >{{ formatDate(event.startStr, "time") }} -
{{ formatDate(event.endStr, "time") }}:
{{ event.extendedProps.locationName }}</b {{ event.extendedProps.locationName }}</b
> >
<b v-else-if="event.extendedProps.is === 'local'">{{ <b v-else-if="event.extendedProps.is === 'local'">{{
@@ -294,9 +297,26 @@ const nextWeeks = computed((): Weeks[] =>
}), }),
); );
const formatDate = (datetime: string) => { const formatDate = (datetime: string, format: null | "time" = null) => {
console.log(typeof datetime); const date = ISOToDate(datetime);
return ISOToDate(datetime); if (!date) return "";
if (format === "time") {
return date.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
});
}
// French date formatting
return date.toLocaleDateString("fr-FR", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}; };
const baseOptions = ref<CalendarOptions>({ const baseOptions = ref<CalendarOptions>({

View File

@@ -0,0 +1,20 @@
<?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\DocStoreBundle\Exception;
class ConversionWithSameMimeTypeException extends \RuntimeException
{
public function __construct(string $mimeType, ?\Throwable $previous = null)
{
parent::__construct("Conversion to same MIME type '{$mimeType}' is not allowed: already at the same MIME type", 0, $previous);
}
}

View File

@@ -25,7 +25,7 @@ export interface GenericDoc {
type: "doc_store_generic_doc"; type: "doc_store_generic_doc";
uniqueKey: string; uniqueKey: string;
key: string; key: string;
identifiers: object; identifiers: { id: number };
context: "person" | "accompanying-period"; context: "person" | "accompanying-period";
doc_date: DateTime; doc_date: DateTime;
metadata: GenericDocMetadata; metadata: GenericDocMetadata;

View File

@@ -4,6 +4,7 @@ import { StoredObject, StoredObjectVersion } from "../../types";
import DropFileWidget from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileWidget.vue"; import DropFileWidget from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileWidget.vue";
import { computed, reactive } from "vue"; import { computed, reactive } from "vue";
import { useToast } from "vue-toast-notification"; import { useToast } from "vue-toast-notification";
import { DOCUMENT_ADD, trans } from "translator";
interface DropFileConfig { interface DropFileConfig {
allowRemove: boolean; allowRemove: boolean;
@@ -75,11 +76,9 @@ function closeModal(): void {
@click="openModal" @click="openModal"
class="btn btn-create" class="btn btn-create"
> >
Ajouter un document {{ trans(DOCUMENT_ADD) }}
</button>
<button v-else @click="openModal" class="btn btn-edit">
Remplacer le document
</button> </button>
<button v-else @click="openModal" class="btn btn-edit"></button>
<modal <modal
v-if="state.showModal" v-if="state.showModal"
:modal-dialog-class="modalClasses" :modal-dialog-class="modalClasses"

View File

@@ -46,6 +46,16 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool
{ {
// we first try to get the permission from the workflow, as attachement (this is the less intensive query)
$workflowPermissionAsAttachment = match ($attribute) {
StoredObjectRoleEnum::SEE => $this->workflowDocumentService->isAllowedByWorkflowForReadOperation($subject),
StoredObjectRoleEnum::EDIT => $this->workflowDocumentService->isAllowedByWorkflowForWriteOperation($subject),
};
if (WorkflowRelatedEntityPermissionHelper::FORCE_DENIED === $workflowPermissionAsAttachment) {
return false;
}
// Retrieve the related entity // Retrieve the related entity
$entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject); $entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject);
@@ -66,7 +76,7 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
return match ($workflowPermission) { return match ($workflowPermission) {
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN => $regularPermission, WorkflowRelatedEntityPermissionHelper::ABSTAIN => WorkflowRelatedEntityPermissionHelper::FORCE_GRANT === $workflowPermissionAsAttachment || $regularPermission,
}; };
} }
} }

View File

@@ -14,6 +14,12 @@ namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObject;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
/**
* Interface for voting on stored object permissions.
*
* Each time a stored object is attached to a document, the voter is responsible for determining
* whether the user has the necessary permissions to access or modify the stored object.
*/
interface StoredObjectVoterInterface interface StoredObjectVoterInterface
{ {
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool; public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool;

View File

@@ -15,6 +15,7 @@ use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime; use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum; use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Chill\DocStoreBundle\Entity\StoredObjectVersion; use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\ConversionWithSameMimeTypeException;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException; use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\WopiBundle\Service\WopiConverter; use Chill\WopiBundle\Service\WopiConverter;
use Symfony\Component\Mime\MimeTypesInterface; use Symfony\Component\Mime\MimeTypesInterface;
@@ -44,6 +45,7 @@ class StoredObjectToPdfConverter
* @throws \UnexpectedValueException if the preferred mime type for the conversion is not found * @throws \UnexpectedValueException if the preferred mime type for the conversion is not found
* @throws \RuntimeException if the conversion or storage of the new version fails * @throws \RuntimeException if the conversion or storage of the new version fails
* @throws StoredObjectManagerException * @throws StoredObjectManagerException
* @throws ConversionWithSameMimeTypeException if the document has already the same mime type79*
*/ */
public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf', bool $includeConvertedContent = false): array public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf', bool $includeConvertedContent = false): array
{ {
@@ -56,7 +58,7 @@ class StoredObjectToPdfConverter
$currentVersion = $storedObject->getCurrentVersion(); $currentVersion = $storedObject->getCurrentVersion();
if ($currentVersion->getType() === $newMimeType) { if ($currentVersion->getType() === $newMimeType) {
throw new \UnexpectedValueException('Already at the same mime type'); throw new ConversionWithSameMimeTypeException($newMimeType);
} }
$content = $this->storedObjectManager->read($currentVersion); $content = $this->storedObjectManager->read($currentVersion);

View File

@@ -86,9 +86,165 @@ class AbstractStoredObjectVoterTest extends TestCase
} }
/** /**
* @dataProvider dataProviderVoteOnAttribute * @dataProvider dataProviderVoteOnAttributeWithStoredObjectPermission
*/ */
public function testVoteOnAttribute( public function testVoteOnAttributeWithStoredObjectPermission(
StoredObjectRoleEnum $attribute,
bool $expected,
bool $isGrantedRegularPermission,
string $isGrantedWorkflowPermission,
string $isGrantedStoredObjectAttachment,
): void {
$storedObject = new StoredObject();
$repository = new DummyRepository($related = new \stdClass());
$token = new UsernamePasswordToken(new User(), 'dummy');
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
$security = $this->prophesize(Security::class);
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
if (StoredObjectRoleEnum::SEE === $attribute) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)
->shouldBeCalled()
->willReturn($isGrantedStoredObjectAttachment);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
->willReturn($isGrantedWorkflowPermission);
} elseif (StoredObjectRoleEnum::EDIT === $attribute) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)
->shouldBeCalled()
->willReturn($isGrantedStoredObjectAttachment);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($related)
->willReturn($isGrantedWorkflowPermission);
} else {
throw new \LogicException('Invalid attribute for StoredObjectVoter');
}
$storedObjectVoter = new class ($repository, $workflowRelatedEntityPermissionHelper->reveal(), $security->reveal()) extends AbstractStoredObjectVoter {
public function __construct(private $repository, $helper, $security)
{
parent::__construct($security, $helper);
}
protected function getRepository(): AssociatedEntityToStoredObjectInterface
{
return $this->repository;
}
protected function getClass(): string
{
return \stdClass::class;
}
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
{
return 'SOME_ROLE';
}
protected function canBeAssociatedWithWorkflow(): bool
{
return true;
}
};
$actual = $storedObjectVoter->voteOnAttribute($attribute, $storedObject, $token);
self::assertEquals($expected, $actual);
}
public static function dataProviderVoteOnAttributeWithStoredObjectPermission(): iterable
{
foreach (['read' => StoredObjectRoleEnum::SEE, 'write' => StoredObjectRoleEnum::EDIT] as $action => $attribute) {
yield 'Not related to any workflow nor attachment ('.$action.')' => [
$attribute,
true,
true,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
];
yield 'Not related to any workflow nor attachment (refuse) ('.$action.')' => [
$attribute,
false,
false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
];
yield 'Is granted by a workflow takes precedence (workflow) ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
];
yield 'Is granted by a workflow takes precedence (stored object) ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
];
yield 'Is granted by a workflow takes precedence (workflow) although grant ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
];
yield 'Is granted by a workflow takes precedence (stored object) although grant ('.$action.')' => [
$attribute,
false,
true,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
];
yield 'Is granted by a workflow takes precedence (initially refused) (workflow) although grant ('.$action.')' => [
$attribute,
false,
false,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
];
yield 'Is granted by a workflow takes precedence (initially refused) (stored object) although grant ('.$action.')' => [
$attribute,
false,
false,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
];
yield 'Force grant inverse the regular permission (workflow) ('.$action.')' => [
$attribute,
true,
false,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
];
yield 'Force grant inverse the regular permission (so) ('.$action.')' => [
$attribute,
true,
false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
];
}
}
/**
* @dataProvider dataProviderVoteOnAttributeWithoutStoredObjectPermission
*/
public function testVoteOnAttributeWithoutStoredObjectPermission(
StoredObjectRoleEnum $attribute, StoredObjectRoleEnum $attribute,
bool $expected, bool $expected,
bool $canBeAssociatedWithWorkflow, bool $canBeAssociatedWithWorkflow,
@@ -105,6 +261,10 @@ class AbstractStoredObjectVoterTest extends TestCase
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission); $security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class); $workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
if (null !== $isGrantedWorkflowPermissionRead) { if (null !== $isGrantedWorkflowPermissionRead) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related) $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
->willReturn($isGrantedWorkflowPermissionRead)->shouldBeCalled(); ->willReturn($isGrantedWorkflowPermissionRead)->shouldBeCalled();
@@ -123,7 +283,7 @@ class AbstractStoredObjectVoterTest extends TestCase
self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message); self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message);
} }
public static function dataProviderVoteOnAttribute(): iterable public static function dataProviderVoteOnAttributeWithoutStoredObjectPermission(): iterable
{ {
// not associated on a workflow // not associated on a workflow
yield [StoredObjectRoleEnum::SEE, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper']; yield [StoredObjectRoleEnum::SEE, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper'];

View File

@@ -23,6 +23,8 @@ See the document: Voir le document
document: document:
Any title: Aucun titre Any title: Aucun titre
replace: Remplacer
Add: Ajouter un document
generic_doc: generic_doc:
filter: filter:

View File

@@ -118,7 +118,7 @@
{{ entity.notes|chill_print_or_message("Aucune note", 'blockquote') }} {{ entity.notes|chill_print_or_message("Aucune note", 'blockquote') }}
{% endblock crud_content_view_details %} {% endblock crud_content_view_details %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% block content_view_actions_back %} {% block content_view_actions_back %}
<li class="cancel"> <li class="cancel">
<a class="btn btn-cancel" href="{{ chill_return_path_or('chill_job_report_index', { 'person': entity.person.id }) }}"> <a class="btn btn-cancel" href="{{ chill_return_path_or('chill_job_report_index', { 'person': entity.person.id }) }}">

View File

@@ -46,6 +46,7 @@
</dd> </dd>
</dl> </dl>
{% endblock crud_content_view_details %} {% endblock crud_content_view_details %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% block content_view_actions_back %} {% block content_view_actions_back %}
<li class="cancel"> <li class="cancel">

View File

@@ -206,6 +206,8 @@
</a> </a>
</li> </li>
{% endblock %} {% endblock %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% block content_view_actions_after %} {% block content_view_actions_after %}
<li> <li>
<a class="btn btn-misc" href="{{ chill_return_path_or('chill_crud_immersion_bilan', { 'id': entity.id, 'person_id': entity.person.id }) }}"> <a class="btn btn-misc" href="{{ chill_return_path_or('chill_crud_immersion_bilan', { 'id': entity.id, 'person_id': entity.person.id }) }}">

View File

@@ -94,6 +94,7 @@
{% endblock crud_content_view_details %} {% endblock crud_content_view_details %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% block content_view_actions_back %} {% block content_view_actions_back %}
<li class="cancel"> <li class="cancel">

View File

@@ -0,0 +1,64 @@
<?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\MainBundle\Action\User\UpdateProfile;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Notification\NotificationFlagManager;
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
use libphonenumber\PhoneNumber;
final class UpdateProfileCommand
{
public array $notificationFlags = [];
public function __construct(
#[PhonenumberConstraint]
public ?PhoneNumber $phonenumber,
) {}
public static function create(User $user, NotificationFlagManager $flagManager): self
{
$updateProfileCommand = new self($user->getPhonenumber());
foreach ($flagManager->getAllNotificationFlagProviders() as $provider) {
$updateProfileCommand->setNotificationFlag(
$provider->getFlag(),
User::NOTIF_FLAG_IMMEDIATE_EMAIL,
$user->isNotificationSendImmediately($provider->getFlag())
);
$updateProfileCommand->setNotificationFlag(
$provider->getFlag(),
User::NOTIF_FLAG_DAILY_DIGEST,
$user->isNotificationDailyDigest($provider->getFlag())
);
}
return $updateProfileCommand;
}
/**
* @param User::NOTIF_FLAG_IMMEDIATE_EMAIL|User::NOTIF_FLAG_DAILY_DIGEST $kind
*/
private function setNotificationFlag(string $type, string $kind, bool $value): void
{
if (!array_key_exists($type, $this->notificationFlags)) {
$this->notificationFlags[$type] = ['immediate_email' => true, 'daily_digest' => false];
}
$k = match ($kind) {
User::NOTIF_FLAG_IMMEDIATE_EMAIL => 'immediate_email',
User::NOTIF_FLAG_DAILY_DIGEST => 'daily_digest',
};
$this->notificationFlags[$type][$k] = $value;
}
}

View File

@@ -0,0 +1,27 @@
<?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\MainBundle\Action\User\UpdateProfile;
use Chill\MainBundle\Entity\User;
final readonly class UpdateProfileCommandHandler
{
public function updateProfile(User $user, UpdateProfileCommand $command): void
{
$user->setPhonenumber($command->phonenumber);
foreach ($command->notificationFlags as $flag => $values) {
$user->setNotificationImmediately($flag, $values['immediate_email']);
$user->setNotificationDailyDigest($flag, $values['daily_digest']);
}
}
}

View File

@@ -48,6 +48,7 @@ class AbsenceController extends AbstractController
$user = $this->security->getUser(); $user = $this->security->getUser();
$user->setAbsenceStart(null); $user->setAbsenceStart(null);
$user->setAbsenceEnd(null);
$em = $this->managerRegistry->getManager(); $em = $this->managerRegistry->getManager();
$em->flush(); $em->flush();

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Repository\AddressReferenceRepositoryInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
final readonly class AddressReferenceAggregatedApiController
{
public function __construct(
private Security $security,
private AddressReferenceRepositoryInterface $addressReferenceRepository,
) {}
#[Route(path: '/api/1.0/main/address-reference/aggregated/search')]
public function search(Request $request): JsonResponse
{
if (!$this->security->isGranted('IS_AUTHENTICATED')) {
throw new AccessDeniedHttpException();
}
if (!$request->query->has('q')) {
throw new BadRequestHttpException('Parameter "q" is required.');
}
$q = trim($request->query->get('q'));
if ('' === $q) {
throw new BadRequestHttpException('Parameter "q" is required and cannot be empty.');
}
$result = $this->addressReferenceRepository->findAggregatedBySearchString($q);
return new JsonResponse(iterator_to_array($result));
}
}

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\MainBundle\Controller;
use Chill\MainBundle\Repository\PostalCodeForAddressReferenceRepositoryInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
final readonly class PostalCodeForAddressReferenceApiController
{
public function __construct(
private PostalCodeForAddressReferenceRepositoryInterface $postalCodeForAddressReferenceRepository,
private Security $security,
) {}
#[Route('/api/1.0/main/address-reference/postal-code/search')]
public function findPostalCodeBySearch(Request $request): JsonResponse
{
if (!$this->security->isGranted('IS_AUTHENTICATED')) {
throw new AccessDeniedHttpException();
}
$search = $request->query->get('q');
if (null === $search || '' === trim($search)) {
throw new BadRequestHttpException('No search query provided');
}
$postalCodes = iterator_to_array($this->postalCodeForAddressReferenceRepository->findPostalCode($search));
return new JsonResponse($postalCodes, json: false);
}
}

View File

@@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Form\UserProfileType;
use Chill\MainBundle\Security\ChillSecurity;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Component\Routing\Annotation\Route;
final class UserProfileController extends AbstractController
{
public function __construct(
private readonly TranslatorInterface $translator,
private readonly ChillSecurity $security,
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry,
) {}
/**
* User profile that allows editing of phonenumber and visualization of certain data.
*/
#[Route(path: '/{_locale}/main/user/my-profile', name: 'chill_main_user_profile')]
public function __invoke(Request $request)
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException();
}
$user = $this->security->getUser();
$editForm = $this->createForm(UserProfileType::class, $user);
$editForm->get('notificationFlags')->setData($user->getNotificationFlags());
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
$notificationFlagsData = $editForm->get('notificationFlags')->getData();
$user->setNotificationFlags($notificationFlagsData);
$em = $this->managerRegistry->getManager();
$em->flush();
$this->addFlash('success', $this->translator->trans('user.profile.Profile successfully updated!'));
return $this->redirectToRoute('chill_main_user_profile');
}
return $this->render('@ChillMain/User/profile.html.twig', [
'user' => $user,
'form' => $editForm->createView(),
]);
}
}

View File

@@ -0,0 +1,75 @@
<?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\MainBundle\Controller;
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand;
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommandHandler;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\UpdateProfileType;
use Chill\MainBundle\Notification\NotificationFlagManager;
use Chill\MainBundle\Security\ChillSecurity;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;
final readonly class UserUpdateProfileController
{
public function __construct(
private TranslatorInterface $translator,
private ChillSecurity $security,
private EntityManagerInterface $entityManager,
private NotificationFlagManager $notificationFlagManager,
private FormFactoryInterface $formFactory,
private UrlGeneratorInterface $urlGenerator,
private Environment $twig,
private UpdateProfileCommandHandler $updateProfileCommandHandler,
) {}
/**
* User profile that allows editing of phonenumber and visualization of certain data.
*/
#[Route(path: '/{_locale}/main/user/my-profile', name: 'chill_main_user_profile')]
public function __invoke(Request $request, Session $session)
{
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedHttpException();
}
$user = $this->security->getUser();
$command = UpdateProfileCommand::create($user, $this->notificationFlagManager);
$editForm = $this->formFactory->create(UpdateProfileType::class, $command);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
$this->updateProfileCommandHandler->updateProfile($user, $command);
$this->entityManager->flush();
$session->getFlashBag()->add('success', $this->translator->trans('user.profile.Profile successfully updated!'));
return new RedirectResponse($this->urlGenerator->generate('chill_main_user_profile'));
}
return new Response($this->twig->render('@ChillMain/User/profile.html.twig', [
'user' => $user,
'form' => $editForm->createView(),
]));
}
}

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller; namespace Chill\MainBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Pagination\PaginatorFactory;
@@ -27,7 +28,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Serializer\SerializerInterface;
class WorkflowApiController class WorkflowApiController extends ApiController
{ {
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly EntityWorkflowRepository $entityWorkflowRepository, private readonly PaginatorFactory $paginatorFactory, private readonly Security $security, private readonly SerializerInterface $serializer) {} public function __construct(private readonly EntityManagerInterface $entityManager, private readonly EntityWorkflowRepository $entityWorkflowRepository, private readonly PaginatorFactory $paginatorFactory, private readonly Security $security, private readonly SerializerInterface $serializer) {}

View File

@@ -44,7 +44,7 @@ final readonly class WorkflowSignatureStateChangeController
$signature, $signature,
$request, $request,
EntityWorkflowStepSignatureVoter::CANCEL, EntityWorkflowStepSignatureVoter::CANCEL,
function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsCanceled($signature); }, fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsCanceled($signature),
'@ChillMain/WorkflowSignature/cancel.html.twig', '@ChillMain/WorkflowSignature/cancel.html.twig',
); );
} }
@@ -56,11 +56,18 @@ final readonly class WorkflowSignatureStateChangeController
$signature, $signature,
$request, $request,
EntityWorkflowStepSignatureVoter::REJECT, EntityWorkflowStepSignatureVoter::REJECT,
function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsRejected($signature); }, fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsRejected($signature),
'@ChillMain/WorkflowSignature/reject.html.twig', '@ChillMain/WorkflowSignature/reject.html.twig',
); );
} }
/**
* @param callable(EntityWorkflowStepSignature): string $markSignature
*
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
private function markSignatureAction( private function markSignatureAction(
EntityWorkflowStepSignature $signature, EntityWorkflowStepSignature $signature,
Request $request, Request $request,
@@ -79,12 +86,13 @@ final readonly class WorkflowSignatureStateChangeController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->wrapInTransaction(function () use ($signature, $markSignature) { $expectedStep = $this->entityManager->wrapInTransaction(fn () => $markSignature($signature));
$markSignature($signature);
});
return new RedirectResponse( return new RedirectResponse(
$this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $signature->getStep()->getEntityWorkflow()->getId()]) $this->chillUrlGenerator->forwardReturnPath(
'chill_main_workflow_wait',
['id' => $signature->getStep()->getEntityWorkflow()->getId(), 'expectedStep' => $expectedStep]
)
); );
} }

View File

@@ -0,0 +1,41 @@
<?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\MainBundle\Controller;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Routing\ChillUrlGeneratorInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;
final readonly class WorkflowWaitStepChangeController
{
public function __construct(
private ChillUrlGeneratorInterface $chillUrlGenerator,
private Environment $twig,
) {}
#[Route('/{_locale}/main/workflow/{id}/wait/{expectedStep}', name: 'chill_main_workflow_wait', methods: ['GET'])]
public function waitForSignatureChange(EntityWorkflow $entityWorkflow, string $expectedStep): Response
{
if ($entityWorkflow->getStep() === $expectedStep) {
return new RedirectResponse(
$this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $entityWorkflow->getId()])
);
}
return new Response(
$this->twig->render('@ChillMain/Workflow/waiting.html.twig', ['workflow' => $entityWorkflow, 'expectedStep' => $expectedStep])
);
}
}

View File

@@ -30,6 +30,7 @@ use Chill\MainBundle\Controller\UserGroupAdminController;
use Chill\MainBundle\Controller\UserGroupApiController; use Chill\MainBundle\Controller\UserGroupApiController;
use Chill\MainBundle\Controller\UserJobApiController; use Chill\MainBundle\Controller\UserJobApiController;
use Chill\MainBundle\Controller\UserJobController; use Chill\MainBundle\Controller\UserJobController;
use Chill\MainBundle\Controller\WorkflowApiController;
use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface; use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface;
use Chill\MainBundle\Doctrine\DQL\Age; use Chill\MainBundle\Doctrine\DQL\Age;
use Chill\MainBundle\Doctrine\DQL\Extract; use Chill\MainBundle\Doctrine\DQL\Extract;
@@ -66,6 +67,7 @@ use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup; use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\UserJob; use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Form\CenterType; use Chill\MainBundle\Form\CenterType;
use Chill\MainBundle\Form\CivilityType; use Chill\MainBundle\Form\CivilityType;
use Chill\MainBundle\Form\CountryType; use Chill\MainBundle\Form\CountryType;
@@ -79,6 +81,7 @@ use Chill\MainBundle\Form\UserGroupType;
use Chill\MainBundle\Form\UserJobType; use Chill\MainBundle\Form\UserJobType;
use Chill\MainBundle\Form\UserType; use Chill\MainBundle\Form\UserType;
use Chill\MainBundle\Security\Authorization\ChillExportVoter; use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter;
use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType; use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType;
use Ramsey\Uuid\Doctrine\UuidType; use Ramsey\Uuid\Doctrine\UuidType;
use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\FileLocator;
@@ -940,6 +943,21 @@ class ChillMainExtension extends Extension implements
], ],
], ],
], ],
[
'class' => EntityWorkflow::class,
'name' => 'workflow',
'base_path' => '/api/1.0/main/workflow',
'base_role' => EntityWorkflowVoter::SEE,
'controller' => WorkflowApiController::class,
'actions' => [
'_entity' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true,
],
],
],
],
], ],
]); ]);
} }

View File

@@ -24,6 +24,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation as Serializer; use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Context\ExecutionContextInterface; use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
use Symfony\Component\Validator\Constraints as Assert;
/** /**
* User. * User.
@@ -45,6 +46,8 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $absenceStart = null; private ?\DateTimeImmutable $absenceStart = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $absenceEnd = null;
/** /**
* Array where SAML attributes's data are stored. * Array where SAML attributes's data are stored.
*/ */
@@ -157,6 +160,11 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
return $this->absenceStart; return $this->absenceStart;
} }
public function getAbsenceEnd(): ?\DateTimeImmutable
{
return $this->absenceEnd;
}
/** /**
* Get attributes. * Get attributes.
* *
@@ -336,7 +344,13 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
public function isAbsent(): bool public function isAbsent(): bool
{ {
return null !== $this->getAbsenceStart() && $this->getAbsenceStart() <= new \DateTimeImmutable('now'); $now = new \DateTimeImmutable('now');
$absenceStart = $this->getAbsenceStart();
$absenceEnd = $this->getAbsenceEnd();
return null !== $absenceStart
&& $absenceStart <= $now
&& (null === $absenceEnd || $now <= $absenceEnd);
} }
/** /**
@@ -410,6 +424,11 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
$this->absenceStart = $absenceStart; $this->absenceStart = $absenceStart;
} }
public function setAbsenceEnd(?\DateTimeImmutable $absenceEnd): void
{
$this->absenceEnd = $absenceEnd;
}
public function setAttributeByDomain(string $domain, string $key, $value): self public function setAttributeByDomain(string $domain, string $key, $value): self
{ {
$this->attributes[$domain][$key] = $value; $this->attributes[$domain][$key] = $value;
@@ -633,46 +652,82 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
return true; return true;
} }
public function getNotificationFlags(): array private function getNotificationFlagData(string $flag): array
{ {
return $this->notificationFlags; return $this->notificationFlags[$flag] ?? [self::NOTIF_FLAG_IMMEDIATE_EMAIL];
}
public function setNotificationFlags(array $notificationFlags)
{
$this->notificationFlags = $notificationFlags;
}
public function getNotificationFlagData(string $flag): array
{
return $this->notificationFlags[$flag] ?? [];
}
public function setNotificationFlagData(string $flag, array $data): void
{
$this->notificationFlags[$flag] = $data;
} }
public function isNotificationSendImmediately(string $type): bool public function isNotificationSendImmediately(string $type): bool
{ {
if ([] === $this->getNotificationFlagData($type) || in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $this->getNotificationFlagData($type), true)) { return $this->isNotificationForElement($type, self::NOTIF_FLAG_IMMEDIATE_EMAIL);
return true;
} }
return false; public function setNotificationImmediately(string $type, bool $active): void
{
$this->setNotificationFlagElement($type, $active, self::NOTIF_FLAG_IMMEDIATE_EMAIL);
}
public function setNotificationDailyDigest(string $type, bool $active): void
{
$this->setNotificationFlagElement($type, $active, self::NOTIF_FLAG_DAILY_DIGEST);
}
/**
* @param self::NOTIF_FLAG_IMMEDIATE_EMAIL|self::NOTIF_FLAG_DAILY_DIGEST $kind
*/
private function setNotificationFlagElement(string $type, bool $active, string $kind): void
{
$notificationFlags = [...$this->notificationFlags];
$changed = false;
if (!isset($notificationFlags[$type])) {
$notificationFlags[$type] = [self::NOTIF_FLAG_IMMEDIATE_EMAIL];
$changed = true;
}
if ($active) {
if (!in_array($kind, $notificationFlags[$type], true)) {
$notificationFlags[$type] = [...$notificationFlags[$type], $kind];
$changed = true;
}
} else {
if (in_array($kind, $notificationFlags[$type], true)) {
$notificationFlags[$type] = array_values(
array_filter($notificationFlags[$type], static fn ($k) => $k !== $kind)
);
$changed = true;
}
}
if ($changed) {
$this->notificationFlags = [...$notificationFlags];
}
}
private function isNotificationForElement(string $type, string $kind): bool
{
return in_array($kind, $this->getNotificationFlagData($type), true);
} }
public function isNotificationDailyDigest(string $type): bool public function isNotificationDailyDigest(string $type): bool
{ {
if (in_array(User::NOTIF_FLAG_DAILY_DIGEST, $this->getNotificationFlagData($type), true)) { return $this->isNotificationForElement($type, self::NOTIF_FLAG_DAILY_DIGEST);
return true;
}
return false;
} }
public function getLocale(): string public function getLocale(): string
{ {
return 'fr'; return 'fr';
} }
#[Assert\Callback]
public function validateAbsenceDates(ExecutionContextInterface $context): void
{
if (null !== $this->getAbsenceEnd() && null === $this->getAbsenceStart()) {
$context->buildViolation(
'user.absence_end_requires_start'
)
->atPath('absenceEnd')
->addViolation();
}
}
} }

View File

@@ -23,9 +23,14 @@ class AbsenceType extends AbstractType
{ {
$builder $builder
->add('absenceStart', ChillDateType::class, [ ->add('absenceStart', ChillDateType::class, [
'required' => true, 'required' => false,
'input' => 'datetime_immutable', 'input' => 'datetime_immutable',
'label' => 'absence.Absence start', 'label' => 'absence.Absence start',
])
->add('absenceEnd', ChillDateType::class, [
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence end',
]); ]);
} }

View File

@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form\DataMapper;
use Chill\MainBundle\Entity\User;
use Symfony\Component\Form\DataMapperInterface;
final readonly class NotificationFlagDataMapper implements DataMapperInterface
{
public function __construct(private array $notificationFlagProviders) {}
public function mapDataToForms($viewData, $forms): void
{
if (null === $viewData) {
$viewData = [];
}
$formsArray = iterator_to_array($forms);
foreach ($this->notificationFlagProviders as $flagProvider) {
$flag = $flagProvider->getFlag();
if (isset($formsArray[$flag])) {
$flagForm = $formsArray[$flag];
$immediateEmailChecked = in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $viewData[$flag] ?? [], true)
|| !array_key_exists($flag, $viewData);
$dailyEmailChecked = in_array(User::NOTIF_FLAG_DAILY_DIGEST, $viewData[$flag] ?? [], true);
if ($flagForm->has('immediate_email')) {
$flagForm->get('immediate_email')->setData($immediateEmailChecked);
}
if ($flagForm->has('daily_email')) {
$flagForm->get('daily_email')->setData($dailyEmailChecked);
}
}
}
}
public function mapFormsToData($forms, &$viewData): void
{
$formsArray = iterator_to_array($forms);
$viewData = [];
foreach ($this->notificationFlagProviders as $flagProvider) {
$flag = $flagProvider->getFlag();
if (isset($formsArray[$flag])) {
$flagForm = $formsArray[$flag];
$viewData[$flag] = [];
if (true === $flagForm['immediate_email']->getData()) {
$viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL;
}
if (true === $flagForm['daily_email']->getData()) {
$viewData[$flag][] = User::NOTIF_FLAG_DAILY_DIGEST;
}
if ([] === $viewData[$flag]) {
$viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL;
}
}
}
}
}

View File

@@ -11,11 +11,9 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form\Type; namespace Chill\MainBundle\Form\Type;
use Chill\MainBundle\Form\DataMapper\NotificationFlagDataMapper;
use Chill\MainBundle\Notification\NotificationFlagManager; use Chill\MainBundle\Notification\NotificationFlagManager;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -30,27 +28,24 @@ class NotificationFlagsType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder->setDataMapper(new NotificationFlagDataMapper($this->notificationFlagProviders));
foreach ($this->notificationFlagProviders as $flagProvider) { foreach ($this->notificationFlagProviders as $flagProvider) {
$flag = $flagProvider->getFlag(); $flag = $flagProvider->getFlag();
$builder->add($flag, FormType::class, [ $flagBuilder = $builder->create($flag, options: [
'label' => $flagProvider->getLabel(), 'label' => $flagProvider->getLabel(),
'required' => false, 'compound' => true,
]); ]);
$builder->get($flag) $flagBuilder
->add('immediate_email', CheckboxType::class, [ ->add('immediate_email', CheckboxType::class, [
'label' => false, 'label' => false,
'required' => false, 'required' => false,
'mapped' => false,
]) ])
->add('daily_email', CheckboxType::class, [ ->add('daily_digest', CheckboxType::class, [
'label' => false, 'label' => false,
'required' => false, 'required' => false,
'mapped' => false,
]) ])
; ;
$builder->add($flagBuilder);
} }
} }
@@ -58,6 +53,7 @@ class NotificationFlagsType extends AbstractType
{ {
$resolver->setDefaults([ $resolver->setDefaults([
'data_class' => null, 'data_class' => null,
'compound' => true,
]); ]);
} }
} }

View File

@@ -11,31 +11,29 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form; namespace Chill\MainBundle\Form;
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand;
use Chill\MainBundle\Form\Type\ChillPhoneNumberType; use Chill\MainBundle\Form\Type\ChillPhoneNumberType;
use Chill\MainBundle\Form\Type\NotificationFlagsType; use Chill\MainBundle\Form\Type\NotificationFlagsType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
class UserProfileType extends AbstractType class UpdateProfileType extends AbstractType
{ {
public function buildForm(FormBuilderInterface $builder, array $options) public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$builder $builder
->add('phonenumber', ChillPhoneNumberType::class, [ ->add('phonenumber', ChillPhoneNumberType::class, [
'required' => false, 'required' => false,
]) ])
->add('notificationFlags', NotificationFlagsType::class, [ ->add('notificationFlags', NotificationFlagsType::class)
'label' => false,
'mapped' => false,
])
; ;
} }
public function configureOptions(OptionsResolver $resolver) public function configureOptions(OptionsResolver $resolver): void
{ {
$resolver->setDefaults([ $resolver->setDefaults([
'data_class' => \Chill\MainBundle\Entity\User::class, 'data_class' => UpdateProfileCommand::class,
]); ]);
} }
} }

View File

@@ -59,7 +59,7 @@ class UserPasswordType extends AbstractType
'invalid_message' => 'The password fields must match', 'invalid_message' => 'The password fields must match',
'constraints' => [ 'constraints' => [
new Length([ new Length([
'min' => 9, 'min' => 14,
'minMessage' => 'The password must be greater than {{ limit }} characters', 'minMessage' => 'The password must be greater than {{ limit }} characters',
]), ]),
new NotBlank(), new NotBlank(),

View File

@@ -105,6 +105,11 @@ class UserType extends AbstractType
'required' => false, 'required' => false,
'input' => 'datetime_immutable', 'input' => 'datetime_immutable',
'label' => 'absence.Absence start', 'label' => 'absence.Absence start',
])
->add('absenceEnd', ChillDateType::class, [
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence end',
]); ]);
// @phpstan-ignore-next-line // @phpstan-ignore-next-line

View File

@@ -14,13 +14,14 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\AddressReference; use Chill\MainBundle\Entity\AddressReference;
use Chill\MainBundle\Entity\PostalCode; use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Search\SearchApiQuery; use Chill\MainBundle\Search\SearchApiQuery;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NativeQuery;
use Doctrine\ORM\Query\ResultSetMapping; use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Doctrine\Persistence\ObjectRepository;
final readonly class AddressReferenceRepository implements ObjectRepository final readonly class AddressReferenceRepository implements AddressReferenceRepositoryInterface
{ {
private EntityManagerInterface $entityManager; private EntityManagerInterface $entityManager;
@@ -65,6 +66,121 @@ final readonly class AddressReferenceRepository implements ObjectRepository
return $this->repository->findAll(); return $this->repository->findAll();
} }
public function findAggregatedBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable
{
$terms = $this->buildTermsFromSearchString($search);
if ([] === $terms) {
return [];
}
$connection = $this->entityManager->getConnection();
$qb = $connection->createQueryBuilder();
$qb->select('row_number() OVER () AS row_number', 'var.street AS street', 'cmpc.id AS postcode_id', 'cmpc.code AS code', 'cmpc.label AS label', 'jsonb_object_agg(var.address_id, var.streetnumber ORDER BY var.row_number) AS positions')
->from('view_chill_main_address_reference', 'var')
->innerJoin('var', 'chill_main_postal_code', 'cmpc', 'cmpc.id = var.postcode_id')
->groupBy('cmpc.id', 'var.street')
->setFirstResult($firstResult)
->setMaxResults($maxResults);
$paramId = 0;
foreach ($terms as $term) {
$qb->andWhere('var.address like UNACCENT(LOWER(?))');
$qb->setParameter(++$paramId, "%{$term}%");
}
if (null !== $postalCode) {
$qb->andWhere('var.postcode_id = ?');
$qb->setParameter(++$paramId, $postalCode instanceof PostalCode ? $postalCode->getId() : $postalCode);
}
$result = $qb->executeQuery();
foreach ($result->iterateAssociative() as $row) {
yield [...$row, 'positions' => json_decode($row['positions'], true, 512, JSON_THROW_ON_ERROR)];
}
}
/**
* @return iterable<AddressReference>
*/
public function findBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable
{
$terms = $this->buildTermsFromSearchString($search);
if ([] === $terms) {
return [];
}
$rsm = new ResultSetMappingBuilder($this->entityManager);
$rsm->addRootEntityFromClassMetadata(AddressReference::class, 'ar');
$baseSql = 'SELECT '.$rsm->generateSelectClause(['ar' => 'ar']).' FROM chill_main_address_reference ar JOIN
view_chill_main_address_reference var ON var.address_id = ar.id';
$nql = $this->buildQueryBySearchString($rsm, $baseSql, $terms, $postalCode);
$orderBy = [];
$pertinence = [];
foreach ($terms as $k => $term) {
$pertinence[] =
"(EXISTS (SELECT 1 FROM unnest(string_to_array(address, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(:order{$k})))))::int";
$pertinence[] = "(address LIKE UNACCENT(LOWER(:order{$k})))::int";
$nql->setParameter('order'.$k, $term);
}
$orderBy[] = implode(' + ', $pertinence).' ASC';
$orderBy[] = implode('row_number ASC', $orderBy);
$nql->setSQL($nql->getSQL().' ORDER BY '.implode(', ', $orderBy));
return $nql->toIterable();
}
public function countBySearchString(string $search, PostalCode|int|null $postalCode = null): int
{
$terms = $this->buildTermsFromSearchString($search);
if ([] === $terms) {
return 0;
}
$rsm = new ResultSetMappingBuilder($this->entityManager);
$rsm->addScalarResult('c', 'c', Types::INTEGER);
$nql = $this->buildQueryBySearchString($rsm, 'SELECT COUNT(var.*) AS c FROM view_chill_main_address_reference var', $terms, $postalCode);
return $nql->getSingleScalarResult();
}
private function buildTermsFromSearchString(string $search): array
{
return array_filter(
array_map(
static fn (string $term) => trim($term),
explode(' ', $search)
),
static fn (string $term) => '' !== $term
);
}
private function buildQueryBySearchString(ResultSetMapping $rsm, string $select, array $terms, PostalCode|int|null $postalCode = null): NativeQuery
{
$nql = $this->entityManager->createNativeQuery('', $rsm);
$sql = $select.' WHERE ';
$wheres = [];
foreach ($terms as $k => $term) {
$wheres[] = "var.address like :w{$k}";
$nql->setParameter("w{$k}", '%'.$term.'%', Types::STRING);
}
if (null !== $postalCode) {
$wheres[] = 'var.postcode_id = :postalCode';
$nql->setParameter('postalCode', $postalCode instanceof PostalCode ? $postalCode->getId() : $postalCode);
}
$nql->setSQL($sql.implode(' AND ', $wheres));
return $nql;
}
/** /**
* @param mixed|null $limit * @param mixed|null $limit
* @param mixed|null $offset * @param mixed|null $offset

View File

@@ -0,0 +1,20 @@
<?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\MainBundle\Repository;
use Chill\MainBundle\Entity\PostalCode;
use Doctrine\Persistence\ObjectRepository;
interface AddressReferenceRepositoryInterface extends ObjectRepository
{
public function findAggregatedBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable;
}

View File

@@ -0,0 +1,69 @@
<?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\MainBundle\Repository;
use Doctrine\DBAL\Connection;
final readonly class PostalCodeForAddressReferenceRepository implements PostalCodeForAddressReferenceRepositoryInterface
{
public function __construct(private Connection $connection) {}
public function findPostalCode(string $search, int $firstResult = 0, int $maxResults = 50): iterable
{
$terms = $this->buildTermsFromSearchString($search);
if ([] === $terms) {
return [];
}
$qb = $this->connection->createQueryBuilder();
$qb->from('chill_main_postal_code', 'cmpc')
->join('cmpc', 'view_chill_main_address_reference', 'vcmar', 'vcmar.postcode_id = cmpc.id')
->join('vcmar', 'country', 'country', condition: 'cmpc.country_id = country.id')
->setFirstResult($firstResult)
->setMaxResults($maxResults)
;
$qb->select(
'DISTINCT ON (cmpc.code, cmpc.label) cmpc.id AS postcode_id',
'cmpc.code AS code',
'cmpc.label AS label',
'country.id AS country_id',
'country.countrycode AS country_code',
'country.name AS country_name'
);
$paramId = 0;
foreach ($terms as $term) {
$qb->andWhere('vcmar.address like ?');
$qb->setParameter(++$paramId, "%{$term}%");
}
$result = $qb->executeQuery();
foreach ($result->iterateAssociative() as $row) {
yield [...$row, 'country_name' => json_decode($row['country_name'], true, 512, JSON_THROW_ON_ERROR)];
}
}
private function buildTermsFromSearchString(string $search): array
{
return array_filter(
array_map(
static fn (string $term) => trim($term),
explode(' ', $search)
),
static fn (string $term) => '' !== $term
);
}
}

View File

@@ -0,0 +1,20 @@
<?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\MainBundle\Repository;
/**
* Search for postal code using optimized materialized view.
*/
interface PostalCodeForAddressReferenceRepositoryInterface
{
public function findPostalCode(string $search, int $firstResult = 0, int $maxResults = 50): iterable;
}

View File

@@ -37,8 +37,13 @@ export const ISOToDate = (str: string | null): Date | null => {
return null; return null;
} }
const [year, month, day] = str.split("-").map((p) => parseInt(p)); // If the string already contains time info, use it directly
if (str.includes("T") || str.includes(" ")) {
return new Date(str);
}
// Otherwise, parse date only
const [year, month, day] = str.split("-").map((p) => parseInt(p));
return new Date(year, month - 1, day, 0, 0, 0, 0); return new Date(year, month - 1, day, 0, 0, 0, 0);
}; };
@@ -69,20 +74,19 @@ export const ISOToDatetime = (str: string | null): Date | null => {
* *
*/ */
export const datetimeToISO = (date: Date): string => { export const datetimeToISO = (date: Date): string => {
let cal, time, offset; const cal = [
cal = [
date.getFullYear(), date.getFullYear(),
(date.getMonth() + 1).toString().padStart(2, "0"), (date.getMonth() + 1).toString().padStart(2, "0"),
date.getDate().toString().padStart(2, "0"), date.getDate().toString().padStart(2, "0"),
].join("-"); ].join("-");
time = [ const time = [
date.getHours().toString().padStart(2, "0"), date.getHours().toString().padStart(2, "0"),
date.getMinutes().toString().padStart(2, "0"), date.getMinutes().toString().padStart(2, "0"),
date.getSeconds().toString().padStart(2, "0"), date.getSeconds().toString().padStart(2, "0"),
].join(":"); ].join(":");
offset = [ const offset = [
date.getTimezoneOffset() <= 0 ? "+" : "-", date.getTimezoneOffset() <= 0 ? "+" : "-",
Math.abs(Math.floor(date.getTimezoneOffset() / 60)) Math.abs(Math.floor(date.getTimezoneOffset() / 60))
.toString() .toString()

View File

@@ -0,0 +1,13 @@
/**
* Extracts the "returnPath" parameter from the current URL's query string and returns it.
* If the parameter is not present, returns the provided fallback path.
*
* @param {string} fallbackPath - The fallback path to use if "returnPath" is not found in the query string.
* @return {string} The "returnPath" from the query string, or the fallback path if "returnPath" is not present.
*/
export function returnPathOr(fallbackPath: string): string {
const urlParams = new URLSearchParams(window.location.search);
const returnPath = urlParams.get("returnPath");
return returnPath ?? fallbackPath;
}

View File

@@ -0,0 +1,16 @@
import { EntityWorkflow } from "ChillMainAssets/types";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
export const fetchWorkflow = async (
workflowId: number,
): Promise<EntityWorkflow> => {
try {
return await makeFetch<null, EntityWorkflow>(
"GET",
`/api/1.0/main/workflow/${workflowId}.json`,
);
} catch (error) {
console.error(`Failed to fetch workflow ${workflowId}:`, error);
throw error;
}
};

View File

@@ -1,5 +1,6 @@
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc"; import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
import { Person } from "../../../ChillPersonBundle/Resources/public/types";
export interface DateTime { export interface DateTime {
datetime: string; datetime: string;
@@ -74,6 +75,7 @@ export interface Postcode {
name: string; name: string;
code: string; code: string;
center: Point; center: Point;
country: Country;
} }
export interface Point { export interface Point {
@@ -89,6 +91,28 @@ export interface Country {
export type AddressRefStatus = "match" | "to_review" | "reviewed"; export type AddressRefStatus = "match" | "to_review" | "reviewed";
/**
* An interface to create an address
*/
export interface AddressCreation {
confidential: boolean;
isNoAddress: boolean;
street: string;
streetNumber: string;
postcode: Postcode;
point: Point; // [number, number]; // [longitude, latitude]
addressReference: AddressReference;
validFrom: DateTime|null;
floor: string;
corridor: string;
steps: string;
flat: string;
buildingName: string;
distribution: string;
extra: string;
}
export interface Address { export interface Address {
type: "address"; type: "address";
address_id: number; address_id: number;
@@ -107,7 +131,7 @@ export interface Address {
confidential: boolean; confidential: boolean;
lines: string[]; lines: string[];
addressReference: AddressReference | null; addressReference: AddressReference | null;
validFrom: DateTime; validFrom: DateTime | null; // TODO there is no null for validFrom
validTo: DateTime | null; validTo: DateTime | null;
point: Point | null; point: Point | null;
refStatus: AddressRefStatus; refStatus: AddressRefStatus;
@@ -202,6 +226,58 @@ export interface WorkflowAttachment {
genericDoc: null | GenericDoc; genericDoc: null | GenericDoc;
} }
export interface Workflow {
name: string;
text: string;
}
export interface EntityWorkflowStep {
type: "entity_workflow_step";
id: number;
comment: string;
currentStep: StepDefinition;
isFinal: boolean;
isFreezed: boolean;
isFinalized: boolean;
transitionPrevious: Transition | null;
transitionAfter: Transition | null;
previousId: number | null;
nextId: number | null;
transitionPreviousBy: User | null;
transitionPreviousAt: DateTime | null;
}
export interface Transition {
name: string;
text: string;
isForward: boolean;
}
export interface StepDefinition {
name: string;
text: string;
}
export interface EntityWorkflow {
type: "entity_workflow";
id: number;
relatedEntityClass: string;
relatedEntityId: number;
workflow: Workflow;
currentStep: EntityWorkflowStep;
steps: EntityWorkflowStep[];
datas: WorkflowData;
title: string;
isOnHoldAtCurrentStep: boolean;
_permissions: {
CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT: boolean;
};
}
export interface WorkflowData {
persons: Person[];
}
export interface ExportGeneration { export interface ExportGeneration {
id: string; id: string;
type: "export_generation"; type: "export_generation";
@@ -215,3 +291,8 @@ export interface ExportGeneration {
export interface PrivateCommentEmbeddable { export interface PrivateCommentEmbeddable {
comments: Record<number, string>; comments: Record<number, string>;
} }
/**
* Possible states for the WaitingScreen Component.
*/
export type WaitingScreenState = "pending" | "failure" | "stopped" | "ready";

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import AddressPicker from "ChillMainAssets/vuejs/AddressPicker/AddressPicker.vue";
import {Ref, ref} from "vue";
const showModal: Ref<boolean> = ref(false);
const modalDialogClasses = {"modal-dialog": true, "modal-dialog-scrollable": true, "modal-xl": true};
const clickButton = () => {
showModal.value = true;
}
const closeModal = () => {
showModal.value = false;
}
</script>
<template>
<modal v-if="showModal" :hide-footer="false" :modal-dialog-class="modalDialogClasses" @close="closeModal">
<template v-slot:header>TODO</template>
<template v-slot:body>
<AddressPicker></AddressPicker>
</template>
</modal>
<button class="btn btn-submit" type="button" @click="clickButton">SEARCH ADDRESS</button>
</template>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import {Address, AddressReference} from "ChillMainAssets/types";
import SearchBar from "ChillMainAssets/vuejs/AddressPicker/Component/SearchBar.vue";
import {
AddressAggregated,
AssociatedPostalCode, fetchAddressReference,
getAddressesAggregated,
getPostalCodes,
} from "ChillMainAssets/vuejs/AddressPicker/driver/local-search";
import {computed, Ref, ref} from "vue";
import AddressAggregatedList from "ChillMainAssets/vuejs/AddressPicker/Component/AddressAggregatedList.vue";
import AddressDetailsForm from "ChillMainAssets/vuejs/AddressPicker/Component/AddressDetailsForm.vue";
interface AddressPickerProps {
suggestions?: Address[];
}
const props = withDefaults(defineProps<AddressPickerProps>(), {
suggestions: () => [],
});
const addresses: Ref<AddressAggregated[]> = ref([]);
const postalCodes: Ref<AssociatedPostalCode[]> = ref([]);
const searchTokens: Ref<string[]> = ref([]);
const addressReference: Ref<AddressReference|null> = ref(null);
let abortControllerSearchAddress: null | AbortController = null;
let abortControllerSearchPostalCode: null | AbortController = null;
const searchResultsClasses = computed(() => ({
"mid-size": addressReference !== null,
}));
const onSearch = async function (search: string): Promise<void> {
performSearchForAddress(search);
performSearchForPostalCode(search);
searchTokens.value = [search];
};
const onPickPosition = async (id: string) => {
console.log('Pick Position', id);
addressReference.value = await fetchAddressReference(id);
}
const performSearchForAddress = async (search: string): Promise<void> => {
if (null !== abortControllerSearchAddress) {
abortControllerSearchAddress.abort();
}
if ("" === search) {
addresses.value = [];
abortControllerSearchAddress = null;
return;
}
abortControllerSearchAddress = new AbortController();
console.log("onSearch", search);
try {
addresses.value = await getAddressesAggregated(
search,
abortControllerSearchAddress,
);
abortControllerSearchAddress = null;
// check if there is only one result
if (addresses.value.length === 1 && Object.keys(addresses.value[0].positions).length === 1) {
onPickPosition(Object.keys(addresses.value[0].positions)[0]);
}
} catch (e: unknown) {
if (e instanceof DOMException && e.name === "AbortError") {
console.log("search aborted for:", search);
return;
}
throw e;
}
};
const performSearchForPostalCode = async (search: string): Promise<void> => {
if (null !== abortControllerSearchPostalCode) {
abortControllerSearchPostalCode.abort();
}
if ("" === search) {
addresses.value = [];
abortControllerSearchPostalCode = null;
return;
}
abortControllerSearchPostalCode = new AbortController();
console.log("onSearch", search);
try {
postalCodes.value = await getPostalCodes(
search,
abortControllerSearchPostalCode,
);
abortControllerSearchPostalCode = null;
} catch (e: unknown) {
if (e instanceof DOMException && e.name === "AbortError") {
console.log("search postal code aborted for:", search);
return;
}
throw e;
}
};
</script>
<template>
<search-bar @search="onSearch"></search-bar>
<div class="address-pick-content">
<div class="search-results" :class="searchResultsClasses">
<address-aggregated-list :addresses="addresses" :search-tokens="searchTokens" @pick-position="(id) => onPickPosition(id)"></address-aggregated-list>
</div>
<div v-if="addressReference !== null" class="address-details-form">
<address-details-form :address="addressReference"></address-details-form>
</div>
</div>
</template>
<style scoped lang="scss">
.address-pick-content {
display: flex;
flex-direction: row;
gap: 1rem;
.search-results {
&.mid-size {
width: 50%;
}
}
.address-details-form {
width: 50%;
}
}
</style>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import {AddressAggregated} from "ChillMainAssets/vuejs/AddressPicker/driver/local-search";
import AddressAggregatedListItem from "ChillMainAssets/vuejs/AddressPicker/Component/AddressAggregatedListItem.vue";
interface AddressAggregatedListProps {
addresses: AddressAggregated[];
searchTokens: string[];
}
const props = defineProps<AddressAggregatedListProps>();
const emit = defineEmits<{
pickPosition: [id: string]
}>();
</script>
<template>
<template v-for="a in props.addresses" :key="a.row_number">
<address-aggregated-list-item :address="a" :search-tokens="props.searchTokens" @pick-position="(id) => emit('pickPosition', id)"></address-aggregated-list-item>
</template>
</template>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import {AddressAggregated} from "ChillMainAssets/vuejs/AddressPicker/driver/local-search";
import {computed, ref} from "vue";
interface AddressAggregatedListItemProps {
address: AddressAggregated;
searchTokens: string[];
}
const props = defineProps<AddressAggregatedListItemProps>();
const emit = defineEmits<{
pickPosition: [id: string]
}>();
const showAllPositions = ref<boolean>(false);
const positionsToShow = computed((): Record<string, string> => {
const obj: Record<string, any> = {};
let count = 0;
for (const [id, position] of Object.entries(props.address.positions)) {
obj[id] = position;
count++;
if (count >= 10 && !showAllPositions.value) {
break;
}
}
return obj;
})
const needToShowMorePosition = computed(() => {
return Object.keys(props.address.positions).length > 10;
})
const onClickButton = (id: string) => {
console.log('onClickButton', id);
emit('pickPosition', id);
}
const displayAllPositions = () => {
showAllPositions.value = true;
}
</script>
<template>
<div>
<div class="street">
<span>{{ props.address.street }}</span>
</div>
<div class="postcode">
<span>{{ props.address.code }}</span> <span>{{ address.label }}</span>
</div>
<div class="positions">
<ul>
<li v-for="(position, id) in positionsToShow" :key="id" >
<button type="button" @click="onClickButton(id)" >
{{ position }}
</button>
</li>
<li v-if="needToShowMorePosition">
<button @click="displayAllPositions">show all</button>
</li>
</ul>
</div>
</div>
</template>
<style scoped lang="scss">
.street {
font-variant: small-caps;
font-weight: bold;
}
.postcode {
font-variant: small-caps;
}
.positions ul {
list-style-type: none;
li {
display: inline-block;
margin-right: 2px;
}
}
</style>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import {AddressReference} from "ChillMainAssets/types";
import {computed} from "vue";
import {addressReferenceToAddress} from "ChillMainAssets/vuejs/AddressPicker/helper";
import AddressDetailsContent from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsContent.vue";
export interface AddressDetailsFormProps {
address: AddressReference;
}
const props = defineProps<AddressDetailsFormProps>();
const address = computed(() => addressReferenceToAddress(props.address));
</script>
<template>
<div>
FORM
</div>
<div>
<address-details-content :address="address"></address-details-content>
</div>
</template>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { ADDRESS_PICKER_SEARCH_FOR_ADDRESSES, trans } from 'translator';
const emits = defineEmits<{
search: [search: string];
}>();
let searchTimer = 0;
let searchString: string;
const onInput = function (event: InputEvent) {
const target = event.target as HTMLInputElement;
const value = target.value;
searchString = value;
if (0 === searchTimer) {
window.clearTimeout(searchTimer);
searchTimer = 0;
}
searchTimer = window.setTimeout(() => {
if (value === searchString) {
emits("search", value);
}
}, 500);
};
</script>
<template>
<div class="input-group mb-3">
<span class="input-group-text">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-search" viewBox="0 0 16 16">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"/>
</svg>
</span>
<input type="search" class="form-control" @input="onInput" :placeholder="trans(ADDRESS_PICKER_SEARCH_FOR_ADDRESSES)" />
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,69 @@
import {AddressReference, TranslatableString} from "ChillMainAssets/types";
export interface AddressAggregated {
row_number: number;
street: string;
postcode_id: number;
code: string;
label: string;
positions: Record<string, string>;
}
export interface AssociatedPostalCode {
postcode_id: number;
code: string;
label: string;
country_id: number;
country_code: string;
country_name: TranslatableString;
}
/**
* @throws {DOMException} when fetch is aborted, the property name is always equals to 'AbortError'
*/
export const getAddressesAggregated = async (
search: string,
abortController: AbortController,
): Promise<AddressAggregated[]> => {
const params = new URLSearchParams({ q: search.trim() });
let response = null;
response = await fetch(
`/api/1.0/main/address-reference/aggregated/search?${params}`,
{ signal: abortController.signal },
);
if (response.ok) {
return await response.json();
}
throw new Error(response.statusText);
};
export const getPostalCodes = async (
search: string,
abortController: AbortController,
): Promise<AssociatedPostalCode[]> => {
const params = new URLSearchParams({ q: search.trim() });
let response = null;
response = await fetch(
`/api/1.0/main/address-reference/postal-code/search?${params}`,
{ signal: abortController.signal },
);
if (response.ok) {
return await response.json();
}
throw new Error(response.statusText);
};
export const fetchAddressReference = async (id: string): Promise<AddressReference> => {
const response = await fetch(`/api/1.0/main/address-reference/${id}.json`);
if (response.ok) {
return await response.json();
}
throw new Error(response.statusText);
}

View File

@@ -0,0 +1,21 @@
import {Address, AddressCreation, AddressReference} from "ChillMainAssets/types";
export const addressReferenceToAddress = (reference: AddressReference): AddressCreation => {
return {
street: reference.street,
streetNumber: reference.streetNumber,
postcode: reference.postcode,
floor: "",
corridor: "",
steps: "",
flat: "",
buildingName: "",
distribution: "",
extra: "",
confidential: false,
addressReference: reference,
point: reference.point,
isNoAddress: false,
validFrom: null,
}
}

View File

@@ -0,0 +1,12 @@
import { createApp } from "vue";
import AddressButton from "ChillMainAssets/vuejs/AddressPicker/AddressButton.vue";
document.addEventListener("DOMContentLoaded", async () => {
document
.querySelectorAll<HTMLDivElement>("div[data-address-picker]")
.forEach((elem): void => {
const app = createApp(AddressButton);
app.mount(elem);
});
});

View File

@@ -10,7 +10,8 @@ import { computed, onMounted, ref } from "vue";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
import { fetchExportGenerationStatus } from "ChillMainAssets/lib/api/export"; import { fetchExportGenerationStatus } from "ChillMainAssets/lib/api/export";
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue"; import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
import { ExportGeneration } from "ChillMainAssets/types"; import WaitingScreen from "../_components/WaitingScreen.vue";
import { ExportGeneration, WaitingScreenState } from "ChillMainAssets/types";
interface AppProps { interface AppProps {
exportGenerationId: string; exportGenerationId: string;
@@ -34,13 +35,16 @@ const storedObject = computed<null | StoredObject>(() => {
}); });
const isPending = computed<boolean>(() => status.value === "pending"); const isPending = computed<boolean>(() => status.value === "pending");
const isFetching = computed<boolean>(
() => tryiesForReady.value < maxTryiesForReady,
);
const isReady = computed<boolean>(() => status.value === "ready");
const isFailure = computed<boolean>(() => status.value === "failure");
const filename = computed<string>(() => `${props.title}-${props.createdDate}`); const filename = computed<string>(() => `${props.title}-${props.createdDate}`);
const state = computed<WaitingScreenState>((): WaitingScreenState => {
if (status.value === "empty") {
return "pending";
}
return status.value;
});
/** /**
* counter for the number of times that we check for a new status * counter for the number of times that we check for a new status
*/ */
@@ -85,38 +89,26 @@ onMounted(() => {
</script> </script>
<template> <template>
<div id="waiting-screen"> <WaitingScreen :state="state">
<div <template v-slot:pending>
v-if="isPending && isFetching"
class="alert alert-danger text-center"
>
<div>
<p> <p>
{{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }} {{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }}
</p> </p>
</div> </template>
<div> <template v-slot:stopped>
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
<span class="sr-only">Loading...</span>
</div>
</div>
<div v-if="isPending && !isFetching" class="alert alert-info">
<div>
<p> <p>
{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }} {{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }}
</p> </p>
</div> </template>
</div>
<div v-if="isFailure" class="alert alert-danger text-center"> <template v-slot:failure>
<div>
<p> <p>
{{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }} {{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }}
</p> </p>
</div> </template>
</div>
<div v-if="isReady" class="alert alert-success text-center"> <template v-slot:ready>
<div>
<p> <p>
{{ trans(EXPORT_GENERATION_EXPORT_READY) }} {{ trans(EXPORT_GENERATION_EXPORT_READY) }}
</p> </p>
@@ -127,15 +119,6 @@ onMounted(() => {
:filename="filename" :filename="filename"
></document-action-buttons-group> ></document-action-buttons-group>
</p> </p>
</div>
</div>
</div>
</template> </template>
</WaitingScreen>
<style scoped lang="scss"> </template>
#waiting-screen {
> .alert {
min-height: 350px;
}
}
</style>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { useIntervalFn } from "@vueuse/core";
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
import { returnPathOr } from "ChillMainAssets/lib/return_path/returnPathHelper";
import { ref } from "vue";
import WaitingScreen from "ChillMainAssets/vuejs/_components/WaitingScreen.vue";
import { WaitingScreenState } from "ChillMainAssets/types";
import {
trans,
WORKFLOW_WAIT_TITLE,
WORKFLOW_WAIT_ERROR_WHILE_WAITING,
WORKFLOW_WAIT_SUCCESS,
} from "translator";
interface WaitPostProcessWorkflowComponentProps {
workflowId: number;
expectedStep: string;
}
const props = defineProps<WaitPostProcessWorkflowComponentProps>();
const counter = ref<number>(0);
const MAX_TRYIES = 50;
const state = ref<WaitingScreenState>("pending");
const { pause, resume } = useIntervalFn(
async () => {
try {
const workflow = await fetchWorkflow(props.workflowId);
counter.value++;
if (workflow.currentStep.currentStep.name === props.expectedStep) {
window.location.assign(
returnPathOr("/fr/main/workflow" + workflow.id + "/show"),
);
resume();
state.value = "ready";
}
if (counter.value > MAX_TRYIES) {
pause();
state.value = "failure";
}
} catch (error) {
console.error(error);
pause();
}
},
2000,
{ immediate: true },
);
</script>
<template>
<div class="container">
<WaitingScreen :state="state">
<template v-slot:pending>
<p>
{{ trans(WORKFLOW_WAIT_TITLE) }}
</p>
</template>
<template v-slot:failure>
<p>
{{ trans(WORKFLOW_WAIT_ERROR_WHILE_WAITING) }}
</p>
</template>
<template v-slot:ready>
<p>
{{ trans(WORKFLOW_WAIT_SUCCESS) }}
</p>
</template>
</WaitingScreen>
</div>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,51 @@
import { createApp } from "vue";
import App from "./App.vue";
function mountApp(): void {
const el = document.querySelector<HTMLDivElement>(".screen-wait");
if (!el) {
console.error(
"WaitPostProcessWorkflow: mount element .screen-wait not found",
);
return;
}
const workflowIdAttr = el.getAttribute("data-workflow-id");
const expectedStep = el.getAttribute("data-expected-step") || "";
if (!workflowIdAttr) {
console.error(
"WaitPostProcessWorkflow: data-workflow-id attribute missing on mount element",
);
return;
}
if (!expectedStep) {
console.error(
"WaitPostProcessWorkflow: data-expected-step attribute missing on mount element",
);
return;
}
const workflowId = Number(workflowIdAttr);
if (Number.isNaN(workflowId)) {
console.error(
"WaitPostProcessWorkflow: data-workflow-id is not a valid number:",
workflowIdAttr,
);
return;
}
const app = createApp(App, {
workflowId,
expectedStep,
});
app.mount(el);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mountApp);
} else {
mountApp();
}

View File

@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, useTemplateRef } from "vue"; import { computed, onMounted, ref, useTemplateRef } from "vue";
import type { WorkflowAttachment } from "ChillMainAssets/types"; import type { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types";
import PickGenericDocModal from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocModal.vue"; import PickGenericDocModal from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocModal.vue";
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc"; import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue"; import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue";
import { GenericDoc } from "ChillDocStoreAssets/types"; import { GenericDoc } from "ChillDocStoreAssets/types";
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
interface AppConfig { interface AppConfig {
workflowId: number; workflowId: number;
@@ -34,6 +35,13 @@ const attachedGenericDoc = computed<GenericDocForAccompanyingPeriod[]>(
) as GenericDocForAccompanyingPeriod[], ) as GenericDocForAccompanyingPeriod[],
); );
const workflow = ref<EntityWorkflow | null>(null);
onMounted(async () => {
workflow.value = await fetchWorkflow(Number(props.workflowId));
console.log("workflow", workflow.value);
});
const openModal = function () { const openModal = function () {
pickDocModal.value?.openModal(); pickDocModal.value?.openModal();
}; };
@@ -49,20 +57,30 @@ const onPickGenericDoc = ({
const onRemoveAttachment = (payload: { attachment: WorkflowAttachment }) => { const onRemoveAttachment = (payload: { attachment: WorkflowAttachment }) => {
emit("removeAttachment", payload); emit("removeAttachment", payload);
}; };
const canEditAttachement = computed<boolean>(() => {
if (null === workflow.value) {
return false;
}
return workflow.value._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT;
});
</script> </script>
<template> <template>
<pick-generic-doc-modal <pick-generic-doc-modal
:workflow="workflow"
:accompanying-period-id="props.accompanyingPeriodId" :accompanying-period-id="props.accompanyingPeriodId"
:to-remove="attachedGenericDoc" :to-remove="attachedGenericDoc"
ref="pickDocModal" ref="pickDocModal"
@pickGenericDoc="onPickGenericDoc" @pickGenericDoc="onPickGenericDoc"
></pick-generic-doc-modal> ></pick-generic-doc-modal>
<attachment-list <attachment-list
:workflow="workflow"
:attachments="props.attachments" :attachments="props.attachments"
@removeAttachment="onRemoveAttachment" @removeAttachment="onRemoveAttachment"
></attachment-list> ></attachment-list>
<ul class="record_actions"> <ul v-if="canEditAttachement" class="record_actions">
<li> <li>
<button type="button" class="btn btn-create" @click="openModal"> <button type="button" class="btn btn-create" @click="openModal">
Ajouter une pièce jointe Ajouter une pièce jointe

View File

@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { WorkflowAttachment } from "ChillMainAssets/types"; import { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types";
import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue"; import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue";
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue"; import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
interface AttachmentListProps { interface AttachmentListProps {
attachments: WorkflowAttachment[]; attachments: WorkflowAttachment[];
workflow: EntityWorkflow | null;
} }
const emit = defineEmits<{ const emit = defineEmits<{
@@ -36,7 +37,12 @@ const props = defineProps<AttachmentListProps>();
:stored-object="a.genericDoc.storedObject" :stored-object="a.genericDoc.storedObject"
></document-action-buttons-group> ></document-action-buttons-group>
</li> </li>
<li> <li
v-if="
!workflow?._permissions
.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT
"
>
<button <button
type="button" type="button"
class="btn btn-delete" class="btn btn-delete"

View File

@@ -6,8 +6,10 @@ import {
import PickGenericDocItem from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocItem.vue"; import PickGenericDocItem from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocItem.vue";
import { fetch_generic_docs_by_accompanying_period } from "ChillDocStoreAssets/js/generic-doc-api"; import { fetch_generic_docs_by_accompanying_period } from "ChillDocStoreAssets/js/generic-doc-api";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { EntityWorkflow } from "ChillMainAssets/types";
interface PickGenericDocProps { interface PickGenericDocProps {
workflow: EntityWorkflow | null;
accompanyingPeriodId: number; accompanyingPeriodId: number;
pickedList: GenericDocForAccompanyingPeriod[]; pickedList: GenericDocForAccompanyingPeriod[];
toRemove: GenericDocForAccompanyingPeriod[]; toRemove: GenericDocForAccompanyingPeriod[];
@@ -36,9 +38,21 @@ const isPicked = (genericDoc: GenericDocForAccompanyingPeriod): boolean =>
) !== -1; ) !== -1;
onMounted(async () => { onMounted(async () => {
genericDocs.value = await fetch_generic_docs_by_accompanying_period( const fetchedGenericDocs = await fetch_generic_docs_by_accompanying_period(
props.accompanyingPeriodId, props.accompanyingPeriodId,
); );
const documentClasses = [
"Chill\\DocStoreBundle\\Entity\\AccompanyingCourseDocument",
"Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument",
"Chill\\DocStoreBundle\\Entity\\PersonDocument",
];
genericDocs.value = fetchedGenericDocs.filter(
(doc) =>
!documentClasses.includes(
props.workflow?.relatedEntityClass || "",
) || props.workflow?.relatedEntityId !== doc.identifiers.id,
);
loaded.value = true; loaded.value = true;
}); });

View File

@@ -3,8 +3,10 @@ import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
import { computed, ref, useTemplateRef } from "vue"; import { computed, ref, useTemplateRef } from "vue";
import PickGenericDoc from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDoc.vue"; import PickGenericDoc from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDoc.vue";
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc"; import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
import { EntityWorkflow } from "ChillMainAssets/types";
interface PickGenericDocModalProps { interface PickGenericDocModalProps {
workflow: EntityWorkflow | null;
accompanyingPeriodId: number; accompanyingPeriodId: number;
toRemove: GenericDocForAccompanyingPeriod[]; toRemove: GenericDocForAccompanyingPeriod[];
} }
@@ -80,6 +82,7 @@ defineExpose({ openModal, closeModal });
</template> </template>
<template v-slot:body> <template v-slot:body>
<pick-generic-doc <pick-generic-doc
:workflow="props.workflow"
:accompanying-period-id="props.accompanyingPeriodId" :accompanying-period-id="props.accompanyingPeriodId"
:to-remove="props.toRemove" :to-remove="props.toRemove"
:picked-list="pickeds" :picked-list="pickeds"

View File

@@ -4,24 +4,27 @@
:show-button-details="false" :show-button-details="false"
></address-render-box> ></address-render-box>
<address-details-ref-matching <address-details-ref-matching
v-if="isAddress(props.address)"
:address="props.address" :address="props.address"
@update-address="onUpdateAddress" @update-address="onUpdateAddress"
></address-details-ref-matching> ></address-details-ref-matching>
<address-details-map :address="props.address"></address-details-map> <address-details-map :address="props.address"></address-details-map>
<address-details-geographical-layers <address-details-geographical-layers
v-if="isAddress(props.address)"
:address="props.address" :address="props.address"
></address-details-geographical-layers> ></address-details-geographical-layers>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Address } from "../../../types"; import {Address, AddressCreation} from "../../../types";
import AddressDetailsMap from "./Parts/AddressDetailsMap.vue"; import AddressDetailsMap from "./Parts/AddressDetailsMap.vue";
import AddressRenderBox from "../Entity/AddressRenderBox.vue"; import AddressRenderBox from "../Entity/AddressRenderBox.vue";
import AddressDetailsGeographicalLayers from "./Parts/AddressDetailsGeographicalLayers.vue"; import AddressDetailsGeographicalLayers from "./Parts/AddressDetailsGeographicalLayers.vue";
import AddressDetailsRefMatching from "./Parts/AddressDetailsRefMatching.vue"; import AddressDetailsRefMatching from "./Parts/AddressDetailsRefMatching.vue";
import {isAddress} from "ChillMainAssets/vuejs/_components/AddressDetails/helper";
interface AddressModalContentProps { interface AddressModalContentProps {
address: Address; address: Address|AddressCreation;
} }
const props = defineProps<AddressModalContentProps>(); const props = defineProps<AddressModalContentProps>();

View File

@@ -12,90 +12,91 @@
Voir sur Voir sur
<a :href="makeUrlGoogleMap(props.address)" target="_blank" <a :href="makeUrlGoogleMap(props.address)" target="_blank"
>Google Maps</a >Google Maps</a
> > <a
<a :href="makeUrlOsm(props.address)" target="_blank">OSM</a> :href="makeUrlOsm(props.address)" target="_blank"
>OSM</a>
</p> </p>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref } from "vue"; import {computed, onMounted, ref} from "vue";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import markerIconPng from "leaflet/dist/images/marker-icon.png"; import markerIconPng from "leaflet/dist/images/marker-icon.png";
import L, { LatLngExpression, LatLngTuple } from "leaflet"; import L, { LatLngExpression, LatLngTuple } from "leaflet";
import { Address, Point } from "../../../../types"; import {Address, AddressCreation, Point} from "../../../../types";
import {buildAddressLines, getAddressPoint} from "ChillMainAssets/vuejs/_components/AddressDetails/helper";
import {useLeafletDisplayLayer, useLeafletMap, useLeafletMarker, useLeafletTileLayer} from "vue-use-leaflet";
const lonLatForLeaflet = (point: Point): LatLngTuple => { const lonLatForLeaflet = (point: Point): LatLngTuple => {
return [point.coordinates[1], point.coordinates[0]]; return [point.coordinates[1], point.coordinates[0]];
}; };
export interface MapProps { export interface MapProps {
address: Address; address: Address|AddressCreation;
} }
const props = defineProps<MapProps>(); const props = defineProps<MapProps>();
const map_div = ref<HTMLDivElement | null>(null);
let map: L.Map | null = null;
let marker: L.Marker | null = null;
onMounted(() => {
if (map_div.value === null) {
// there is no map div when the address does not have any Point
return;
}
if (props.address.point !== null) {
map = L.map(map_div.value);
map.setView(lonLatForLeaflet(props.address.point), 18);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
const markerIcon = L.icon({ const markerIcon = L.icon({
iconUrl: markerIconPng, iconUrl: markerIconPng,
iconAnchor: [12, 41], iconAnchor: [12, 41],
}); });
const latLngMarker = computed((): LatLngExpression => {
marker = L.marker(lonLatForLeaflet(props.address.point), { if (props.address === null || props.address.point === null) {
icon: markerIcon, return [0, 0, 0];
});
marker.addTo(map);
} }
return [props.address.point.coordinates[1], props.address.point.coordinates[0], 0]
}); });
const makeUrlGoogleMap = (address: Address): string => { const map_div = ref<HTMLDivElement | null>(null);
const map = useLeafletMap(map_div, {zoom: 18, center: latLngMarker});
const tileLayer = useLeafletTileLayer(
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}
);
useLeafletDisplayLayer(map, tileLayer);
const marker = useLeafletMarker(latLngMarker, {icon: markerIcon});
useLeafletDisplayLayer(map, marker);
const makeUrlGoogleMap = (address: Address|AddressCreation): string => {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("api", "1"); params.append("api", "1");
if (address.point !== null && address.addressReference !== null) { const point = getAddressPoint(address);
if (point !== null && address.addressReference !== null) {
params.append( params.append(
"query", "query",
`${address.point.coordinates[1]} ${address.point.coordinates[0]}`, `${point.coordinates[1]} ${point.coordinates[0]}`,
); );
} else { } else {
params.append("query", address.lines.join(", ")); params.append("query", buildAddressLines(address).join(", "));
} }
return `https://www.google.com/maps/search/?${params.toString()}`; return `https://www.google.com/maps/search/?${params.toString()}`;
}; };
const makeUrlOsm = (address: Address): string => { const makeUrlOsm = (address: Address|AddressCreation): string => {
if (address.point !== null && address.addressReference !== null) { const point = getAddressPoint(address);
if (point !== null && address.addressReference !== null) {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("mlat", `${address.point.coordinates[1]}`); params.append("mlat", `${point.coordinates[1]}`);
params.append("mlon", `${address.point.coordinates[0]}`); params.append("mlon", `${point.coordinates[0]}`);
const hashParams = new URLSearchParams(); const hashParams = new URLSearchParams();
hashParams.append( hashParams.append(
"map", "map",
`18/${address.point.coordinates[1]}/${address.point.coordinates[0]}`, `18/${point.coordinates[1]}/${point.coordinates[0]}`,
); );
return `https://www.openstreetmap.org/?${params.toString()}#${hashParams.toString()}`; return `https://www.openstreetmap.org/?${params.toString()}#${hashParams.toString()}`;
} }
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("query", address.lines.join(", ")); params.append("query", buildAddressLines(address).join(", "));
return `https://www.openstreetmap.org/search?${params.toString()}`; return `https://www.openstreetmap.org/search?${params.toString()}`;
}; };

View File

@@ -0,0 +1,46 @@
import {Address, AddressCreation, Point} from "ChillMainAssets/types";
import addressFormatter, {Input} from "@fragaria/address-formatter";
/**
* Checks if the given object is of type Address by verifying the existence
* of the `lines` property and confirming that it is an array of strings.
*
* @param {AddressCreation | Address} obj - The object to check.
* @return {boolean} Returns true if the object is of type Address, otherwise false.
*/
export function isAddress(obj: AddressCreation | Address): obj is Address {
return (obj as any).lines !== undefined && Array.isArray((obj as any).lines);
}
function buildAddressFormatterObject(address: AddressCreation): Input {
return {
city: address.postcode.name,
postcode: address.postcode.code,
countryCode: address.postcode.country.code,
street: address.street,
houseNumber: address.streetNumber,
};
}
export const buildAddressLines = (address: AddressCreation|Address): string[] => {
if (isAddress(address)) {
return address.lines;
}
const lines = addressFormatter.format(buildAddressFormatterObject(address), {output: 'array', countryCode: address.addressReference.postcode.country.code });
console.log('lines:', lines);
return lines;
}
export const buildAddressText = (address: AddressCreation|Address): string => {
return buildAddressLines(address).join(' - ');
}
export const getAddressPoint = (address: AddressCreation|Address): Point|null => {
if (isAddress(address)) {
return address.point;
}
return address.addressReference?.point;
}

View File

@@ -4,14 +4,14 @@
<div v-if="isConfidential"> <div v-if="isConfidential">
<confidential :position-btn-far="true"> <confidential :position-btn-far="true">
<template #confidential-content> <template #confidential-content>
<div v-if="isMultiline === true"> <div v-if="isMultiline">
<p <p
v-for="(l, i) in address.lines" v-for="(l, i) in buildAddressLines(address)"
:key="`line-${i}`" :key="`line-${i}`"
> >
{{ l }} {{ l }}
</p> </p>
<p v-if="showButtonDetails"> <p v-if="showButtonDetails && isAddress(address) ">
<address-details-button <address-details-button
:address_id="address.address_id" :address_id="address.address_id"
:address_ref_status="address.refStatus" :address_ref_status="address.refStatus"
@@ -19,8 +19,8 @@
</p> </p>
</div> </div>
<div v-else> <div v-else>
<p v-if="'' !== address.text" class="street"> <p v-if="'' !== buildAddressText(address)" class="street">
{{ address.text }} {{ buildAddressText(address) }}
</p> </p>
<p <p
v-if="null !== address.postcode" v-if="null !== address.postcode"
@@ -29,8 +29,8 @@
{{ address.postcode.code }} {{ address.postcode.code }}
{{ address.postcode.name }} {{ address.postcode.name }}
</p> </p>
<p v-if="null !== address.country" class="country"> <p v-if="null !== address.postcode" class="country">
{{ localizeString(address.country.name) }} {{ localizeString(address.postcode.country.name) }}
</p> </p>
</div> </div>
</template> </template>
@@ -38,11 +38,11 @@
</div> </div>
<div v-if="!isConfidential"> <div v-if="!isConfidential">
<div v-if="isMultiline === true"> <div v-if="isMultiline">
<p v-for="(l, i) in address.lines" :key="`line-${i}`"> <p v-for="(l, i) in buildAddressLines(address)" :key="`line-${i}`">
{{ l }} {{ l }}
</p> </p>
<p v-if="showButtonDetails"> <p v-if="showButtonDetails && isAddress(address) ">
<address-details-button <address-details-button
:address_id="address.address_id" :address_id="address.address_id"
:address_ref_status="address.refStatus" :address_ref_status="address.refStatus"
@@ -50,9 +50,9 @@
</p> </p>
</div> </div>
<div v-else> <div v-else>
<p v-if="address.text" class="street"> <p v-if="'' !== buildAddressText(address)" class="street">
{{ address.text }} {{ buildAddressText(address)}}
<template v-if="showButtonDetails"> <template v-if="showButtonDetails && isAddress(address) ">
<address-details-button <address-details-button
:address_id="address.address_id" :address_id="address.address_id"
:address_ref_status="address.refStatus" :address_ref_status="address.refStatus"
@@ -63,68 +63,49 @@
</div> </div>
</component> </component>
<div v-if="useDatePane === true" class="address-more"> <div v-if="useDatePane" class="address-more">
<div v-if="address.validFrom"> <div v-if="address.validFrom">
<span class="validFrom"> <span class="validFrom">
<b>{{ trans(ADDRESS_VALID_FROM) }}</b <b>{{ trans(ADDRESS_VALID_FROM) }}</b
>: {{ $d(address.validFrom.date) }} >: {{ address.validFrom?.datetime8601 }}
</span> </span>
</div> </div>
<div v-if="address.validTo"> <div v-if="isAddress(address) && address.validTo !== null">
<span class="validTo"> <span class="validTo">
<b>{{ trans(ADDRESS_VALID_TO) }}</b <b>{{ trans(ADDRESS_VALID_TO) }}</b
>: {{ $d(address.validTo.date) }} >: {{ address.validTo?.datetime8601 }}
</span> </span>
</div> </div>
</div> </div>
</component> </component>
</template> </template>
<script> <script setup lang="ts">
import { computed } from "vue";
import Confidential from "ChillMainAssets/vuejs/_components/Confidential.vue"; import Confidential from "ChillMainAssets/vuejs/_components/Confidential.vue";
import AddressDetailsButton from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsButton.vue"; import AddressDetailsButton from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsButton.vue";
import { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO } from "translator"; import { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO } from "translator";
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
import {Address, AddressCreation} from "ChillMainAssets/types";
import {isAddress, buildAddressLines, buildAddressText} from "ChillMainAssets/vuejs/_components/AddressDetails/helper";
export default { const props = withDefaults(
name: "AddressRenderBox", defineProps<{
methods: { localizeString }, address: Address|AddressCreation;
components: { isMultiline?: boolean;
Confidential, useDatePane?: boolean;
AddressDetailsButton, showButtonDetails?: boolean;
}, }>(),
props: { {
address: { isMultiline: true,
type: Object, useDatePane: false,
}, showButtonDetails: true,
isMultiline: { }
default: true, );
type: Boolean,
}, const component = computed(() => (props.isMultiline ? "div" : "span"));
useDatePane: { const multiline = computed(() => (props.isMultiline ? "multiline" : ""));
default: false, const isConfidential = computed(() => Boolean(props.address?.confidential));
type: Boolean,
},
showButtonDetails: {
default: true,
type: Boolean,
},
},
setup() {
return { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO };
},
computed: {
component() {
return this.isMultiline === true ? "div" : "span";
},
multiline() {
return this.isMultiline === true ? "multiline" : "";
},
isConfidential() {
return this.address.confidential;
},
},
};
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -84,6 +84,8 @@ const emits = defineEmits<{
} }
.modal-header .close { .modal-header .close {
border-top-right-radius: 0.3rem; border-top-right-radius: 0.3rem;
margin-right: 0;
margin-left: auto;
} }
/* /*
* The following styles are auto-applied to elements with * The following styles are auto-applied to elements with

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { WaitingScreenState } from "ChillMainAssets/types";
interface Props {
state: WaitingScreenState;
}
const props = defineProps<Props>();
</script>
<template>
<div id="waiting-screen">
<div
v-if="props.state === 'pending' && !!$slots.pending"
class="alert alert-danger text-center"
>
<div>
<slot name="pending"></slot>
</div>
<div>
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
<span class="sr-only">Loading...</span>
</div>
</div>
<div
v-if="props.state === 'stopped' && !!$slots.stopped"
class="alert alert-info"
>
<div>
<slot name="stopped"></slot>
</div>
</div>
<div
v-if="props.state === 'failure' && !!$slots.failure"
class="alert alert-danger text-center"
>
<div>
<slot name="failure"></slot>
</div>
</div>
<div
v-if="props.state === 'ready' && !!$slots.ready"
class="alert alert-success text-center"
>
<div>
<slot name="ready"></slot>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
#waiting-screen {
> .alert {
min-height: 350px;
}
}
</style>

View File

@@ -44,17 +44,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endblock content_view_actions_duplicate_link %} {% endblock content_view_actions_duplicate_link %}
{% block content_view_actions_merge %} {% block content_view_actions_merge %}{% endblock %}
<li>
<a href="{{ chill_path_add_return_path('chill_thirdparty_find_duplicate',
{ 'thirdparty_id': entity.id }) }}"
title="{{ 'Merge'|trans }}"
class="btn btn-misc">
<i class="bi bi-chevron-contract"></i>
{{ 'Merge'|trans }}
</a>
</li>
{% endblock %}
{% block content_view_actions_edit_link %} {% block content_view_actions_edit_link %}
{% if chill_crud_action_exists(crud_name, 'edit') %} {% if chill_crud_action_exists(crud_name, 'edit') %}
{% if is_granted(chill_crud_config('role', crud_name, 'edit'), entity) %} {% if is_granted(chill_crud_config('role', crud_name, 'edit'), entity) %}

View File

@@ -0,0 +1,15 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block css %}
{{ encore_entry_link_tags('address_picker') }}
{% endblock %}
{% block js %}
{{ encore_entry_script_tags('address_picker') }}
{% endblock %}
{% block content %}
<div data-address-picker="data-address-picker"></div>
{% endblock %}

View File

@@ -280,11 +280,17 @@
</div> </div>
{% endblock %} {% endblock %}
{% block pick_linked_entities_widget %}
<input type="hidden" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value|escape('html_attr') }}" {% endif %} data-input-uniqid="{{ form.vars['uniqid'] }}" />
<div data-input-uniqid="{{ form.vars['uniqid'] }}" data-module="pick-linked-entities" data-pick-entities-type="{{ form.vars['pick-entities-type'] }}"
></div>
{% block pick_linked_entities_widget %}
<input type="hidden" {{ block('widget_attributes') }}
{% if value is not empty %}value="{{ value|escape('html_attr') }}" {% endif %}
data-input-uniqid="{{ form.vars['uniqid'] }}"/>
<div
data-input-uniqid="{{ form.vars['uniqid'] }}"
data-module="pick-linked-entities"
data-pick-entities-type="{{ form.vars['pick-entities-type'] }}"
data-suggested="{{ form.vars['suggested']|json_encode|escape('html_attr') }}"
></div>
{% endblock %} {% endblock %}
{% block pick_postal_code_widget %} {% block pick_postal_code_widget %}

View File

@@ -8,36 +8,36 @@
<div class="col-md-10"> <div class="col-md-10">
<h2>{{ 'absence.My absence'|trans }}</h2> <h2>{{ 'absence.My absence'|trans }}</h2>
{% if user.absenceStart is not null %}
<div> <div>
<p>{{ 'absence.You are listed as absent, as of'|trans }} {{ user.absenceStart|format_date('long') }}</p> {% if user.absenceStart is not null %}
<ul class="record_actions sticky-form-buttons"> <div class="alert alert-success flash_message">{{ 'absence.You are listed as absent, as of {date, date, short}'|trans({
<li> date: user.absenceStart
<a href="{{ path('chill_main_user_absence_unset') }}" }) }}
class="btn btn-delete">{{ 'absence.Unset absence'|trans }}</a> {% if user.absenceEnd is not null %}
</li> {{ 'until %date%'|trans({'%date%': user.absenceEnd|format_date('short') }) }}
</ul> {% endif %}
</div> </div>
{% else %} {% else %}
<div> <div class="alert alert-warning flash_message">{{ 'absence.No absence listed'|trans }}</div>
<p class="chill-no-data-statement">{{ 'absence.No absence listed'|trans }}</p> {% endif %}
</div> </div>
<div> <div>
{{ form_start(form) }} {{ form_start(form) }}
{{ form_row(form.absenceStart) }} {{ form_row(form.absenceStart) }}
{{ form_row(form.absenceEnd) }}
<ul class="record_actions sticky-form-buttons"> <ul class="record_actions sticky-form-buttons">
<li>
<a class="btn btn-delete" title="Modifier" href="{{ path('chill_main_user_absence_unset') }}">{{ 'absence.Unset absence'|trans }}</a>
</li>
<li> <li>
<button class="btn btn-save" type="submit"> <button class="btn btn-save" type="submit">
{{ 'Save'|trans }} {{ 'Save'|trans }}
</button> </button>
</li> </li>
</ul> </ul>
{{ form_end(form) }} {{ form_end(form) }}
</div> </div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -5,7 +5,7 @@
role="button" role="button"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
aria-expanded="false"> aria-expanded="false">
<i class="fa fa-flash"></i> <i class="bi bi-lightning-fill"></i>
</a> </a>
<div class="dropdown-menu"> <div class="dropdown-menu">
{% for menu in menus %} {% for menu in menus %}

View File

@@ -64,7 +64,7 @@
{{ form_widget(flag.immediate_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }} {{ form_widget(flag.immediate_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }}
</td> </td>
<td class="text-center"> <td class="text-center">
{{ form_widget(flag.daily_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }} {{ form_widget(flag.daily_digest, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -58,12 +58,14 @@
{% endif %} {% endif %}
</section> </section>
{% if signatures|length > 0 %}
<section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section>
{% endif %}
<section class="step my-4">{% include '@ChillMain/Workflow/_attachment.html.twig' %}</section> <section class="step my-4">{% include '@ChillMain/Workflow/_attachment.html.twig' %}</section>
<section class="step my-4">{% include '@ChillMain/Workflow/_follow.html.twig' %}</section> <section class="step my-4">{% include '@ChillMain/Workflow/_follow.html.twig' %}</section>
{% if signatures|length > 0 %} {% if entity_workflow.currentStep.sends|length > 0 %}
<section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section>
{% elseif entity_workflow.currentStep.sends|length > 0 %}
<section class="step my-4"> <section class="step my-4">
<h2>{{ 'workflow.external_views.title'|trans({'numberOfSends': entity_workflow.currentStep.sends|length }) }}</h2> <h2>{{ 'workflow.external_views.title'|trans({'numberOfSends': entity_workflow.currentStep.sends|length }) }}</h2>
{% include '@ChillMain/Workflow/_send_views_list.html.twig' with {'sends': entity_workflow.currentStep.sends} %} {% include '@ChillMain/Workflow/_send_views_list.html.twig' with {'sends': entity_workflow.currentStep.sends} %}

View File

@@ -0,0 +1,18 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block title %}{{ 'workflow.signature.waiting_for'|trans }}{% endblock %}
{% block css %}
{{ encore_entry_link_tags('page_workflow_waiting_post_process') }}
{% endblock %}
{% block js %}
{{ encore_entry_script_tags('page_workflow_waiting_post_process') }}
{% endblock %}
{% block content %}
<h1>{{ block('title') }}</h1>
<div class="screen-wait" data-workflow-id="{{ workflow.id|e('html_attr') }}" data-expected-step="{{ expectedStep|e('html_attr') }}"></div>
{% endblock %}

View File

@@ -79,7 +79,7 @@
<div class="d-flex flex-row mb-5 alert alert-warning" role="alert"> <div class="d-flex flex-row mb-5 alert alert-warning" role="alert">
<p class="m-2">{{'absence.You are marked as being absent'|trans }}</p> <p class="m-2">{{'absence.You are marked as being absent'|trans }}</p>
<span class="ms-auto"> <span class="ms-auto">
<a class="btn btn-remove" title="Modifier" href="{{ path('chill_main_user_absence_index') }}">{{ 'absence.Unset absence'|trans }}</a> <a class="btn btn-delete" title="Modifier" href="{{ path('chill_main_user_absence_unset') }}">{{ 'absence.Unset absence'|trans }}</a>
</span> </span>
</div> </div>
{% endif %} {% endif %}

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\MainBundle\Security\Authorization;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Workflow\Registry;
final class EntityWorkflowAttachmentVoter extends Voter
{
public function __construct(
private readonly Registry $registry,
) {}
public const EDIT = 'CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT';
protected function supports(string $attribute, $subject): bool
{
return $subject instanceof EntityWorkflow && self::EDIT === $attribute;
}
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
if (!$subject instanceof EntityWorkflow) {
throw new \UnexpectedValueException('Subject must be an instance of EntityWorkflow');
}
if ($subject->isFinal()) {
return false;
}
$workflow = $this->registry->get($subject, $subject->getWorkflowName());
$marking = $workflow->getMarking($subject);
foreach ($marking->getPlaces() as $place => $int) {
$placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place);
if ($placeMetadata['isSentExternal'] ?? false) {
return false;
}
}
return true;
}
}

View File

@@ -66,6 +66,7 @@ class AddressNormalizer implements ContextAwareNormalizerInterface, NormalizerAw
'name' => $address->getPostCode()->getName(), 'name' => $address->getPostCode()->getName(),
'code' => $address->getPostCode()->getCode(), 'code' => $address->getPostCode()->getCode(),
'center' => $address->getPostcode()->getCenter(), 'center' => $address->getPostcode()->getCenter(),
'country' => $this->normalizer->normalize($address->getPostCode()->getCountry(), $format, [AbstractNormalizer::GROUPS => ['read']]),
], ],
'country' => [ 'country' => [
'id' => $address->getPostCode()->getCountry()->getId(), 'id' => $address->getPostCode()->getCountry()->getId(),

View File

@@ -12,18 +12,25 @@ declare(strict_types=1);
namespace Chill\MainBundle\Serializer\Normalizer; namespace Chill\MainBundle\Serializer\Normalizer;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Security\Authorization\EntityWorkflowAttachmentVoter;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Chill\MainBundle\Workflow\Helper\MetadataExtractor; use Chill\MainBundle\Workflow\Helper\MetadataExtractor;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Workflow\Registry; use Symfony\Component\Workflow\Registry;
class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareInterface final class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareInterface
{ {
use NormalizerAwareTrait; use NormalizerAwareTrait;
public function __construct(private readonly EntityWorkflowManager $entityWorkflowManager, private readonly MetadataExtractor $metadataExtractor, private readonly Registry $registry) {} public function __construct(
private readonly EntityWorkflowManager $entityWorkflowManager,
private readonly MetadataExtractor $metadataExtractor,
private readonly Registry $registry,
private readonly Security $security,
) {}
/** /**
* @param EntityWorkflow $object * @param EntityWorkflow $object
@@ -46,6 +53,9 @@ class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareIn
'datas' => $this->normalizer->normalize($handler->getEntityData($object), $format, $context), 'datas' => $this->normalizer->normalize($handler->getEntityData($object), $format, $context),
'title' => $handler->getEntityTitle($object), 'title' => $handler->getEntityTitle($object),
'isOnHoldAtCurrentStep' => $object->isOnHoldAtCurrentStep(), 'isOnHoldAtCurrentStep' => $object->isOnHoldAtCurrentStep(),
'_permissions' => [
EntityWorkflowAttachmentVoter::EDIT => $this->security->isGranted(EntityWorkflowAttachmentVoter::EDIT, $object),
],
]; ];
} }

View File

@@ -39,6 +39,8 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
'label' => '', 'label' => '',
'email' => '', 'email' => '',
'isAbsent' => false, 'isAbsent' => false,
'absenceStart' => null,
'absenceEnd' => null,
]; ];
public function __construct(private readonly UserRender $userRender, private readonly ClockInterface $clock) {} public function __construct(private readonly UserRender $userRender, private readonly ClockInterface $clock) {}
@@ -77,6 +79,11 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
['docgen:expects' => PhoneNumber::class, 'groups' => 'docgen:read'] ['docgen:expects' => PhoneNumber::class, 'groups' => 'docgen:read']
); );
$absenceDatesContext = array_merge(
$context,
['docgen:expects' => \DateTimeImmutable::class, 'groups' => 'docgen:read']
);
if (null === $object && 'docgen' === $format) { if (null === $object && 'docgen' === $format) {
return [...self::NULL_USER, 'phonenumber' => $this->normalizer->normalize(null, $format, $phonenumberContext), 'civility' => $this->normalizer->normalize(null, $format, $civilityContext), 'user_job' => $this->normalizer->normalize(null, $format, $userJobContext), 'main_center' => $this->normalizer->normalize(null, $format, $centerContext), 'main_scope' => $this->normalizer->normalize(null, $format, $scopeContext), 'current_location' => $this->normalizer->normalize(null, $format, $locationContext), 'main_location' => $this->normalizer->normalize(null, $format, $locationContext)]; return [...self::NULL_USER, 'phonenumber' => $this->normalizer->normalize(null, $format, $phonenumberContext), 'civility' => $this->normalizer->normalize(null, $format, $civilityContext), 'user_job' => $this->normalizer->normalize(null, $format, $userJobContext), 'main_center' => $this->normalizer->normalize(null, $format, $centerContext), 'main_scope' => $this->normalizer->normalize(null, $format, $scopeContext), 'current_location' => $this->normalizer->normalize(null, $format, $locationContext), 'main_location' => $this->normalizer->normalize(null, $format, $locationContext)];
} }
@@ -99,6 +106,8 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
'main_center' => $this->normalizer->normalize($object->getMainCenter(), $format, $centerContext), 'main_center' => $this->normalizer->normalize($object->getMainCenter(), $format, $centerContext),
'main_scope' => $this->normalizer->normalize($object->getMainScope($at), $format, $scopeContext), 'main_scope' => $this->normalizer->normalize($object->getMainScope($at), $format, $scopeContext),
'isAbsent' => $object->isAbsent(), 'isAbsent' => $object->isAbsent(),
'absenceStart' => $this->normalizer->normalize($object->getAbsenceStart(), $format, $absenceDatesContext),
'absenceEnd' => $this->normalizer->normalize($object->getAbsenceEnd(), $format, $absenceDatesContext),
]; ];
if ('docgen' === $format) { if ('docgen' === $format) {

View File

@@ -0,0 +1,85 @@
<?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\User\UpdateProfile;
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand;
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommandHandler;
use Chill\MainBundle\Entity\User;
use libphonenumber\PhoneNumber;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @coversNothing
*/
final class UpdateProfileCommandHandlerTest extends TestCase
{
public function testUpdateProfileWithNullPhoneAndFlags(): void
{
$user = new User();
// Pre-set some flags to opposite values to check they are updated
$flag = 'tickets';
$user->setNotificationImmediately($flag, true);
$user->setNotificationDailyDigest($flag, true);
$command = new UpdateProfileCommand(null);
$command->notificationFlags = [
$flag => [
'immediate_email' => false,
'daily_digest' => false,
],
];
(new UpdateProfileCommandHandler())->updateProfile($user, $command);
self::assertNull($user->getPhonenumber(), 'Phone should be set to null');
self::assertFalse($user->isNotificationSendImmediately($flag));
self::assertFalse($user->isNotificationDailyDigest($flag));
}
public function testUpdateProfileWithPhoneAndMultipleFlags(): void
{
$user = new User();
$phone = new PhoneNumber();
$phone->setCountryCode(33); // France
$phone->setNationalNumber(612345678);
$command = new UpdateProfileCommand($phone);
$command->notificationFlags = [
'reports' => [
'immediate_email' => true,
'daily_digest' => false,
],
'activities' => [
'immediate_email' => false,
'daily_digest' => true,
],
];
(new UpdateProfileCommandHandler())->updateProfile($user, $command);
// Phone assigned
self::assertInstanceOf(PhoneNumber::class, $user->getPhonenumber());
self::assertSame(33, $user->getPhonenumber()->getCountryCode());
self::assertSame('612345678', (string) $user->getPhonenumber()->getNationalNumber());
// Flags applied
self::assertTrue($user->isNotificationSendImmediately('reports'));
self::assertFalse($user->isNotificationDailyDigest('reports'));
self::assertFalse($user->isNotificationSendImmediately('activities'));
self::assertTrue($user->isNotificationDailyDigest('activities'));
}
}

View File

@@ -0,0 +1,103 @@
<?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\User\UpdateProfile;
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
use Chill\MainBundle\Notification\NotificationFlagManager;
use libphonenumber\PhoneNumber;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Translation\TranslatableMessage;
/**
* @internal
*
* @coversNothing
*/
final class UpdateProfileCommandTest extends TestCase
{
public function testCreateTransfersPhonenumberAndNotificationFlags(): void
{
$user = new User();
// set a phone number
$phone = new PhoneNumber();
$phone->setCountryCode(32); // Belgium
$phone->setNationalNumber(471234567);
$user->setPhonenumber($phone);
// configure notification flags on the user via helpers
$flagA = 'foo';
$flagB = 'bar';
// For tickets: immediate true, daily false
$user->setNotificationImmediately($flagA, true);
$user->setNotificationDailyDigest($flagA, false);
// For reports: immediate false, daily true
$user->setNotificationImmediately($flagB, false);
$user->setNotificationDailyDigest($flagB, true);
// a third flag not explicitly set to validate default behavior from User
$flagC = 'foobar'; // by default immediate-email is true, daily-digest is false per User::getNotificationFlagData
$manager = $this->createNotificationFlagManager([$flagA, $flagB, $flagC]);
$command = UpdateProfileCommand::create($user, $manager);
// phone number transferred
self::assertInstanceOf(PhoneNumber::class, $command->phonenumber);
self::assertSame($phone->getCountryCode(), $command->phonenumber->getCountryCode());
self::assertSame($phone->getNationalNumber(), $command->phonenumber->getNationalNumber());
// flags transferred consistently
self::assertArrayHasKey($flagA, $command->notificationFlags);
self::assertArrayHasKey($flagB, $command->notificationFlags);
self::assertArrayHasKey($flagC, $command->notificationFlags);
self::assertSame([
'immediate_email' => true,
'daily_digest' => false,
], $command->notificationFlags[$flagA]);
self::assertSame([
'immediate_email' => false,
'daily_digest' => true,
], $command->notificationFlags[$flagB]);
// default from User::getNotificationFlagData -> immediate true, daily false
self::assertSame([
'immediate_email' => true,
'daily_digest' => false,
], $command->notificationFlags[$flagC]);
}
private function createNotificationFlagManager(array $flags): NotificationFlagManager
{
$providers = array_map(fn (string $flag) => new class ($flag) implements NotificationFlagProviderInterface {
public function __construct(private readonly string $flag) {}
public function getFlag(): string
{
return $this->flag;
}
public function getLabel(): TranslatableMessage
{
return new TranslatableMessage($this->flag);
}
}, $flags);
return new NotificationFlagManager($providers);
}
}

View File

@@ -0,0 +1,118 @@
<?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\MainBundle\Tests\Controller;
use Chill\MainBundle\Controller\AddressReferenceAggregatedApiController;
use Chill\MainBundle\Repository\AddressReferenceRepositoryInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Security\Core\Security;
/**
* @internal
*
* @covers \Chill\MainBundle\Controller\AddressReferenceAggregatedApiController
*/
final class AddressReferenceAggregatedApiControllerTest extends TestCase
{
use ProphecyTrait;
public function testAccessDeniedWhenNotAuthenticated(): void
{
$security = $this->prophesize(Security::class);
$security->isGranted('IS_AUTHENTICATED')->willReturn(false);
$repo = $this->prophesize(AddressReferenceRepositoryInterface::class);
$controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal());
$request = new Request(query: ['q' => 'anything']);
$this->expectException(AccessDeniedHttpException::class);
$controller->search($request);
}
public function testBadRequestWhenQueryIsMissing(): void
{
$security = $this->prophesize(Security::class);
$security->isGranted('IS_AUTHENTICATED')->willReturn(true);
$repo = $this->prophesize(AddressReferenceRepositoryInterface::class);
$controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal());
$request = new Request();
$this->expectException(BadRequestHttpException::class);
$this->expectExceptionMessage('Parameter "q" is required.');
$controller->search($request);
}
public function testBadRequestWhenQueryIsEmpty(): void
{
$security = $this->prophesize(Security::class);
$security->isGranted('IS_AUTHENTICATED')->willReturn(true);
$repo = $this->prophesize(AddressReferenceRepositoryInterface::class);
$controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal());
$request = new Request(query: ['q' => ' ']);
$this->expectException(BadRequestHttpException::class);
$this->expectExceptionMessage('Parameter "q" is required and cannot be empty.');
$controller->search($request);
}
public function testSuccessfulSearchReturnsJsonAndCallsRepositoryWithTrimmedQuery(): void
{
$security = $this->prophesize(Security::class);
$security->isGranted('IS_AUTHENTICATED')->willReturn(true);
$expectedQuery = 'foo';
$iterableResult = new \ArrayIterator([
[
'street' => 'Main Street',
'postcode_id' => 123,
'code' => '1000',
'label' => 'Brussels',
'positions' => ['1' => '12', '2' => '14'],
'row_number' => 1,
],
]);
$repo = $this->prophesize(AddressReferenceRepositoryInterface::class);
$repo->findAggregatedBySearchString($expectedQuery)->willReturn($iterableResult)->shouldBeCalledOnce();
$controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal());
// Provide spaces around to ensure trimming is applied
$request = new Request(query: ['q' => " {$expectedQuery} "]);
$response = $controller->search($request);
self::assertSame(200, $response->getStatusCode());
$data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertIsArray($data);
self::assertCount(1, $data);
self::assertSame('Main Street', $data[0]['street']);
self::assertSame(123, $data[0]['postcode_id']);
self::assertSame('1000', $data[0]['code']);
self::assertSame('Brussels', $data[0]['label']);
}
}

View File

@@ -45,7 +45,7 @@ final class UserControllerTest extends WebTestCase
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
$username = 'Test_user'.uniqid(); $username = 'Test_user'.uniqid();
$password = 'Password1234!'; $password = 'Password_1234!';
// Fill in the form and submit it // Fill in the form and submit it
@@ -99,7 +99,7 @@ final class UserControllerTest extends WebTestCase
{ {
$client = $this->getClientAuthenticatedAsAdmin(); $client = $this->getClientAuthenticatedAsAdmin();
$crawler = $client->request('GET', "/fr/admin/user/{$userId}/edit_password"); $crawler = $client->request('GET', "/fr/admin/user/{$userId}/edit_password");
$newPassword = '1234Password!'; $newPassword = '1234_Password!';
$form = $crawler->selectButton('Changer le mot de passe')->form([ $form = $crawler->selectButton('Changer le mot de passe')->form([
'chill_mainbundle_user_password[new_password][first]' => $newPassword, 'chill_mainbundle_user_password[new_password][first]' => $newPassword,

View File

@@ -96,11 +96,13 @@ final class NotificationTest extends KernelTestCase
$this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when no notification flags are set, by default immediate email'); $this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when no notification flags are set, by default immediate email');
// immediate-email preference // immediate-email preference
$user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_IMMEDIATE_EMAIL, User::NOTIF_FLAG_DAILY_DIGEST]]); $user->setNotificationImmediately('test_notification_type', true);
$user->setNotificationDailyDigest('test_notification_type', true);
$this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when preferences contain immediate-email'); $this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when preferences contain immediate-email');
// daily-email preference // daily-email preference
$user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_DAILY_DIGEST]]); $user->setNotificationDailyDigest('test_notification_type', true);
$user->setNotificationImmediately('test_notification_type', false);
$this->assertFalse($user->isNotificationSendImmediately($notification->getType()), 'Should return false when preference is daily-email only'); $this->assertFalse($user->isNotificationSendImmediately($notification->getType()), 'Should return false when preference is daily-email only');
$this->assertTrue($user->isNotificationDailyDigest($notification->getType()), 'Should return true when preference is daily-email'); $this->assertTrue($user->isNotificationDailyDigest($notification->getType()), 'Should return true when preference is daily-email');

View File

@@ -0,0 +1,82 @@
<?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\MainBundle\Tests\Entity;
use Chill\MainBundle\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class UserNotificationFlagsPersistenceTest extends KernelTestCase
{
public function testFlushPersistsNotificationFlagsChanges(): void
{
self::bootKernel();
$em = self::getContainer()->get('doctrine')->getManager();
$user = new User();
$user->setUsername('user_'.bin2hex(random_bytes(4)));
$user->setLabel('Test User');
$user->setPassword('secret');
// Étape 1: créer et persister lutilisateur
$em->persist($user);
$em->flush();
$id = $user->getId();
self::assertNotNull($id, 'User should have an ID after flush');
try {
// Sanity check: par défaut, pas de daily digest pour "alerts"
self::assertFalse($user->isNotificationDailyDigest('alerts'));
// Étape 2: activer le daily digest -> setNotificationFlagElement réassigne la propriété
$user->setNotificationDailyDigest('alerts', true);
$em->flush(); // persist le changement
$em->clear(); // simule un nouveau cycle de requête
// Étape 3: recharger depuis la base et vérifier la persistance
/** @var User $reloaded */
$reloaded = $em->find(User::class, $id);
self::assertNotNull($reloaded);
self::assertTrue(
$reloaded->isNotificationDailyDigest('alerts'),
'Daily digest flag should be persisted'
);
// Étape 4: modifier via setNotificationFlagData (remplacement du tableau)
// Cette méthode doit réassigner la propriété (copie -> réassignation)
$reloaded->setNotificationImmediately('alerts', true);
$reloaded->setNotificationDailyDigest('alerts', false);
$em->flush();
$em->clear();
/** @var User $reloaded2 */
$reloaded2 = $em->find(User::class, $id);
self::assertNotNull($reloaded2);
// Le daily digest nest plus actif, seul immediate-email est présent
self::assertFalse($reloaded2->isNotificationDailyDigest('alerts'));
self::assertTrue($reloaded2->isNotificationSendImmediately('alerts'));
} finally {
// Nettoyage
$managed = $em->find(User::class, $id);
if (null !== $managed) {
$em->remove($managed);
$em->flush();
}
$em->clear();
}
}
}

View File

@@ -67,4 +67,54 @@ class UserTest extends TestCase
->first()->getEndDate() ->first()->getEndDate()
); );
} }
public function testIsAbsent()
{
$user = new User();
// Absent: today is within absence period
$absenceStart = new \DateTimeImmutable('-1 day');
$absenceEnd = new \DateTimeImmutable('+1 day');
$user->setAbsenceStart($absenceStart);
$user->setAbsenceEnd($absenceEnd);
self::assertTrue($user->isAbsent(), 'Should be absent when now is between start and end');
// Absent: end is null
$user->setAbsenceStart(new \DateTimeImmutable('-2 days'));
$user->setAbsenceEnd(null);
self::assertTrue($user->isAbsent(), 'Should be absent when started and no end');
// Not absent: absenceStart is in the future
$user->setAbsenceStart(new \DateTimeImmutable('+2 days'));
$user->setAbsenceEnd(null);
self::assertFalse($user->isAbsent(), 'Should not be absent if start is in the future');
// Not absent: absenceEnd is in the past
$user->setAbsenceStart(new \DateTimeImmutable('-5 days'));
$user->setAbsenceEnd(new \DateTimeImmutable('-1 day'));
self::assertFalse($user->isAbsent(), 'Should not be absent if end is in the past');
// Not absent: both are null
$user->setAbsenceStart(null);
$user->setAbsenceEnd(null);
self::assertFalse($user->isAbsent(), 'Should not be absent if start is null');
}
public function testSetNotification(): void
{
$user = new User();
self::assertTrue($user->isNotificationSendImmediately('dummy'));
self::assertFalse($user->isNotificationDailyDigest('dummy'));
$user->setNotificationImmediately('dummy', false);
self::assertFalse($user->isNotificationSendImmediately('dummy'));
$user->setNotificationDailyDigest('dummy', true);
self::assertTrue($user->isNotificationDailyDigest('dummy'));
$user->setNotificationImmediately('dummy', true);
self::assertTrue($user->isNotificationSendImmediately('dummy'));
self::assertTrue($user->isNotificationDailyDigest('dummy'));
}
} }

View File

@@ -144,7 +144,7 @@ class NotificationMailerTest extends TestCase
$idProperty->setValue($user, 456); $idProperty->setValue($user, 456);
// Set notification flags for the user // Set notification flags for the user
$user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_IMMEDIATE_EMAIL]]); $user->setNotificationImmediately('test_notification_type', true);
$messageBus = $this->createMock(MessageBusInterface::class); $messageBus = $this->createMock(MessageBusInterface::class);
$messageBus->expects($this->once()) $messageBus->expects($this->once())

View File

@@ -0,0 +1,88 @@
<?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\MainBundle\Tests\Repository;
use Chill\MainBundle\Entity\AddressReference;
use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Repository\AddressReferenceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class AddressReferenceRepositoryTest extends KernelTestCase
{
private static AddressReferenceRepository $repository;
public static function setUpBeforeClass(): void
{
static::bootKernel();
static::$repository = static::getContainer()->get(AddressReferenceRepository::class);
}
/**
* @dataProvider generateSearchString
*/
public function testFindBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void
{
$actual = static::$repository->findBySearchString($search, $postalCode);
self::assertIsIterable($actual, $text);
if (null !== $expected) {
self::assertEquals($expected, iterator_to_array($actual));
}
}
/**
* @dataProvider generateSearchString
*/
public function testCountBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void
{
$actual = static::$repository->countBySearchString($search, $postalCode);
self::assertIsInt($actual, $text);
}
/**
* @dataProvider generateSearchString
*/
public function testFindAggreggateBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void
{
$actual = static::$repository->findAggregatedBySearchString($search, $postalCode);
self::assertIsIterable($actual, $text);
if (null !== $expected) {
self::assertEquals($expected, iterator_to_array($actual));
}
}
public static function generateSearchString(): iterable
{
self::bootKernel();
$em = static::getContainer()->get(EntityManagerInterface::class);
/** @var AddressReference $ar */
$ar = $em->createQuery('SELECT ar FROM '.AddressReference::class.' ar')
->setMaxResults(1)
->getSingleResult();
yield ['', null, 'search with empty string', []];
yield [' ', null, 'search with spaces only', []];
yield ['rue des moulins', null, 'search contains an empty string'];
yield ['rue des moulins', $ar->getPostcode()->getId(), 'search with postal code as an id'];
yield ['rue des moulins', $ar->getPostcode(), 'search with postal code instance'];
}
}

View File

@@ -0,0 +1,61 @@
<?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\MainBundle\Tests\Repository;
use Chill\MainBundle\Repository\PostalCodeForAddressReferenceRepository;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
final class PostalCodeForAddressReferenceRepositoryTest extends KernelTestCase
{
private Connection $connection;
protected function setUp(): void
{
self::bootKernel();
$this->connection = self::getContainer()->get(Connection::class);
}
/**
* @return iterable<string[]>
*/
public static function provideSearches(): iterable
{
yield [''];
yield [' '];
yield ['hugo'];
yield [' hugo'];
yield ['hugo '];
yield ['rue victor hugo'];
yield ['rue victor hugo'];
}
#[DataProvider('provideSearches')]
public function testFindPostalCodeDoesNotErrorAndIsIterable(string $search): void
{
$repository = new PostalCodeForAddressReferenceRepository($this->connection);
$result = $repository->findPostalCode($search);
self::assertIsIterable($result);
// Ensure it can be converted to an array (and iterate without error)
$rows = \is_array($result) ? $result : iterator_to_array($result, false);
self::assertIsArray($rows);
}
}

View File

@@ -0,0 +1,173 @@
<?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\MainBundle\Tests\Security\Authorization;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Security\Authorization\EntityWorkflowAttachmentVoter;
use Chill\MainBundle\Workflow\EntityWorkflowMarkingStore;
use Chill\MainBundle\Workflow\WorkflowTransitionContextDTO;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Workflow\DefinitionBuilder;
use Symfony\Component\Workflow\Metadata\InMemoryMetadataStore;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\Workflow;
use Symfony\Component\Workflow\WorkflowInterface;
/**
* @internal
*
* @coversNothing
*/
class EntityWorkflowAttachmentVoterTest extends TestCase
{
use ProphecyTrait;
/**
* @dataProvider dataVoteOnAttribute
*/
public function testVoteOnAttribute(EntityWorkflow $entityWorkflow, int $expected): void
{
$voter = new EntityWorkflowAttachmentVoter($this->buildRegistry());
$actual = $voter->vote(
new UsernamePasswordToken(new User(), 'default'),
$entityWorkflow,
['CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT'],
);
$this->assertEquals($expected, $actual);
}
public static function dataVoteOnAttribute(): iterable
{
$entity = new EntityWorkflow();
$entity->setWorkflowName('dummy');
$workflow = static::buildRegistry()->get($entity, 'dummy');
$dto = new WorkflowTransitionContextDTO($entity);
$dto->futureDestUsers[] = new User();
$workflow->apply(
$entity,
'to_final_positive',
['context' => $dto,
'byUser' => new User(),
'transition' => 'to_final_positive',
'transitionAt' => new \DateTimeImmutable()],
);
// we need to mark manually as final, as the listener is not registered
$entity->getCurrentStep()->setIsFinal(true);
yield 'on final positive' => [
$entity,
VoterInterface::ACCESS_DENIED,
];
$entity = new EntityWorkflow();
$entity->setWorkflowName('dummy');
$workflow = static::buildRegistry()->get($entity, 'dummy');
$dto = new WorkflowTransitionContextDTO($entity);
$dto->futureDestUsers[] = new User();
$workflow->apply(
$entity,
'to_final_negative',
['context' => $dto,
'byUser' => new User(),
'transition' => 'to_final_negative',
'transitionAt' => new \DateTimeImmutable()],
);
// we need to mark manually as final, as the listener is not registered
$entity->getCurrentStep()->setIsFinal(true);
yield 'on final negative' => [
$entity,
VoterInterface::ACCESS_DENIED,
];
$entity = new EntityWorkflow();
$entity->setWorkflowName('dummy');
$workflow = static::buildRegistry()->get($entity, 'dummy');
$dto = new WorkflowTransitionContextDTO($entity);
$dto->futureDestUsers[] = new User();
$workflow->apply(
$entity,
'to_sent_external',
['context' => $dto,
'byUser' => new User(),
'transition' => 'to_sent_external',
'transitionAt' => new \DateTimeImmutable()],
);
yield 'on sent_external' => [
$entity,
VoterInterface::ACCESS_DENIED,
];
$entity = new EntityWorkflow();
$entity->setWorkflowName('dummy');
yield 'on initial' => [
$entity,
VoterInterface::ACCESS_GRANTED,
];
}
private static function buildRegistry(): Registry
{
$builder = new DefinitionBuilder();
$builder
->setInitialPlaces(['initial'])
->addPlaces(['initial', 'sent_external', 'final_positive', 'final_negative'])
->addTransitions([
new Transition('to_final_positive', ['initial'], 'final_positive'),
new Transition('to_sent_external', ['initial'], 'sent_external'),
new Transition('to_final_negative', ['initial'], 'final_negative'),
])
->setMetadataStore(
new InMemoryMetadataStore(
placesMetadata: [
'sent_external' => [
'isSentExternal' => true,
],
'final_positive' => [
'isFinal' => true,
'isFinalPositive' => true,
],
'final_negative' => [
'isFinal' => true,
'isFinalPositive' => false,
],
]
)
);
$workflow = new Workflow($builder->build(), new EntityWorkflowMarkingStore(), name: 'dummy');
$registry = new Registry();
$registry->addWorkflow($workflow, new class () implements WorkflowSupportStrategyInterface {
public function supports(WorkflowInterface $workflow, object $subject): bool
{
return true;
}
});
return $registry;
}
}

View File

@@ -101,6 +101,8 @@ final class UserNormalizerTest extends TestCase
'text_without_absent' => 'SomeUser', 'text_without_absent' => 'SomeUser',
'isAbsent' => false, 'isAbsent' => false,
'main_center' => ['context' => Center::class], 'main_center' => ['context' => Center::class],
'absenceStart' => ['context' => \DateTimeImmutable::class],
'absenceEnd' => ['context' => \DateTimeImmutable::class],
]]; ]];
yield [$userNoPhone, 'docgen', ['docgen:expects' => User::class], yield [$userNoPhone, 'docgen', ['docgen:expects' => User::class],
@@ -120,6 +122,8 @@ final class UserNormalizerTest extends TestCase
'text_without_absent' => 'AnotherUser', 'text_without_absent' => 'AnotherUser',
'isAbsent' => false, 'isAbsent' => false,
'main_center' => ['context' => Center::class], 'main_center' => ['context' => Center::class],
'absenceStart' => ['context' => \DateTimeImmutable::class],
'absenceEnd' => ['context' => \DateTimeImmutable::class],
]]; ]];
yield [null, 'docgen', ['docgen:expects' => User::class], [ yield [null, 'docgen', ['docgen:expects' => User::class], [
@@ -138,6 +142,8 @@ final class UserNormalizerTest extends TestCase
'text_without_absent' => '', 'text_without_absent' => '',
'isAbsent' => false, 'isAbsent' => false,
'main_center' => ['context' => Center::class], 'main_center' => ['context' => Center::class],
'absenceStart' => null,
'absenceEnd' => null,
]]; ]];
} }
} }

View File

@@ -11,6 +11,9 @@ declare(strict_types=1);
namespace Chill\MainBundle\Tests\Workflow\Helper; namespace Chill\MainBundle\Tests\Workflow\Helper;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowAttachmentRepository;
use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper; use Chill\MainBundle\Workflow\Helper\WorkflowRelatedEntityPermissionHelper;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
@@ -148,8 +151,11 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
$dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User()); $entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), yield [[], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because the user is not present as a dest user']; 'abstain because there is no workflow'];
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
'force deny because the user is not present as a dest user'];
$entityWorkflow = new EntityWorkflow(); $entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto = new WorkflowTransitionContextDTO($entityWorkflow);
@@ -171,6 +177,9 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(), yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user was a previous user']; 'force grant because the user was a previous user'];
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
'force denied because the user was not a previous user'];
$entityWorkflow = new EntityWorkflow(); $entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User(); $dto->futureDestUsers[] = $user = new User();
@@ -232,6 +241,13 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(), yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: there is a signature on a canceled workflow']; 'abstain: there is a signature on a canceled workflow'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('sent_external', $dto, 'to_sent_external', new \DateTimeImmutable(), $user);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(), 'force denied: the workflow is sent to an external user'];
} }
public function testNoWorkflow(): void public function testNoWorkflow(): void
@@ -253,7 +269,217 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class); $entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn($entityWorkflows); $entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->willReturn($entityWorkflows);
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable())); $repository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
$repository->findByStoredObject(Argument::type(StoredObject::class))->willReturn([]);
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $repository->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
}
/**
* @dataProvider provideDataAllowedByWorkflowReadOperationByAttachment
*
* @param list<EntityWorkflow> $entityWorkflows
*/
public function testAllowedByWorkflowReadByAttachment(
array $entityWorkflows,
User $user,
string $expected,
?\DateTimeImmutable $atDate,
string $message,
): void {
// all entities must have this workflow name, so we are ok to set it here
foreach ($entityWorkflows as $entityWorkflow) {
$entityWorkflow->setWorkflowName('dummy');
}
$helper = $this->buildHelperForAttachment($entityWorkflows, $user, $atDate);
self::assertEquals($expected, $helper->isAllowedByWorkflowForReadOperation(new StoredObject()), $message);
}
public static function provideDataAllowedByWorkflowReadOperationByAttachment(): iterable
{
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because the user is not present as a dest user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user is a current user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user was a previous user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'Abstain: there is a signature for person, but the attachment is not concerned'];
}
/**
* @dataProvider provideDataAllowedByWorkflowWriteOperationByAttachment
*
* @param list<EntityWorkflow> $entityWorkflows
*/
public function testAllowedByWorkflowWriteByAttachment(
array $entityWorkflows,
User $user,
string $expected,
?\DateTimeImmutable $atDate,
string $message,
): void {
// all entities must have this workflow name, so we are ok to set it here
foreach ($entityWorkflows as $entityWorkflow) {
$entityWorkflow->setWorkflowName('dummy');
}
$helper = $this->buildHelperForAttachment($entityWorkflows, $user, $atDate);
self::assertEquals($expected, $helper->isAllowedByWorkflowForWriteOperation(new StoredObject()), $message);
}
public static function provideDataAllowedByWorkflowWriteOperationByAttachment(): iterable
{
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because there is no workflow'];
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because the user is not present as a dest user (and attachment)'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user is a current user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, new \DateTimeImmutable(),
'force grant because the user was a previous user'];
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain because the user was not a previous user'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('final_positive', $dto, 'to_final_positive', new \DateTimeImmutable(), new User());
$entityWorkflow->getCurrentStep()->setIsFinal(true);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
'force denied: user was a previous user, but it is finalized positive'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable());
$entityWorkflow->getCurrentStep()->setIsFinal(true);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: user was a previous user, it is finalized, but finalized negative'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$signature = $entityWorkflow->getCurrentStep()->getSignatures()->first();
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: there is a signature, but not on the attachment'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
yield [[$entityWorkflow], new User(), WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: there is a signature, but the signature is not on the attachment'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futurePersonSignatures[] = new Person();
$entityWorkflow->setStep('test', $dto, 'to_test', new \DateTimeImmutable(), new User());
$signature = $entityWorkflow->getCurrentStep()->getSignatures()->first();
$signature->setState(EntityWorkflowSignatureStateEnum::SIGNED)->setStateDate(new \DateTimeImmutable());
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$entityWorkflow->setStep('final_negative', $dto, 'to_final_negative', new \DateTimeImmutable(), new User());
$entityWorkflow->getCurrentStep()->setIsFinal(true);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::ABSTAIN, new \DateTimeImmutable(),
'abstain: there is a signature on a canceled workflow'];
$entityWorkflow = new EntityWorkflow();
$dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestUsers[] = $user = new User();
$entityWorkflow->setStep('sent_external', $dto, 'to_sent_external', new \DateTimeImmutable(), $user);
yield [[$entityWorkflow], $user, WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, new \DateTimeImmutable(),
'force denied: the workflow is sent to an external user'];
}
/**
* @param list<EntityWorkflow> $entityWorkflows
*/
private function buildHelperForAttachment(array $entityWorkflows, User $user, ?\DateTimeImmutable $atDateTime): WorkflowRelatedEntityPermissionHelper
{
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$entityWorkflowManager = $this->prophesize(EntityWorkflowManager::class);
$entityWorkflowManager->findByRelatedEntity(Argument::type('object'))->shouldNotBeCalled();
$repository = $this->prophesize(EntityWorkflowAttachmentRepository::class);
$attachments = [];
foreach ($entityWorkflows as $entityWorkflow) {
$attachments[] = new EntityWorkflowAttachment('dummy', ['id' => 1], $entityWorkflow, new StoredObject());
}
$repository->findByStoredObject(Argument::type(StoredObject::class))->willReturn($attachments);
return new WorkflowRelatedEntityPermissionHelper($security->reveal(), $entityWorkflowManager->reveal(), $repository->reveal(), $this->buildRegistry(), new MockClock($atDateTime ?? new \DateTimeImmutable()));
} }
private static function buildRegistry(): Registry private static function buildRegistry(): Registry
@@ -261,10 +487,13 @@ class WorkflowRelatedEntityPermissionHelperTest extends TestCase
$builder = new DefinitionBuilder(); $builder = new DefinitionBuilder();
$builder $builder
->setInitialPlaces(['initial']) ->setInitialPlaces(['initial'])
->addPlaces(['initial', 'test', 'final_positive', 'final_negative']) ->addPlaces(['initial', 'test', 'sent_external', 'final_positive', 'final_negative'])
->setMetadataStore( ->setMetadataStore(
new InMemoryMetadataStore( new InMemoryMetadataStore(
placesMetadata: [ placesMetadata: [
'sent_external' => [
'isSentExternal' => true,
],
'final_positive' => [ 'final_positive' => [
'isFinal' => true, 'isFinal' => true,
'isFinalPositive' => true, 'isFinalPositive' => true,

View File

@@ -11,8 +11,11 @@ declare(strict_types=1);
namespace Chill\MainBundle\Tests\Workflow\Messenger; namespace Chill\MainBundle\Tests\Workflow\Messenger;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Service\StoredObjectToPdfConverter;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow; use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowAttachment;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository; use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface; use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
use Chill\MainBundle\Workflow\EntityWorkflowManager; use Chill\MainBundle\Workflow\EntityWorkflowManager;
@@ -23,6 +26,7 @@ use Chill\ThirdPartyBundle\Entity\ThirdParty;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Address;
@@ -39,24 +43,54 @@ class PostSendExternalMessageHandlerTest extends TestCase
public function testSendMessageHappyScenario(): void public function testSendMessageHappyScenario(): void
{ {
$entityWorkflow = $this->buildEntityWorkflow(); $entityWorkflow = $this->buildEntityWorkflow();
// Prepare attachments (2 attachments)
$attachmentStoredObject1 = new StoredObject();
$attachmentStoredObject2 = new StoredObject();
new EntityWorkflowAttachment('generic_doc', ['id' => 1], $entityWorkflow, $attachmentStoredObject1);
new EntityWorkflowAttachment('generic_doc', ['id' => 2], $entityWorkflow, $attachmentStoredObject2);
// Prepare transition DTO and sends
$dto = new WorkflowTransitionContextDTO($entityWorkflow); $dto = new WorkflowTransitionContextDTO($entityWorkflow);
$dto->futureDestineeEmails = ['external@example.com']; $dto->futureDestineeEmails = ['external@example.com'];
$dto->futureDestineeThirdParties = [(new ThirdParty())->setEmail('3party@example.com')]; $dto->futureDestineeThirdParties = [(new ThirdParty())->setEmail('3party@example.com')];
$entityWorkflow->setStep('send_external', $dto, 'to_send_external', new \DateTimeImmutable(), new User()); $entityWorkflow->setStep('send_external', $dto, 'to_send_external', new \DateTimeImmutable(), new User());
// Repository returns our workflow
$repository = $this->prophesize(EntityWorkflowRepository::class); $repository = $this->prophesize(EntityWorkflowRepository::class);
$repository->find(1)->willReturn($entityWorkflow); $repository->find(1)->willReturn($entityWorkflow);
// Mailer must send to both recipients
$mailer = $this->prophesize(MailerInterface::class); $mailer = $this->prophesize(MailerInterface::class);
$mailer->send(Argument::that($this->buildCheckAddressCallback('3party@example.com')))->shouldBeCalledOnce(); $mailer->send(Argument::that($this->buildCheckAddressCallback('3party@example.com')))->shouldBeCalledOnce();
$mailer->send(Argument::that($this->buildCheckAddressCallback('external@example.com')))->shouldBeCalledOnce(); $mailer->send(Argument::that($this->buildCheckAddressCallback('external@example.com')))->shouldBeCalledOnce();
// Workflow manager and handler
$workflowHandler = $this->prophesize(EntityWorkflowHandlerInterface::class); $workflowHandler = $this->prophesize(EntityWorkflowHandlerInterface::class);
$workflowHandler->getEntityTitle($entityWorkflow, Argument::any())->willReturn('title'); $workflowHandler->getEntityTitle($entityWorkflow, Argument::any())->willReturn('title');
$workflowManager = $this->prophesize(EntityWorkflowManager::class); $workflowManager = $this->prophesize(EntityWorkflowManager::class);
$workflowManager->getHandler($entityWorkflow)->willReturn($workflowHandler->reveal()); $workflowManager->getHandler($entityWorkflow)->willReturn($workflowHandler->reveal());
$handler = new PostSendExternalMessageHandler($repository->reveal(), $mailer->reveal(), $workflowManager->reveal()); // Associated stored object for the workflow
$associatedStoredObject = new StoredObject();
$workflowManager->getAssociatedStoredObject($entityWorkflow)->willReturn($associatedStoredObject);
// Converter should be called for each attachment and the associated stored object
$converter = $this->prophesize(StoredObjectToPdfConverter::class);
$converter->addConvertedVersion($attachmentStoredObject1, 'fr')->shouldBeCalledOnce();
$converter->addConvertedVersion($attachmentStoredObject2, 'fr')->shouldBeCalledOnce();
$converter->addConvertedVersion($associatedStoredObject, 'fr')->shouldBeCalledOnce();
// Logger (not used in happy path, but required by handler)
$logger = $this->prophesize(LoggerInterface::class);
$handler = new PostSendExternalMessageHandler(
$repository->reveal(),
$mailer->reveal(),
$workflowManager->reveal(),
$converter->reveal(),
$logger->reveal(),
);
$handler(new PostSendExternalMessage(1, 'fr')); $handler(new PostSendExternalMessage(1, 'fr'));

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