Compare commits

..

133 Commits

Author SHA1 Message Date
nobohan
db65134743 add person: increase z-index of toast and wait for validation before closing modal 2022-01-17 12:17:22 +01:00
7af4c3434e Merge remote-tracking branch 'origin/master' into issue357_front_end_validation 2022-01-16 22:51:45 +01:00
46c6d0e293 minor fix: } missing in template causing an error 2022-01-14 15:25:17 +01:00
bb0a04b19a Using translations instead of hardcoded 2022-01-14 14:37:50 +01:00
8d3ba314e0 adjustment badges into rounded-pill + red color for urgent 2022-01-14 14:18:37 +01:00
9e48a1db0a badges added in list view for parcours that are urgent and/or confidential 2022-01-14 13:58:56 +01:00
6c71cb0e80 Merge branch '232_resources_comment' into 'master'
232 resources comment

See merge request Chill-Projet/chill-bundles!276
2022-01-13 11:05:15 +00:00
11d4450244 fix condition to show comment in on-the-fly 2022-01-13 11:27:36 +01:00
5a79df0e0a fix test 2022-01-12 22:34:32 +01:00
c0c2131be0 fix notification test 2022-01-12 22:22:18 +01:00
e6ea2674c2 comment console.log, remove v-bind before ':' 2022-01-12 22:13:14 +01:00
39b5ce0320 fix access to database in ci 2022-01-12 22:06:47 +01:00
e0ee817421 batch rename twig attribut 2022-01-12 21:36:12 +01:00
8159144f26 fix accesss to database on ci 2022-01-12 20:20:20 +00:00
630115fd3a update changelog 2022-01-12 20:43:17 +01:00
87cf8d737e remove dump 2022-01-12 20:38:09 +01:00
ecda740d81 on-the-fly: add a 'parent' option to pass parent context. So we could now display resource.comment below the renderbox. 2022-01-12 20:26:54 +01:00
c9e3960238 clean commented unused code 2022-01-12 17:55:00 +01:00
782e098e31 Course resume page: hide a masonry bloc if no content (fix) 2022-01-12 17:37:49 +01:00
34ff91979b emit response and commit change in store 2022-01-12 17:06:21 +01:00
nobohan
a09c8ee8af upd CHANGELOG 2022-01-12 15:47:11 +01:00
nobohan
a312a9463d address: display error message if some fields are empty (street & streetnumber) 2022-01-12 15:44:37 +01:00
nobohan
0035128138 address: display error message if some fields are empty 2022-01-12 15:44:37 +01:00
nobohan
49cb154672 address: add field validation (WIP) 2022-01-12 15:44:37 +01:00
nobohan
1a7ec9e396 Activity: fix vuejs warning 2022-01-12 15:44:37 +01:00
nobohan
fa0b9271c2 location: treat 422 error when POSTing new location 2022-01-12 15:44:37 +01:00
nobohan
c7b9a1a3fe location: fix error when creating a new location: a new location could not be added to the availableLocations due to refactoring 2022-01-12 15:44:37 +01:00
nobohan
f1c61a2387 person: treat 422 error in AddPerson for thirdparty 2022-01-12 15:44:37 +01:00
nobohan
8f6a70b240 person: add validation for required fields in on-the-fly person 2022-01-12 15:44:37 +01:00
nobohan
40e4bf953f vuejs: better violations message in 422 error handling 2022-01-12 15:44:37 +01:00
nobohan
378f3a16fc person: on-the-fly person: first implementation of makeFetch for posting person 2022-01-12 15:44:37 +01:00
abcc8557ce resources: add a WriteComment new subcomponent 2022-01-12 15:25:06 +01:00
15342f85d3 only show current participations in parcours resumé 2022-01-12 13:39:53 +01:00
b679d833da display location type between parentheses for actions 2022-01-12 13:37:04 +01:00
f8ca349d9e use renderbox to display referrer in action list item 2022-01-12 12:10:41 +01:00
a8978a52b9 blur effect added on requestor if marked as anonymous 2022-01-12 12:05:45 +01:00
b1bffd875c Undo 'add thirdparty comment in acccourse resources' (commit 62b8b3e6)
-> it was displaying wrong comment !
2022-01-12 11:19:57 +01:00
b23161fa1d Merge branch 'master' into 232_resources_comment 2022-01-12 10:11:39 +01:00
e540baaa4f fix condition when confirming delete action 2022-01-12 10:09:42 +01:00
9fe744f09f thirdparty docgen normalizer: fix return type for telephone 2022-01-12 09:39:15 +01:00
6b1310148f notification: fix test 2022-01-11 11:24:11 +01:00
1650f30a59 upgrade app 2022-01-11 11:22:46 +01:00
f6386a13ac fix cs 2022-01-11 11:05:42 +01:00
e437705714 composer deps symfony/mime, change version 2022-01-11 10:29:00 +01:00
f79225fc2b Merge branch 'master' into notification/completion 2022-01-11 10:18:02 +01:00
7564c2fde1 AccompanyingPeriodResource: add ACL 2022-01-10 23:12:52 +01:00
2c4d06371c AccompanyingPeriodResource: fix deserialization + code style 2022-01-10 23:08:23 +01:00
8e012982f5 Merge remote-tracking branch 'origin/master' into 232_resources_comment 2022-01-10 21:56:36 +01:00
ba893e8429 Merge branch '327_pinned_comment' into 'master'
327 pinned comment

See merge request Chill-Projet/chill-bundles!279
2022-01-10 20:55:37 +00:00
b153fc19f6 327 pinned comment 2022-01-10 20:55:37 +00:00
098c2fbaa0 Merge remote-tracking branch 'origin/master' into 232_resources_comment 2022-01-10 21:50:00 +01:00
479e4ffe64 add notification box in context person, page show activity 2022-01-10 17:08:56 +01:00
42a14fbd6b AccompanyingPeriod list: manage context entity variable in record_actions buttons (TO BE CHECKED)
Need to pass context entity in record_actions buttons to use variables like 'person' !
2022-01-10 16:55:01 +01:00
46ca74fc70 Merge branch 'master' into notification/completion 2022-01-10 16:11:06 +01:00
ae931b5869 update changelog 2022-01-10 16:04:04 +01:00
fded08aaaa include Activity in notification (ShowInNotification) 2022-01-10 16:01:22 +01:00
d60938352b notification: add selector on title, move styles 2022-01-10 15:59:36 +01:00
af6f3043ae activity list page: put item bloc in a separate include 2022-01-10 15:58:11 +01:00
8afd1ee275 Activity show page: move notify button and notification-box in post menu area 2022-01-10 15:14:50 +01:00
5a739380cd force ShowInNotification background (for odd gray container) 2022-01-10 14:36:58 +01:00
a1ec2b637d ShownInNotification: put border on alert block 2022-01-10 14:36:27 +01:00
584b77bbf4 accourse resume page: fix masonry block border 2022-01-10 14:36:27 +01:00
e84182cdac translations + alert message position 2022-01-10 13:08:09 +01:00
01d51da7e1 Merge branch 'issue344_vue_multiselect_i18n' into 'master'
Issue344 vue multiselect i18n

See merge request Chill-Projet/chill-bundles!278
2022-01-10 12:04:57 +00:00
juminet
699dcc85de Issue344 vue multiselect i18n 2022-01-10 12:04:57 +00:00
e677c0fe2f Merge branch 'issue309_address_lines' into 'master'
address: render Address as lines

See merge request Chill-Projet/chill-bundles!277
2022-01-10 11:49:38 +00:00
juminet
edeaaf0218 address: render Address as lines 2022-01-10 11:49:38 +00:00
afae3b58c2 notification form integration (create/edit) 2022-01-10 12:44:08 +01:00
95027e93c6 fix translations for AddPersons in PickEntity 2022-01-10 12:41:47 +01:00
a3ea28d307 Merge branch 'issue342_location_in_acc_period' into 'master'
accompanying period: add location to accompanying period + add delete button

See merge request Chill-Projet/chill-bundles!272
2022-01-10 11:03:14 +00:00
juminet
921dd639bf accompanying period: add location to accompanying period + add delete button 2022-01-10 11:03:14 +00:00
a40077e91b include AccompanyingPeriod in notification (shownInNotification) 2022-01-10 10:52:09 +01:00
8de5c8900a Merge branch 'docgen/action-add-missing-goals' into 'master'
Docgen/action add missing goals

See merge request Chill-Projet/chill-bundles!280
2022-01-10 09:35:26 +00:00
efc3e3915b Docgen/action add missing goals 2022-01-10 09:35:26 +00:00
98ab3b66ba Accompanying periods list: move item in separate template (to share) 2022-01-10 10:21:31 +01:00
b1cd85971a notification show page: use macro to display thread comments 2022-01-07 15:50:47 +01:00
0a3b50568d Merge branch '327_pinned_comment' into notification/completion 2022-01-07 15:25:54 +01:00
69b5c98d2e inject record_actions buttons inside macro 2022-01-07 15:24:06 +01:00
5c4bef26e8 hotfix typo in NotificationComment 2022-01-07 14:56:17 +01:00
bdf4b71080 Merge branch '327_pinned_comment' into notification/completion 2022-01-07 13:02:59 +01:00
c60d46c68b put macro show_comment in a separate template 2022-01-07 12:51:03 +01:00
be4b8a94f0 AccompanyingCourse comments: move and rename files 2022-01-07 12:37:19 +01:00
a54cc70fd6 temporary disable previous commit 2022-01-07 12:34:14 +01:00
18959f60e2 reuse notification item in show template 2022-01-06 19:42:45 +01:00
7d4ca8d149 remove dropped tests, separate list item in external include 2022-01-06 19:04:24 +01:00
442e2a1ede notification list, item header integration (test 3 ways) 2022-01-06 19:04:24 +01:00
c32b767d9b Notifications list template 2022-01-06 19:04:16 +01:00
140f53b81f setting NotificationReadToggle options for several cases. improve options 2022-01-06 13:34:39 +01:00
b9e5734039 bouton bleu 2022-01-05 21:16:47 +01:00
f5f5d66f3d design notification box in post menu area 2022-01-05 20:58:51 +01:00
1e0c62b09d Merge branch 'master' into notification/completion 2022-01-05 11:15:49 +01:00
40a457ba69 notificaiton: add mailer component 2022-01-04 22:41:17 +01:00
66aac8908c notification: fix translations 2022-01-04 22:41:04 +01:00
c17fd968be notification: do not show system notification in UI 2022-01-04 22:40:48 +01:00
6f561f57a6 Notification: send an email when a notification is created 2022-01-04 22:35:23 +01:00
4f1540c541 add a patch resource api endpoint (wip) 2022-01-04 19:37:27 +01:00
4ef024516c notificaiton: improve NotificationHandlerInterface + mark notification
as unread if a comment is appended
2022-01-04 19:02:03 +01:00
ebc6d21ba6 notification: order notification by date desc in inbox and sent 2022-01-04 18:38:08 +01:00
d31661ae7a notification: fix NotificationHandlerInterface 2022-01-04 17:55:34 +01:00
f76f7a1641 notification: handle activities 2022-01-04 17:40:47 +01:00
160d382e07 notification: add notification sent to widget which list notifications
ont an entity
2022-01-04 17:14:09 +01:00
a8fe049ecd notification: add more variable in twig template 2022-01-04 17:00:31 +01:00
3a207b2c5d Notification: add a counter for notifications 2022-01-04 16:44:01 +01:00
af2eca0d03 Change accompanyingCourse Resources comment by a string 2022-01-04 15:56:00 +01:00
68ea0a1086 wip delete comment 2022-01-04 12:07:37 +01:00
5bb5468198 notificaiton: add title to list and forms 2022-01-04 11:32:15 +01:00
5fead5b467 fix translation, disable throw exception 2022-01-04 11:18:56 +01:00
0edd5667e0 fix cs 2022-01-04 11:09:59 +01:00
45dd21e02a add component and type to add user dynamically 2022-01-03 15:42:24 +01:00
5bf1b9d8bd send system notification on period confirmation 2021-12-30 02:11:21 +01:00
7bc4ad9779 twig extension for listing notification on a given entity 2021-12-30 01:02:16 +01:00
1576507f7e notification: fix widget toggle read status 2021-12-29 23:09:26 +01:00
9ddfd194be notification: small vue component to toggle read status of a notification 2021-12-29 18:51:25 +01:00
8fe94bd117 notification: update comment and api endpoint for marking as read/unread 2021-12-29 17:36:14 +01:00
9d638fe897 allow to edit notification 2021-12-29 15:24:52 +01:00
478c3b3247 notification: add update information 2021-12-29 15:12:27 +01:00
58c7560a1c Merge remote-tracking branch 'origin/notification/completion' into notification/completion 2021-12-29 14:46:55 +01:00
433307020a add a new button to send notifications 2021-12-29 10:52:15 +01:00
5a5ff5f54a notification: fix type for id 2021-12-28 23:32:11 +01:00
a0b112e8db notificaiton: handle remove addressee and reset at the end of registering unread 2021-12-28 23:31:33 +01:00
cb88a37885 notification: add comment in show and append comment 2021-12-28 23:25:12 +01:00
9647785d8b notificaton: add comments 2021-12-28 23:03:58 +01:00
1ad6a958e2 notification: add show action 2021-12-28 22:35:15 +01:00
f453dbb543 notification: more check in test 2021-12-28 20:04:30 +01:00
b323ddae05 fix loading of unread addressees in notification 2021-12-28 19:40:38 +01:00
d5d64cdf65 upgrade test app 2021-12-26 01:12:42 +01:00
700bb02374 notification: add test for unread consistency 2021-12-26 01:12:32 +01:00
bd3919efcb notification: store users which are unread instead of read, and in
dedicated table

The query "which are the unread notification" is much more frequent than
the read one. We then store the unread items in a dedicated table.
2021-12-26 01:00:50 +01:00
f6f0786d38 notificaiton: load interface automatically 2021-12-25 22:53:35 +01:00
5cebcfddb7 fix cs ? 2021-12-25 22:51:58 +01:00
11824adda4 notification: create 2021-12-25 22:41:25 +01:00
d62893827b create a notification controller 2021-12-25 22:16:34 +01:00
e972beee11 fix controller loading and add UserMenu entry 2021-12-25 20:47:54 +01:00
204 changed files with 5973 additions and 1490 deletions

View File

@@ -3,4 +3,3 @@
# Run tests from root to adapt your own environment
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres?serverVersion=12&charset=utf8

View File

@@ -20,6 +20,8 @@ variables:
# Configure postgres environment variables (https://hub.docker.com/r/_/postgres/)
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
# configure database access
DATABASE_URL: postgresql://postgres:postgres@db:5432/postgres?serverVersion=12&charset=utf8
# fetch the chill-app using git submodules
GIT_SUBMODULE_STRATEGY: recursive
REDIS_HOST: redis

View File

@@ -11,6 +11,19 @@ and this project adheres to
## Unreleased
<!-- write down unreleased development here -->
* vuejs: add validation on required fields for AddPerson, Address and Location components
* vuejs: treat 422 validation errors in locations and AddPerson components
## Test releases
### test release 2022-01-12
* fix thirdparty normalizer on telephone field: https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/322
### test release 2022-01-11
* vuejs: translate in French all multiselect widgets
* [address] define address lines according postal standards for France and Belgium (default) and change AddressRender, chill_entity_render_box and AddressRenderBox.vue
* [household] change translations (champs-libres/departement-de-la-vendee/accent-suivi-developpement#109)
* [household] add address i18n in household component (champs-libres/departement-de-la-vendee/accent-suivi-developpement#158)
* [household] add on the fly i18n in household component
@@ -20,6 +33,9 @@ and this project adheres to
* [household] household member editor: remove markNoAddress button (champs-libres/departement-de-la-vendee/accent-suivi-developpement#109)
* [person]: ordering fields in add person (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/61)
* [person]: Add email and alt names in add person (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/61)
* [accompanyingCourse] Add a delete action and delete buttons to delete a accompanying course when step = DRAFT (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/64)
* [accompanyingCourse] Add a administrative location in the accompanying course, set the user current location as default, allow to select a location in a select field and do not allow to confirm the accompanying course if location is empty.
* [accompanyingCourse] Add the administrative location in the available variables for document generation
* AddAddress: optimize loading: wait for the user finish typing;
* UserPicker: fix bug with deprecated role
* docgen: add base context + tests
@@ -35,8 +51,10 @@ and this project adheres to
* address reference: add index for refid
* [accompanyingCourse_work] fix styles conflicts + fix bug with remove goal (remove goals one at a time)
* [accompanyingCourse] improve masonry on resume page, add origin
## Test releases
* [notification] new notification interface, can be associated to AccompanyingCourse/Period, Activities.
* List notifications, show, and comment in User section
* Notify button and contextual notification box on associated objects pages
* [accompanyingCourse] add a comment for each resource associated. A modal allow to save comment. Comment is displayed in on-the-fly show modal of the accompanyingCourse context (edit page + resume page).
### test release 2021-12-14

View File

@@ -33,7 +33,8 @@
"symfony/form": "^4.4",
"symfony/framework-bundle": "^4.4",
"symfony/intl": "^4.4",
"symfony/mime": "^4.4",
"symfony/mailer": "^5.4",
"symfony/mime": "^5.4",
"symfony/monolog-bundle": "^3.5",
"symfony/security-bundle": "^4.4",
"symfony/serializer": "^5.3",
@@ -47,13 +48,19 @@
"symfony/yaml": "^4.4",
"twig/extra-bundle": "^3.0",
"twig/intl-extra": "^3.0",
"twig/markdown-extra": "^3.3"
"twig/markdown-extra": "^3.3",
"twig/string-extra": "^3.3",
"twig/twig": "^3.0"
},
"conflict": {
"symfony/symfony": "*"
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.3",
"drupol/php-conventions": "^5",
"fakerphp/faker": "^1.13",
"nelmio/alice": "^3.8",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^1.0",
"phpunit/phpunit": ">= 7.5",
"symfony/debug-bundle": "^5.1",
@@ -64,8 +71,17 @@
"symfony/var-dumper": "^4.4",
"symfony/web-profiler-bundle": "^4.4"
},
"conflict": {
"symfony/symfony": "*"
"config": {
"bin-dir": "bin",
"optimize-autoloader": true,
"sort-packages": true,
"vendor-dir": "tests/app/vendor",
"allow-plugins": {
"composer/package-versions-deprecated": true,
"phpstan/extension-installer": true,
"ergebnis/composer-normalize": true,
"phpro/grumphp": true
}
},
"autoload": {
"psr-4": {

View File

@@ -315,11 +315,6 @@ parameters:
count: 1
path: src/Bundle/ChillMainBundle/Security/PasswordRecover/TokenManager.php
-
message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#"
count: 3
path: src/Bundle/ChillMainBundle/Templating/Entity/AddressRender.php
-
message: "#^Construct empty\\(\\) is not allowed\\. Use more strict comparison\\.$#"
count: 1

View File

@@ -6,7 +6,6 @@
backupGlobals="false"
colors="true"
bootstrap="tests/app/tests/bootstrap.php"
stopOnFailure="true"
>
<php>
<ini name="error_reporting" value="-1" />

View File

@@ -0,0 +1,45 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\ActivityBundle\Notification;
use Chill\ActivityBundle\Entity\Activity;
use Chill\ActivityBundle\Repository\ActivityRepository;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Notification\NotificationHandlerInterface;
final class ActivityNotificationHandler implements NotificationHandlerInterface
{
private ActivityRepository $activityRepository;
public function __construct(ActivityRepository $activityRepository)
{
$this->activityRepository = $activityRepository;
}
public function getTemplate(Notification $notification, array $options = []): string
{
return '@ChillActivity/Activity/showInNotification.html.twig';
}
public function getTemplateData(Notification $notification, array $options = []): array
{
return [
'notification' => $notification,
'activity' => $this->activityRepository->find($notification->getRelatedEntityId()),
];
}
public function supports(Notification $notification, array $options = []): bool
{
return $notification->getRelatedEntityClass() === Activity::class;
}
}

View File

@@ -1,33 +0,0 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\ActivityBundle\Notification;
use Chill\ActivityBundle\Entity\Activity;
use Chill\MainBundle\Entity\Notification;
final class ActivityNotificationRenderer
{
public function getTemplate()
{
return '@ChillActivity/Activity/showInNotification.html.twig';
}
public function getTemplateData(Notification $notification)
{
return ['notification' => $notification];
}
public function supports(Notification $notification, array $options = []): bool
{
return $notification->getRelatedEntityClass() === Activity::class;
}
}

View File

@@ -11,7 +11,7 @@ import Location from './components/Location.vue';
export default {
name: "App",
props: ['hasSocialIssues', 'hasLocation', 'hasPerson'],
props: ['hasSocialIssues', 'hasLocation', 'hasPerson'],
components: {
ConcernedGroups,
SocialIssuesAcc,

View File

@@ -12,7 +12,7 @@
</div>
<div v-if="getContext === 'accompanyingCourse' && suggestedEntities.length > 0">
<ul class="list-suggest add-items inline">
<li v-for="p in suggestedEntities" @click="addSuggestedEntity(p)">
<li v-for="(p, i) in suggestedEntities" @click="addSuggestedEntity(p)" :key="`suggestedEntities-${i}`">
<span>{{ p.text }}</span>
</li>
</ul>

View File

@@ -15,14 +15,16 @@
:searchable="true"
:placeholder="$t('activity.choose_location')"
:custom-label="customLabel"
:select-label="$t('multiselect.select_label')"
:deselect-label="$t('multiselect.deselect_label')"
:selected-label="$t('multiselect.selected_label')"
:options="availableLocations"
group-values="locations"
group-label="locationGroup"
v-model="location"
>
</VueMultiselect>
<new-location v-bind:locations="locations"></new-location>
<new-location v-bind:availableLocations="availableLocations"></new-location>
</div>
</div>
</teleport>
@@ -32,7 +34,6 @@
import { mapState, mapGetters } from "vuex";
import VueMultiselect from "vue-multiselect";
import NewLocation from "./Location/NewLocation.vue";
import { getLocations, getLocationTypeByDefaultFor, getUserCurrentLocation } from "../api.js";
export default {
name: "Location",

View File

@@ -18,15 +18,6 @@
</template>
<template v-slot:body>
<form>
<div class="form-floating mb-3">
<p v-if="errors.length">
<b>{{ $t('activity.errors') }}</b>
<ul>
<li v-for="error in errors" :key="error">{{ error }}</li>
</ul>
</p>
</div>
<div class="form-floating mb-3">
<select class="form-select form-select-lg" id="type" required v-model="selectType">
<option selected disabled value="">{{ $t('activity.choose_location_type') }}</option>
@@ -62,6 +53,12 @@
<input class="form-control form-control-lg" id="email" v-model="inputEmail" placeholder />
<label for="email">{{ $t('activity.location_fields.email') }}</label>
</div>
<div class="alert alert-warning" v-if="errors.length">
<ul>
<li v-for="(e, i) in errors" :key="i">{{ e }}</li>
</ul>
</div>
</form>
</template>
<template v-slot:footer>
@@ -81,7 +78,8 @@
import Modal from 'ChillMainAssets/vuejs/_components/Modal.vue';
import AddAddress from "ChillMainAssets/vuejs/Address/components/AddAddress.vue";
import { mapState } from "vuex";
import { getLocationTypes, postLocation } from "../../api";
import { getLocationTypes } from "../../api";
import { makeFetch } from 'ChillMainAssets/lib/api/apiMethods';
export default {
name: "NewLocation",
@@ -89,7 +87,7 @@ export default {
Modal,
AddAddress,
},
props: ['locations'],
props: ['availableLocations'],
data() {
return {
errors: [],
@@ -223,7 +221,6 @@ export default {
},
saveNewLocation() {
if (this.checkForm()) {
console.log('saveNewLocation', this.selected);
let body = {
type: 'location',
name: this.selected.name,
@@ -242,23 +239,28 @@ export default {
}
});
}
postLocation(body)
.then(
location => new Promise(resolve => {
this.locations.push(location);
this.$store.dispatch('updateLocation', location);
resolve();
this.modal.showModal = false;
})
).catch(
err => {
this.errors.push(err.message);
makeFetch('POST', '/api/1.0/main/location.json', body)
.then(response => {
this.$store.dispatch('addAvailableLocationGroup', {
locationGroup: 'Localisations nouvellement créées',
locations: [response]
});
this.$store.dispatch('updateLocation', response);
this.modal.showModal = false;
})
.catch((error) => {
if (error.name === 'ValidationException') {
for (let v of error.violations) {
this.errors.push(v);
}
} else {
this.errors.push('An error occurred');
}
);
})
};
},
submitNewAddress(payload) {
console.log('submitNewAddress', payload);
this.selected.addressId = payload.addressId;
this.addAddress.context.addressId = payload.addressId;
this.addAddress.context.edit = true;

View File

@@ -9,9 +9,9 @@
<check-social-issue
v-for="issue in socialIssuesList"
v-bind:key="issue.id"
v-bind:issue="issue"
v-bind:selection="socialIssuesSelected"
:key="issue.id"
:issue="issue"
:selection="socialIssuesSelected"
@updateSelected="updateIssuesSelected">
</check-social-issue>
@@ -21,18 +21,18 @@
label="text"
track-by="id"
open-direction="bottom"
v-bind:close-on-select="true"
v-bind:preserve-search="false"
v-bind:reset-after="true"
v-bind:hide-selected="true"
v-bind:taggable="false"
v-bind:multiple="false"
v-bind:searchable="true"
v-bind:allow-empty="true"
v-bind:show-labels="false"
v-bind:loading="issueIsLoading"
v-bind:placeholder="$t('activity.choose_other_social_issue')"
v-bind:options="socialIssuesOther"
:close-on-select="true"
:preserve-search="false"
:reset-after="true"
:hide-selected="true"
:taggable="false"
:multiple="false"
:searchable="true"
:allow-empty="true"
:show-labels="false"
:loading="issueIsLoading"
:placeholder="$t('activity.choose_other_social_issue')"
:options="socialIssuesOther"
@select="addIssueInList">
</VueMultiselect>
</div>
@@ -58,9 +58,9 @@
<check-social-action
v-if="socialIssuesSelected.length || socialActionsSelected.length"
v-for="action in socialActionsList"
v-bind:key="action.id"
v-bind:action="action"
v-bind:selection="socialActionsSelected"
:key="action.id"
:action="action"
:selection="socialActionsSelected"
@updateSelected="updateActionsSelected">
</check-social-action>
</template>

View File

@@ -1,4 +1,5 @@
import { personMessages } from 'ChillPersonAssets/vuejs/_js/i18n'
import { multiSelectMessages } from 'ChillMainAssets/vuejs/_js/i18n'
const activityMessages = {
fr: {
@@ -33,12 +34,11 @@ const activityMessages = {
},
create_address: 'Créer une adresse',
edit_address: "Modifier l'adresse"
}
}
}
Object.assign(activityMessages.fr, personMessages.fr);
Object.assign(activityMessages.fr, personMessages.fr, multiSelectMessages.fr);
export {
activityMessages

View File

@@ -12,7 +12,11 @@ const hasLocation = document.querySelector('#location') !== null;
const hasPerson = document.querySelector('#add-persons') !== null;
const app = createApp({
template: `<app :hasSocialIssues="hasSocialIssues", :hasLocation="hasLocation", :hasPerson="hasPerson"></app>`,
template: `<app
:hasSocialIssues="hasSocialIssues"
:hasLocation="hasLocation"
:hasPerson="hasPerson"
></app>`,
data() {
return {
hasSocialIssues,

View File

@@ -240,6 +240,9 @@ const store = createStore({
});
commit("updateActionsSelected", payload);
},
addAvailableLocationGroup({ commit }, payload) {
commit("addAvailableLocationGroup", payload);
},
addPersonsInvolved({ commit }, payload) {
//console.log('### action addPersonsInvolved', payload.result.type);
switch (payload.result.type) {

View File

@@ -0,0 +1,151 @@
{% set t = activity.type %}
<div class="item-bloc activity-item{% if itemBlocClass is defined %} {{ itemBlocClass }}{% endif %}">
<div class="item-row">
<div class="wrap-list">
<div class="wl-row">
<div class="wl-col title">
{% if activity.date %}
<p class="date-label">
{{ activity.date|format_date('short') }}
</p>
{% endif %}
</div>
<div class="wl-col list">
<h2 class="badge-title">
<span class="title_label"></span>
<span class="title_action">
{{ activity.type.name | localize_translatable_string }}
{% if activity.emergency %}
<span class="badge bg-danger rounded-pill fs-6 float-end">{{ 'Emergency'|trans|upper }}</span>
{% endif %}
</span>
</h2>
</div>
</div>
</div>
</div>
<div class="item-row column separator">
<div class="wrap-list">
{% if activity.location and t.locationVisible %}
<div class="wl-row">
<div class="wl-col title"><h3>{{ 'location'|trans }}</h3></div>
<div class="wl-col list">
<p class="wl-item">
{{ activity.location.name }}
<span>({{ activity.location.locationType.title|localize_translatable_string }})</span>
</p>
</div>
</div>
{% endif %}
{% if activity.sentReceived is not empty and t.sentReceivedVisible %}
<div class="wl-row">
<div class="wl-col title"><h3>{{ 'Sent received'|trans }}</h3></div>
<div class="wl-col list">
<p class="wl-item">
{{ activity.sentReceived|capitalize|trans }}
</p>
</div>
</div>
{% endif %}
{% if activity.user and t.userVisible %}
<div class="wl-row">
<div class="wl-col title"><h3>{{ 'Referrer'|trans }}</h3></div>
<div class="wl-col list">
<p class="wl-item">
{{ activity.user|chill_entity_render_string|capitalize }}
</p>
</div>
</div>
{% endif %}
</div>
{% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {
'context': context,
'render': 'wrap-list',
'entity': activity,
'badge_person': true
} %}
<div class="wrap-list">
{%- if activity.reasons is not empty and t.reasonsVisible -%}
<div class="wl-row">
<div class="wl-col title">
<h3>{{ 'Reasons'|trans }}</h3>
</div>
<div class="wl-col list">
{% for r in activity.reasons %}
<p class="wl-item reasons">
{{ r|chill_entity_render_box }}
</p>
{% endfor %}
</div>
</div>
{% endif %}
{%- if activity.socialIssues is not empty and t.socialIssuesVisible -%}
<div class="wl-row">
<div class="wl-col title">
<h3>{{ 'Social issues'|trans }}</h3>
</div>
<div class="wl-col list">
{% for r in activity.socialIssues %}
<p class="wl-item social-issues">
{{ r|chill_entity_render_box }}
</p>
{% endfor %}
</div>
</div>
{% endif %}
{%- if activity.socialActions is not empty and t.socialActionsVisible -%}
<div class="wl-row">
<div class="wl-col title">
<h3>{{ 'Social actions'|trans }}</h3>
</div>
<div class="wl-col list">
{% for r in activity.socialActions %}
<p class="wl-item social-actions">
{{ r|chill_entity_render_box }}
</p>
{% endfor %}
</div>
</div>
{% endif %}
{% if activity.comment.comment is not empty and is_granted('CHILL_ACTIVITY_SEE_DETAILS', activity) %}
<div class="wl-row">
<div class="wl-col title">
<h3>{{ 'Comment'|trans }}</h3>
</div>
<div class="wl-col list">
{{ activity.comment|chill_entity_render_box({
'disable_markdown': false,
'limit_lines': 3,
'metadata': false
}) }}
</div>
</div>
{% endif %}
{# Only if ACL SEE_DETAILS AND/OR only on template SHOW ??
durationTime
travelTime
comment
documents
attendee
#}
</div>
</div>
<div class="item-row separator">
<ul class="record_actions">
{{ recordAction }}
</ul>
</div>
</div>

View File

@@ -3,11 +3,12 @@
{{ path(pathname, parms) }}
{% endmacro %}
{% macro insert_onthefly(type, entity) %}
{% macro insert_onthefly(type, entity, parent = null) %}
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
action: 'show', displayBadge: true,
targetEntity: { name: type, id: entity.id },
buttonText: entity|chill_entity_render_string
buttonText: entity|chill_entity_render_string,
parent: parent
} %}
{% endmacro %}
@@ -59,7 +60,7 @@
}]) %}
{% endif %}
{% if (with_display == 'bloc') %}
{% if (render == 'bloc') %}
<div class="{{ context }} flex-bloc concerned-groups">
{% for bloc in blocks %}
@@ -90,7 +91,7 @@
</div>
{% endif %}
{% if (with_display == 'row') %}
{% if (render == 'row') %}
<div class="concerned-groups">
{% for bloc in blocks %}
<div class="group">
@@ -115,7 +116,7 @@
</div>
{% endif %}
{% if (with_display == 'wrap-list') %}
{% if (render == 'wrap-list') %}
<div class="concerned-groups wrap-list">
{% for bloc in blocks %}
<div class="wl-row">

View File

@@ -1,3 +1,61 @@
{% macro recordAction(activity, context = null, person_id = null, accompanying_course_id = null) %}
{% if no_action is not defined or no_action == false %}
<li>
<a class="btn btn-notify" href="{{ chill_path_add_return_path('chill_main_notification_create', {
'entityClass': 'Chill\\ActivityBundle\\Entity\\Activity',
'entityId': activity.id
}) }}">{{ 'notification.Notify'|trans }}</a>
</li>
{% endif %}
{% if context == 'person' and activity.accompanyingPeriod is not empty %}
{#
Disable person_id in following links, for redirect to accompanyingCourse context
#}
{% set person_id = null %}
{% set accompanying_course_id = activity.accompanyingPeriod.id %}
<li>
<a href="{{ chill_path_add_return_path('chill_activity_activity_list',{
'accompanying_period_id': accompanying_course_id
}) }}"
class="btn btn-primary"
title="{{ 'See activity in accompanying course context'|trans }}">
<i class="fa fa-random fa-fw"></i>
{{ 'Period number %number%'|trans({'%number%': accompanying_course_id}) }}
</a>
</li>
{% endif %}
<li>
<a href="{{ path('chill_activity_activity_show', {'id': activity.id,
'person_id': person_id,
'accompanying_period_id': accompanying_course_id
}) }}"
class="btn btn-show"
title="{{ 'Show'|trans }}"></a>
</li>
{% if no_action is not defined or no_action == false %}
{% if is_granted('CHILL_ACTIVITY_UPDATE', activity) %}
<li>
<a href="{{ path('chill_activity_activity_edit', {'id': activity.id,
'person_id': person_id,
'accompanying_period_id': accompanying_course_id
}) }}"
class="btn btn-update"
title="{{ 'Edit'|trans }}"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACTIVITY_DELETE', activity) %}
<li>
<a href="{{ path('chill_activity_activity_delete', {'id': activity.id,
'person_id': person_id,
'accompanying_period_id': accompanying_course_id
}) }}"
class="btn btn-delete"
title="{{ 'Delete'|trans }}"></a>
</li>
{% endif %}
{% endif %}
{% endmacro %}
<div class="context-{{ context }}">
{% if activities|length == 0 %}
@@ -8,203 +66,10 @@
{% else %}
<div class="flex-table activity-list">
{% for activity in activities %}
{% set t = activity.type %}
<div class="item-bloc">
<div class="item-row">
<div class="wrap-list">
<div class="wl-row">
<div class="wl-col title">
{% if activity.date %}
<p class="date-label">
{{ activity.date|format_date('short') }}
</p>
{% endif %}
</div>
<div class="wl-col list">
<h2 class="badge-title">
<span class="title_label"></span>
<span class="title_action">
{{ activity.type.name | localize_translatable_string }}
{% if activity.emergency %}
<span class="badge bg-danger rounded-pill fs-6 float-end">{{ 'Emergency'|trans|upper }}</span>
{% endif %}
</span>
</h2>
</div>
</div>
</div>
</div>
<div class="item-row column separator">
<div class="wrap-list">
{% if activity.location and t.locationVisible %}
<div class="wl-row">
<div class="wl-col title"><h3>{{ 'location'|trans }}</h3></div>
<div class="wl-col list">
<p class="wl-item">
<span>{{ activity.location.locationType.title|localize_translatable_string }}</span>
{{ activity.location.name }}
</p>
</div>
</div>
{% endif %}
{% if activity.sentReceived is not empty and t.sentReceivedVisible %}
<div class="wl-row">
<div class="wl-col title"><h3>{{ 'Sent received'|trans }}</h3></div>
<div class="wl-col list">
<p class="wl-item">
{{ activity.sentReceived|capitalize|trans }}
</p>
</div>
</div>
{% endif %}
{% if activity.user and t.userVisible %}
<div class="wl-row">
<div class="wl-col title"><h3>{{ 'Referrer'|trans }}</h3></div>
<div class="wl-col list">
<p class="wl-item">
{{ activity.user.usernameCanonical|chill_entity_render_string|capitalize }}
</p>
</div>
</div>
{% endif %}
</div>
{% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {
'context': context,
'with_display': 'wrap-list',
'entity': activity,
'badge_person': true
} %}
<div class="wrap-list">
{%- if activity.reasons is not empty and t.reasonsVisible -%}
<div class="wl-row">
<div class="wl-col title">
<h3>{{ 'Reasons'|trans }}</h3>
</div>
<div class="wl-col list">
{% for r in activity.reasons %}
<p class="wl-item reasons">
{{ r|chill_entity_render_box }}
</p>
{% endfor %}
</div>
</div>
{% endif %}
{%- if activity.socialIssues is not empty and t.socialIssuesVisible -%}
<div class="wl-row">
<div class="wl-col title">
<h3>{{ 'Social issues'|trans }}</h3>
</div>
<div class="wl-col list">
{% for r in activity.socialIssues %}
<p class="wl-item social-issues">
{{ r|chill_entity_render_box }}
</p>
{% endfor %}
</div>
</div>
{% endif %}
{%- if activity.socialActions is not empty and t.socialActionsVisible -%}
<div class="wl-row">
<div class="wl-col title">
<h3>{{ 'Social actions'|trans }}</h3>
</div>
<div class="wl-col list">
{% for r in activity.socialActions %}
<p class="wl-item social-actions">
{{ r|chill_entity_render_box }}
</p>
{% endfor %}
</div>
</div>
{% endif %}
{% if activity.comment.comment is not empty and is_granted('CHILL_ACTIVITY_SEE_DETAILS', activity) %}
<div class="wl-row">
<div class="wl-col title">
<h3>{{ 'Comment'|trans }}</h3>
</div>
<div class="wl-col list">
{{ activity.comment|chill_entity_render_box({
'disable_markdown': false,
'limit_lines': 3,
'metadata': false
}) }}
</div>
</div>
{% endif %}
{# Only if ACL SEE_DETAILS AND/OR only on template SHOW ??
durationTime
travelTime
comment
documents
attendee
#}
</div>
</div>
<div class="item-row separator">
<ul class="record_actions">
{% if context == 'person' and activity.accompanyingPeriod is not empty %}
{#
Disable person_id in following links, for redirect to accompanyingCourse context
#}
{% set person_id = null %}
{% set accompanying_course_id = activity.accompanyingPeriod.id %}
<li>
<a href="{{ chill_path_add_return_path('chill_activity_activity_list',{
'accompanying_period_id': accompanying_course_id
}) }}"
class="btn btn-primary"
title="{{ 'See activity in accompanying course context'|trans }}">
<i class="fa fa-random fa-fw"></i>
{{ 'Period number %number%'|trans({'%number%': accompanying_course_id}) }}
</a>
</li>
{% endif %}
<li>
<a href="{{ path('chill_activity_activity_show', {'id': activity.id,
'person_id': person_id,
'accompanying_period_id': accompanying_course_id
}) }}"
class="btn btn-show"
title="{{ 'Show'|trans }}"></a>
</li>
{% if no_action is not defined or no_action == false %}
{% if is_granted('CHILL_ACTIVITY_UPDATE', activity) %}
<li>
<a href="{{ path('chill_activity_activity_edit', {'id': activity.id,
'person_id': person_id,
'accompanying_period_id': accompanying_course_id
}) }}"
class="btn btn-update"
title="{{ 'Edit'|trans }}"></a>
</li>
{% endif %}
{% if is_granted('CHILL_ACTIVITY_DELETE', activity) %}
<li>
<a href="{{ path('chill_activity_activity_delete', {'id': activity.id,
'person_id': person_id,
'accompanying_period_id': accompanying_course_id
}) }}"
class="btn btn-delete"
title="{{ 'Delete'|trans }}"></a>
</li>
{% endif %}
{% endif %}
</ul>
</div>
</div>
{% include 'ChillActivityBundle:Activity:_list_item.html.twig' with {
'context': context,
'recordAction': _self.recordAction(activity, context, person_id, accompanying_course_id)
} %}
{% endfor %}
</div>
{% endif %}

View File

@@ -4,6 +4,17 @@
{% block title %}{{ 'Activity list' |trans }}{% endblock title %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_notification_toggle_read_status') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_notification_toggle_read_status') }}
{% endblock %}
{% block content %}
{% set person_id = null %}

View File

@@ -20,6 +20,16 @@
{% block title %}{{ 'Activity list' |trans }}{% endblock title %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_notification_toggle_read_status') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_notification_toggle_read_status') }}
{% endblock %}
{% block personcontent %}
{% set person_id = null %}

View File

@@ -34,7 +34,7 @@
<div class="item-row separator">
<dl class="chill_view_data">
<dt class="inline">{{ 'Referrer'|trans|capitalize }}</dt>
<dd>{{ entity.user }}</dd>
<dd>{{ entity.user|chill_entity_render_box }}</dd>
{%- if entity.scope -%}
<dt class="inline">{{ 'Scope'|trans }}</dt>
@@ -85,7 +85,7 @@
{% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {
'context': context,
'with_display': 'bloc',
'render': 'bloc',
'badge_person': 'true'
} %}
@@ -103,8 +103,8 @@
<dd>
{% if entity.location is not null %}
<p>
<span>{{ entity.location.locationType.title|localize_translatable_string }}</span>
{{ entity.location.name }}
<span>({{ entity.location.locationType.title|localize_translatable_string }})</span>
</p>
<div class="ms-3">{{ entity.location.address|chill_entity_render_box }}</div>
{% else %}
@@ -198,8 +198,8 @@
</a>
</li>
{% if is_granted('CHILL_ACTIVITY_UPDATE', entity) %}
<li>
<a class="btn btn-update" href="{{ path('chill_activity_activity_edit', { 'id': entity.id, 'person_id': person_id, 'accompanying_period_id': accompanying_course_id }) }}">
<li>
<a class="btn btn-update" href="{{ path('chill_activity_activity_edit', { 'id': entity.id, 'person_id': person_id, 'accompanying_period_id': accompanying_course_id }) }}">
{{ 'Edit'|trans }}
</a>
</li>
@@ -212,9 +212,3 @@
</li>
{% endif %}
</ul>
<script>
import ShowPane from "../../../../ChillMainBundle/Resources/public/vuejs/Address/components/ShowPane";
export default {
components: {ShowPane}
}
</script>

View File

@@ -4,6 +4,16 @@
{% block title 'Show the activity'|trans %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_notification_toggle_read_status') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_notification_toggle_read_status') }}
{% endblock %}
{% import 'ChillActivityBundle:ActivityReason:macro.html.twig' as m %}
{% block content -%}
@@ -11,3 +21,21 @@
{% include 'ChillActivityBundle:Activity:show.html.twig' with {'context': 'accompanyingCourse'} %}
</div>
{% endblock content %}
{% block block_post_menu %}
<div class="post-menu pt-4">
<div class="d-grid gap-2">
<a class="btn btn-primary" href="{{ chill_path_add_return_path('chill_main_notification_create', {'entityClass': 'Chill\\ActivityBundle\\Entity\\Activity', 'entityId': entity.id}) }}">
<i class="fa fa-paper-plane fa-fw"></i>
{{ 'notification.Notify'|trans }}
</a>
</div>
{% set notifications = chill_list_notifications('Chill\\ActivityBundle\\Entity\\Activity', entity.id) %}
{% if notifications is not empty %}
{{ notifications|raw }}
{% endif %}
</div>
{% endblock %}

View File

@@ -1,2 +1,27 @@
{% macro recordAction(activity) %}
<li>
<a href="{{ path('chill_activity_activity_show', {'id': activity.id }) }}"
class="btn btn-show" title="{{ 'Show the activity'|trans }}"></a>
</li>
{% endmacro %}
<a href="{{ path('chill_activity_activity_show', {'id': notification.relatedEntityId }) }}">Go to Activity</a>
{% if activity is not null %}
<div class="flex-table">
{% if is_granted('CHILL_ACTIVITY_SEE', activity) %}
{% include 'ChillActivityBundle:Activity:_list_item.html.twig' with {
'recordAction': _self.recordAction(activity),
'context': 'accompanyingCourse',
'itemBlocClass': 'bg-chill-llight-gray'
} %}
{% else %}
<div class="alert alert-warning border-warning border-1">
{{ 'This is the minimal activity data'|trans ~ ': ' ~ activity.id }}<br>
{{ 'you are not allowed to see it details'|trans }}
</div>
{% endif %}
</div>
{% else %}
<div class="alert alert-warning border-warning border-1">
{{ 'You get notified of an activity which does not exists any more'|trans }}
</div>
{% endif %}

View File

@@ -4,6 +4,16 @@
{% block title 'Show the activity'|trans %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_notification_toggle_read_status') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_notification_toggle_read_status') }}
{% endblock %}
{% import 'ChillActivityBundle:ActivityReason:macro.html.twig' as m %}
{% block personcontent -%}
@@ -11,3 +21,21 @@
{% include 'ChillActivityBundle:Activity:show.html.twig' with {'context': 'person'} %}
</div>
{% endblock personcontent %}
{% block block_post_menu %}
<div class="post-menu pt-4">
<div class="d-grid gap-2">
<a class="btn btn-primary" href="{{ chill_path_add_return_path('chill_main_notification_create', {'entityClass': 'Chill\\ActivityBundle\\Entity\\Activity', 'entityId': entity.id}) }}">
<i class="fa fa-paper-plane fa-fw"></i>
{{ 'notification.Notify'|trans }}
</a>
</div>
{% set notifications = chill_list_notifications('Chill\\ActivityBundle\\Entity\\Activity', entity.id) %}
{% if notifications is not empty %}
{{ notifications|raw }}
{% endif %}
</div>
{% endblock %}

View File

@@ -224,3 +224,7 @@ Aggregate by activity reason: Aggréger par sujet de l'activité
Last activities: Les dernières activités
See activity in accompanying course context: Voir l'activité dans le contexte du parcours d'accompagnement
You get notified of an activity which does not exists any more: Cette notification ne correspond pas à une activité valide.
you are not allowed to see it details: La notification fait référence à une activité à laquelle vous n'avez pas accès.
This is the minimal activity data: Activité n°

View File

@@ -13,6 +13,9 @@
:close-on-select="false"
:allow-empty="true"
:model-value="value"
:select-label="$t('multiselect.select_label')"
:deselect-label="$t('multiselect.deselect_label')"
:selected-label="$t('multiselect.selected_label')"
@select="selectUsers"
@remove="unSelectUsers"
@close="coloriseSelectedValues"

View File

@@ -1,13 +1,17 @@
import { multiSelectMessages } from 'ChillMainAssets/vuejs/_js/i18n'
const calendarUserSelectorMessages = {
fr: {
choose_your_calendar_user: "Afficher les plages de disponibilités",
select_user: "Sélectionnez des calendriers",
show_my_calendar: "Afficher mon calendrier",
show_weekends: "Afficher les week-ends"
}
};
fr: {
choose_your_calendar_user: "Afficher les plages de disponibilités",
select_user: "Sélectionnez des calendriers",
show_my_calendar: "Afficher mon calendrier",
show_weekends: "Afficher les week-ends"
}
};
export {
calendarUserSelectorMessages
};
Object.assign(calendarUserSelectorMessages.fr, multiSelectMessages.fr);
export {
calendarUserSelectorMessages
};

View File

@@ -94,7 +94,7 @@
<div class="item-col">
{% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {
'context': accompanyingCourse,
'with_display': 'row',
'render': 'row',
'entity': calendar
} %}
</div>

View File

@@ -6,7 +6,7 @@
</dl>
<h2 class="chill-red">{{ 'Concerned groups'|trans }}</h2>
{% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {'context': context, 'with_display': 'bloc' } %}
{% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {'context': context, 'render': 'bloc' } %}
<h2 class="chill-red">{{ 'Calendar data'|trans }}</h2>
@@ -108,13 +108,13 @@
{# TODO
{% if is_granted('CHILL_ACTIVITY_DELETE', entity) %}
#}
<li>
<a href="{{ path('chill_calendar_calendar_delete', { 'id': entity.id, 'accompanying_period_id': accompanying_course_id, 'user_id': user_id } ) }}" class="btn btn-delete">
{{ 'Delete'|trans }}
</a>
</li>
{#
{% endif %}
#}

View File

@@ -276,7 +276,6 @@ final class DocGeneratorTemplateController extends AbstractController
fwrite($templateResource, $dataDecrypted);
rewind($templateResource);
}
$datas = $context->getData($template, $entity, $contextGenerationData);
try {

View File

@@ -46,7 +46,6 @@ class RelatorioDriver implements DriverInterface
'template' => new DataPart($template, $templateName ?? uniqid('template_'), $resourceType),
];
$form = new FormDataPart($formFields);
dump(json_encode($data));
try {
$response = $this->relatorioClient->request('POST', $this->url, [

View File

@@ -23,11 +23,7 @@
<input type="hidden" name="entityClassName" value="{{ contextManager.getContextByKey(entity.context).entityClass|e('html_attr') }}" />
<input type="text" name="entityId" />
<ul class="record_actions">
<li>
<button type="submit" class="btn btn-mini btn-neutral">{{ 'docgen.test generate'|trans }}</button>
</li>
</ul>
<button type="submit" class="btn btn-mini btn-misc"><i class="fa fa-cog"></i>{{ 'docgen.test generate'|trans }}</button>
</form>
</td>
<td>

View File

@@ -11,9 +11,11 @@ declare(strict_types=1);
namespace Chill\DocGeneratorBundle\Serializer\Helper;
use Symfony\Component\Serializer\Mapping\ClassMetadata;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use function array_merge;
use function is_array;
class NormalizeNullValueHelper
{
@@ -30,7 +32,7 @@ class NormalizeNullValueHelper
$this->discriminatorValue = $discriminatorValue;
}
public function normalize(array $attributes, string $format = 'docgen', ?array $context = [])
public function normalize(array $attributes, string $format = 'docgen', ?array $context = [], ?ClassMetadata $classMetadata = null)
{
$data = [];
$data['isNull'] = true;
@@ -58,7 +60,7 @@ class NormalizeNullValueHelper
default:
$data[$key] = $this->normalizer->normalize(null, $format, array_merge(
$context,
$this->getContextForAttribute($key, $context, $classMetadata),
['docgen:expects' => $class]
));
@@ -69,4 +71,25 @@ class NormalizeNullValueHelper
return $data;
}
private function getContextForAttribute(string $key, array $initialContext, ?ClassMetadata $classMetadata): array
{
if (null === $classMetadata) {
return $initialContext;
}
$attributeMetadata = $classMetadata->getAttributesMetadata()[$key] ?? null;
if (null !== $attributeMetadata) {
/** @var \Symfony\Component\Serializer\Mapping\AttributeMetadata $attributeMetadata */
$initialContext = array_merge(
$initialContext,
$attributeMetadata->getNormalizationContextForGroups(
is_array($initialContext['groups']) ? $initialContext['groups'] : [$initialContext['groups']]
)
);
}
return $initialContext;
}
}

View File

@@ -26,7 +26,7 @@ class CollectionDocGenNormalizer implements ContextAwareNormalizerInterface, Nor
/**
* @param Collection $object
* @param null|string $format
* @param string|null $format
*
* @return array|ArrayObject|bool|float|int|string|void|null
*/

View File

@@ -66,7 +66,7 @@ class DocGenObjectNormalizer implements NormalizerAwareInterface, NormalizerInte
if (!$this->classMetadataFactory->hasMetadataFor($classMetadataKey)) {
throw new LogicException(sprintf(
'This object does not have metadata: %s. Add groups on this entity to allow to serialize with the format %s and groups %s',
is_object($object) ? get_class($object) : '(todo' /*$context['docgen:expects'],*/,
is_object($object) ? get_class($object) : '(todo' /*$context['docgen:expects'],*/ ,
$format,
implode(', ', ($context['groups'] ?? []))
));
@@ -196,7 +196,7 @@ class DocGenObjectNormalizer implements NormalizerAwareInterface, NormalizerInte
$normalizer = new NormalizeNullValueHelper($this->normalizer, $typeKey, $typeValue);
return $normalizer->normalize($keys, $format, $context);
return $normalizer->normalize($keys, $format, $context, $metadata);
}
/**
@@ -260,9 +260,13 @@ class DocGenObjectNormalizer implements NormalizerAwareInterface, NormalizerInte
/** @var AttributeMetadata $attribute */
$value = $this->propertyAccess->getValue($object, $attribute->getName());
$key = $attribute->getSerializedName() ?? $attribute->getName();
$isTranslatable = $attribute->getNormalizationContextForGroups(
is_array($context['groups']) ? $context['groups'] : [$context['groups']]
)['is-translatable'] ?? false;
$objectContext = array_merge(
$context,
$attribute->getNormalizationContextForGroups(
is_array($context['groups']) ? $context['groups'] : [$context['groups']]
)
);
$isTranslatable = $objectContext['is-translatable'] ?? false;
if ($isTranslatable) {
$data[$key] = $this->translatableStringHelper
@@ -273,7 +277,7 @@ class DocGenObjectNormalizer implements NormalizerAwareInterface, NormalizerInte
foreach ($value as $k => $v) {
$arr[$k] =
$this->normalizer->normalize($v, $format, array_merge(
$context,
$objectContext,
$attribute->getNormalizationContextForGroups($expectedGroups)
));
}
@@ -281,11 +285,11 @@ class DocGenObjectNormalizer implements NormalizerAwareInterface, NormalizerInte
} elseif (is_object($value)) {
$data[$key] =
$this->normalizer->normalize($value, $format, array_merge(
$context,
$objectContext,
$attribute->getNormalizationContextForGroups($expectedGroups)
));
} elseif (null === $value) {
$data[$key] = $this->normalizeNullOutputValue($format, $context, $attribute, $reflection);
$data[$key] = $this->normalizeNullOutputValue($format, $objectContext, $attribute, $reflection);
} else {
$data[$key] = $value;
}

View File

@@ -14,6 +14,7 @@ namespace Chill\DocGeneratorBundle\tests\Serializer\Normalizer;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -33,6 +34,49 @@ final class DocGenObjectNormalizerTest extends KernelTestCase
$this->normalizer = self::$container->get(NormalizerInterface::class);
}
public function testChangeContextOnAttribute()
{
$object = new TestableParentClass();
$actual = $this->normalizer->normalize(
$object,
'docgen',
['groups' => 'docgen:read']
);
$this->assertIsArray($actual);
$this->assertArrayHasKey('child', $actual);
$this->assertIsArray($actual['child']);
$this->assertArrayHasKey('foo', $actual['child']);
$this->assertEquals('bar', $actual['child']['foo']);
$this->assertArrayNotHasKey('baz', $actual['child']);
// test with child = null
$object->child = null;
$actual = $this->normalizer->normalize(
$object,
'docgen',
['groups' => 'docgen:read']
);
$this->assertIsArray($actual);
$this->assertArrayHasKey('child', $actual);
$this->assertIsArray($actual['child']);
$this->assertArrayHasKey('foo', $actual['child']);
$this->assertEquals('', $actual['child']['foo']);
$this->assertArrayNotHasKey('baz', $actual['child']);
$actual = $this->normalizer->normalize(
null,
'docgen',
['groups' => 'docgen:read', 'docgen:expects' => TestableParentClass::class],
);
$this->assertIsArray($actual);
$this->assertArrayHasKey('child', $actual);
$this->assertIsArray($actual['child']);
$this->assertArrayHasKey('foo', $actual['child']);
$this->assertEquals('', $actual['child']['foo']);
$this->assertArrayNotHasKey('baz', $actual['child']);
}
public function testNormalizationBasic()
{
$scope = new Scope();
@@ -99,3 +143,30 @@ final class DocGenObjectNormalizerTest extends KernelTestCase
$this->assertEquals($expected, $normalized, 'test normalization fo an user with null center');
}
}
class TestableParentClass
{
/**
* @Serializer\Groups("docgen:read")
* @Serializer\Context(normalizationContext={"groups": "docgen:read:foo"}, groups={"docgen:read"})
*/
public ?TestableChildClass $child;
public function __construct()
{
$this->child = new TestableChildClass();
}
}
class TestableChildClass
{
/**
* @Serializer\Groups("docgen:read")
*/
public string $baz = 'bloup';
/**
* @Serializer\Groups("docgen:read:foo")
*/
public string $foo = 'bar';
}

View File

@@ -13,6 +13,7 @@ namespace Chill\DocGeneratorBundle\tests\Service\Context;
use Chill\DocGeneratorBundle\Service\Context\BaseContextData;
use Chill\MainBundle\Entity\User;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -23,6 +24,8 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
*/
final class BaseContextDataTest extends KernelTestCase
{
use ProphecyTrait;
protected function setUp(): void
{
parent::setUp();

View File

@@ -7,6 +7,7 @@ docgen:
Context: Contexte
New template: Nouveau gabarit
Edit template: Modifier gabarit
test generate: Tester la génération
With context: 'Avec le contexte :'

View File

@@ -22,6 +22,7 @@ use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass;
use Chill\MainBundle\DependencyInjection\CompilerPass\WidgetsCompilerPass;
use Chill\MainBundle\DependencyInjection\ConfigConsistencyCompilerPass;
use Chill\MainBundle\DependencyInjection\RoleProvidersCompilerPass;
use Chill\MainBundle\Notification\NotificationHandlerInterface;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Chill\MainBundle\Search\SearchApiInterface;
use Chill\MainBundle\Security\ProvideRoleInterface;
@@ -29,6 +30,7 @@ use Chill\MainBundle\Security\Resolver\CenterResolverInterface;
use Chill\MainBundle\Security\Resolver\ScopeResolverInterface;
use Chill\MainBundle\Templating\Entity\ChillEntityRenderInterface;
use Chill\MainBundle\Templating\Entity\CompilerPass as RenderEntityCompilerPass;
use Chill\MainBundle\Templating\UI\NotificationCounterInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
@@ -50,6 +52,10 @@ class ChillMainBundle extends Bundle
->addTag('chill.render_entity');
$container->registerForAutoconfiguration(SearchApiInterface::class)
->addTag('chill.search_api_provider');
$container->registerForAutoconfiguration(NotificationHandlerInterface::class)
->addTag('chill_main.notification_handler');
$container->registerForAutoconfiguration(NotificationCounterInterface::class)
->addTag('chill.count_notification.user');
$container->addCompilerPass(new SearchableServicesCompilerPass());
$container->addCompilerPass(new ConfigConsistencyCompilerPass());

View File

@@ -0,0 +1,87 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Security\Authorization\NotificationVoter;
use Doctrine\ORM\EntityManagerInterface;
use RuntimeException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Security;
use UnexpectedValueException;
/**
* @Route("/api/1.0/main/notification")
*/
class NotificationApiController
{
private EntityManagerInterface $entityManager;
private Security $security;
public function __construct(EntityManagerInterface $entityManager, Security $security)
{
$this->entityManager = $entityManager;
$this->security = $security;
}
/**
* @Route("/{id}/mark/read", name="chill_api_main_notification_mark_read", methods={"POST"})
*/
public function markAsRead(Notification $notification): JsonResponse
{
return $this->markAs('read', $notification);
}
/**
* @Route("/{id}/mark/unread", name="chill_api_main_notification_mark_unread", methods={"POST"})
*/
public function markAsUnread(Notification $notification): JsonResponse
{
return $this->markAs('unread', $notification);
}
private function markAs(string $target, Notification $notification): JsonResponse
{
if (!$this->security->isGranted(NotificationVoter::NOTIFICATION_TOGGLE_READ_STATUS, $notification)) {
throw new AccessDeniedException('Not allowed to toggle read status of notification');
}
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new RuntimeException('not possible to mark as read by this user');
}
switch ($target) {
case 'read':
$notification->markAsReadBy($user);
break;
case 'unread':
$notification->markAsUnreadBy($user);
break;
default:
throw new UnexpectedValueException("target not supported: {$target}");
}
$this->entityManager->flush();
return new JsonResponse(null, JsonResponse::HTTP_ACCEPTED, [], false);
}
}

View File

@@ -11,59 +11,292 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Notification\NotificationRenderer;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\NotificationComment;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\NotificationCommentType;
use Chill\MainBundle\Form\NotificationType;
use Chill\MainBundle\Notification\Exception\NotificationHandlerNotFound;
use Chill\MainBundle\Notification\NotificationHandlerManager;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\NotificationRepository;
use Chill\MainBundle\Security\Authorization\NotificationVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @Route("/{_locale}/notification")
*/
class NotificationController extends AbstractController
{
private $security;
private EntityManagerInterface $em;
public function __construct(Security $security)
{
private NotificationHandlerManager $notificationHandlerManager;
private NotificationRepository $notificationRepository;
private PaginatorFactory $paginatorFactory;
private Security $security;
private TranslatorInterface $translator;
public function __construct(
EntityManagerInterface $em,
Security $security,
NotificationRepository $notificationRepository,
NotificationHandlerManager $notificationHandlerManager,
PaginatorFactory $paginatorFactory,
TranslatorInterface $translator
) {
$this->em = $em;
$this->security = $security;
$this->notificationRepository = $notificationRepository;
$this->notificationHandlerManager = $notificationHandlerManager;
$this->paginatorFactory = $paginatorFactory;
$this->translator = $translator;
}
/**
* @Route("/show", name="chill_main_notification_show")
* @Route("/create", name="chill_main_notification_create")
*/
public function showAction(
NotificationRepository $notificationRepository,
NotificationRenderer $notificationRenderer,
PaginatorFactory $paginatorFactory
) {
public function createAction(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
if (!$this->security->getUser() instanceof User) {
throw new AccessDeniedHttpException('You must be authenticated and a user to create a notification');
}
if (!$request->query->has('entityClass')) {
throw new BadRequestHttpException('Missing entityClass parameter');
}
if (!$request->query->has('entityId')) {
throw new BadRequestHttpException('missing entityId parameter');
}
$notification = new Notification();
$notification
->setRelatedEntityClass($request->query->get('entityClass'))
->setRelatedEntityId($request->query->getInt('entityId'))
->setSender($this->security->getUser());
try {
$handler = $this->notificationHandlerManager->getHandler($notification);
} catch (NotificationHandlerNotFound $e) {
throw new BadRequestHttpException('no handler for this notification');
}
$form = $this->createForm(NotificationType::class, $notification);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->em->persist($notification);
$this->em->flush();
$this->addFlash('success', $this->translator->trans('notification.Notification created'));
if ($request->query->has('returnPath')) {
return new RedirectResponse($request->query->get('returnPath'));
}
return $this->redirectToRoute('chill_main_homepage');
}
return $this->render('@ChillMain/Notification/create.html.twig', [
'form' => $form->createView(),
'handler' => $handler,
'notification' => $notification,
]);
}
/**
* @Route("/{id}/edit", name="chill_main_notification_edit")
*/
public function editAction(Notification $notification, Request $request): Response
{
$this->denyAccessUnlessGranted(NotificationVoter::NOTIFICATION_UPDATE, $notification);
$form = $this->createForm(NotificationType::class, $notification);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->em->flush();
$this->addFlash('success', $this->translator->trans('notification.Notification updated'));
if ($request->query->has('returnPath')) {
return new RedirectResponse($request->query->get('returnPath'));
}
return $this->redirectToRoute('chill_main_notification_my');
}
return $this->render('@ChillMain/Notification/edit.html.twig', [
'form' => $form->createView(),
'handler' => $this->notificationHandlerManager->getHandler($notification),
'notification' => $notification,
]);
}
/**
* @Route("/inbox", name="chill_main_notification_my")
*/
public function inboxAction(): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$currentUser = $this->security->getUser();
$notificationsNbr = $notificationRepository->countAllForAttendee(($currentUser));
$paginator = $paginatorFactory->create($notificationsNbr);
$notificationsNbr = $this->notificationRepository->countAllForAttendee(($currentUser));
$paginator = $this->paginatorFactory->create($notificationsNbr);
$notifications = $notificationRepository->findAllForAttendee(
$notifications = $this->notificationRepository->findAllForAttendee(
$currentUser,
$limit = $paginator->getItemsPerPage(),
$offset = $paginator->getCurrentPage()->getFirstItemNumber()
);
return $this->render('@ChillMain/Notification/list.html.twig', [
'datas' => $this->itemsForTemplate($notifications),
'notifications' => $notifications,
'paginator' => $paginator,
'step' => 'inbox',
'unreads' => $this->countUnread(),
]);
}
/**
* @Route("/sent", name="chill_main_notification_sent")
*/
public function sentAction(): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$currentUser = $this->security->getUser();
$notificationsNbr = $this->notificationRepository->countAllForSender($currentUser);
$paginator = $this->paginatorFactory->create($notificationsNbr);
$notifications = $this->notificationRepository->findAllForSender(
$currentUser,
$limit = $paginator->getItemsPerPage(),
$offset = $paginator->getCurrentPage()->getFirstItemNumber()
);
return $this->render('@ChillMain/Notification/list.html.twig', [
'datas' => $this->itemsForTemplate($notifications),
'notifications' => $notifications,
'paginator' => $paginator,
'step' => 'sent',
'unreads' => $this->countUnread(),
]);
}
/**
* @Route("/{id}/show", name="chill_main_notification_show")
*/
public function showAction(Notification $notification, Request $request): Response
{
$this->denyAccessUnlessGranted(NotificationVoter::NOTIFICATION_SEE, $notification);
if ($request->query->has('edit')) {
$commentId = $request->query->getInt('edit');
$editedComment = $notification->getComments()->filter(static function (NotificationComment $c) use ($commentId) {
return $c->getId() === $commentId;
})->first();
if (false === $editedComment) {
throw $this->createNotFoundException("Comment with id {$commentId} does not exists nor belong to this notification");
}
$this->denyAccessUnlessGranted(NotificationVoter::COMMENT_EDIT, $editedComment);
$editedCommentForm = $this->createForm(NotificationCommentType::class, $editedComment);
if (Request::METHOD_POST === $request->getMethod() && 'edit' === $request->request->get('form')) {
$editedCommentForm->handleRequest($request);
if ($editedCommentForm->isSubmitted() && $editedCommentForm->isValid()) {
$this->em->flush();
$this->addFlash('success', $this->translator->trans('notification.comment_updated'));
return $this->redirectToRoute('chill_main_notification_show', [
'id' => $notification->getId(),
'_fragment' => 'comment-' . $commentId,
]);
}
}
}
if ($this->isGranted(NotificationVoter::COMMENT_ADD, $notification)) {
$appendComment = new NotificationComment();
$appendCommentForm = $this->createForm(NotificationCommentType::class, $appendComment);
if (Request::METHOD_POST === $request->getMethod() && 'append' === $request->request->get('form')) {
$appendCommentForm->handleRequest($request);
if ($appendCommentForm->isSubmitted() && $appendCommentForm->isValid()) {
$notification->addComment($appendComment);
$this->em->persist($appendComment);
$this->em->flush();
$this->addFlash('success', $this->translator->trans('notification.comment_appended'));
return $this->redirectToRoute('chill_main_notification_show', [
'id' => $notification->getId(),
]);
}
}
}
$response = $this->render('@ChillMain/Notification/show.html.twig', [
'notification' => $notification,
'handler' => $this->notificationHandlerManager->getHandler($notification),
'appendCommentForm' => isset($appendCommentForm) ? $appendCommentForm->createView() : null,
'editedCommentForm' => isset($editedCommentForm) ? $editedCommentForm->createView() : null,
'editedCommentId' => $commentId ?? null,
]);
// we mark the notification as read after having computed the response
if ($this->getUser() instanceof User && !$notification->isReadBy($this->getUser())) {
$notification->markAsReadBy($this->getUser());
$this->em->flush();
}
return $response;
}
private function countUnread(): array
{
return [
'sent' => $this->notificationRepository->countUnreadByUserWhereSender($this->security->getUser()),
'inbox' => $this->notificationRepository->countUnreadByUserWhereAddressee($this->security->getUser()),
];
}
private function itemsForTemplate(array $notifications): array
{
$templateData = [];
foreach ($notifications as $notification) {
$data = [
'template' => $notificationRenderer->getTemplate($notification),
'template_data' => $notificationRenderer->getTemplateData($notification),
$templateData[] = [
'template' => $this->notificationHandlerManager->getTemplate($notification),
'template_data' => $this->notificationHandlerManager->getTemplateData($notification),
'notification' => $notification,
];
$templateData[] = $data;
}
return $this->render('@ChillMain/Notification/show.html.twig', [
'datas' => $templateData,
'notifications' => $notifications,
'paginator' => $paginator,
]);
return $templateData;
}
}

View File

@@ -11,7 +11,9 @@ declare(strict_types=1);
namespace Chill\MainBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -20,19 +22,27 @@ use Doctrine\ORM\Mapping as ORM;
* @ORM\Entity
* @ORM\Table(
* name="chill_main_notification",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"relatedEntityClass", "relatedEntityId"})
* }
* )
* @ORM\HasLifecycleCallbacks
*/
class Notification
class Notification implements TrackUpdateInterface
{
private array $addedAddresses = [];
/**
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_notification_addresses_user")
*/
private Collection $addressees;
private ?ArrayCollection $addressesOnLoad = null;
/**
* @ORM\OneToMany(targetEntity=NotificationComment::class, mappedBy="notification", orphanRemoval=true)
* @ORM\OrderBy({"createdAt": "ASC"})
*/
private Collection $comments;
/**
* @ORM\Column(type="datetime_immutable")
*/
@@ -43,43 +53,84 @@ class Notification
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private int $id;
private ?int $id = null;
/**
* @ORM\Column(type="text")
*/
private string $message;
/**
* @ORM\Column(type="json")
*/
private array $read;
private string $message = '';
/**
* @ORM\Column(type="string", length=255)
*/
private string $relatedEntityClass;
private string $relatedEntityClass = '';
/**
* @ORM\Column(type="integer")
*/
private int $relatedEntityId;
private array $removedAddresses = [];
/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=false)
* @ORM\JoinColumn(nullable=true)
*/
private User $sender;
private ?User $sender = null;
/**
* @ORM\Column(type="text", options={"default": ""})
*/
private string $title = '';
/**
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_notification_addresses_unread")
*/
private Collection $unreadBy;
/**
* @ORM\Column(type="datetime_immutable")
*/
private ?DateTimeImmutable $updatedAt;
/**
* @ORM\ManyToOne(targetEntity=User::class)
*/
private ?User $updatedBy;
public function __construct()
{
$this->addressees = new ArrayCollection();
$this->unreadBy = new ArrayCollection();
$this->comments = new ArrayCollection();
$this->setDate(new DateTimeImmutable());
}
public function addAddressee(User $addressee): self
{
if (!$this->addressees->contains($addressee)) {
$this->addressees[] = $addressee;
$this->addedAddresses[] = $addressee;
}
return $this;
}
public function addComment(NotificationComment $comment): self
{
if (!$this->comments->contains($comment)) {
$this->comments[] = $comment;
$comment->setNotification($this);
}
return $this;
}
public function addUnreadBy(User $user): self
{
if (!$this->unreadBy->contains($user)) {
$this->unreadBy[] = $user;
}
return $this;
@@ -90,9 +141,19 @@ class Notification
*/
public function getAddressees(): Collection
{
// keep a copy to compute changes later
if (null === $this->addressesOnLoad) {
$this->addressesOnLoad = new ArrayCollection($this->addressees->toArray());
}
return $this->addressees;
}
public function getComments(): Collection
{
return $this->comments;
}
public function getDate(): ?DateTimeImmutable
{
return $this->date;
@@ -108,11 +169,6 @@ class Notification
return $this->message;
}
public function getRead(): array
{
return $this->read;
}
public function getRelatedEntityClass(): ?string
{
return $this->relatedEntityClass;
@@ -128,9 +184,97 @@ class Notification
return $this->sender;
}
public function getTitle(): string
{
return $this->title;
}
public function getUnreadBy(): Collection
{
return $this->unreadBy;
}
public function getUpdatedAt(): ?DateTimeImmutable
{
return $this->updatedAt;
}
public function getUpdatedBy(): ?User
{
return $this->updatedBy;
}
public function isReadBy(User $user): bool
{
return !$this->unreadBy->contains($user);
}
public function isSystem(): bool
{
return null === $this->sender;
}
public function markAsReadBy(User $user): self
{
return $this->removeUnreadBy($user);
}
public function markAsUnreadBy(User $user): self
{
return $this->addUnreadBy($user);
}
/**
* @ORM\PreFlush
*/
public function registerUnread()
{
foreach ($this->addedAddresses as $addressee) {
$this->addUnreadBy($addressee);
}
foreach ($this->removedAddresses as $addressee) {
$this->removeAddressee($addressee);
}
if (null !== $this->addressesOnLoad) {
foreach ($this->addressees as $existingAddresse) {
if (!$this->addressesOnLoad->contains($existingAddresse)) {
$this->addUnreadBy($existingAddresse);
}
}
foreach ($this->addressesOnLoad as $onLoadAddressee) {
if (!$this->addressees->contains($onLoadAddressee)) {
$this->removeUnreadBy($onLoadAddressee);
}
}
}
$this->removedAddresses = [];
$this->addedAddresses = [];
$this->addressesOnLoad = null;
}
public function removeAddressee(User $addressee): self
{
$this->addressees->removeElement($addressee);
if ($this->addressees->removeElement($addressee)) {
$this->removedAddresses[] = $addressee;
}
return $this;
}
public function removeComment(NotificationComment $comment): self
{
$this->comments->removeElement($comment);
return $this;
}
public function removeUnreadBy(User $user): self
{
$this->unreadBy->removeElement($user);
return $this;
}
@@ -149,13 +293,6 @@ class Notification
return $this;
}
public function setRead(array $read): self
{
$this->read = $read;
return $this;
}
public function setRelatedEntityClass(string $relatedEntityClass): self
{
$this->relatedEntityClass = $relatedEntityClass;
@@ -176,4 +313,25 @@ class Notification
return $this;
}
public function setTitle(string $title): Notification
{
$this->title = $title;
return $this;
}
public function setUpdatedAt(DateTimeInterface $datetime): self
{
$this->updatedAt = $datetime;
return $this;
}
public function setUpdatedBy(User $user): self
{
$this->updatedBy = $user;
return $this;
}
}

View File

@@ -0,0 +1,191 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreFlushEventArgs;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table("chill_main_notification_comment")
* @ORM\HasLifecycleCallbacks
*/
class NotificationComment implements TrackCreationInterface, TrackUpdateInterface
{
/**
* @ORM\Column(type="text")
*/
private string $content = '';
/**
* @ORM\Column(type="datetime_immutable", nullable=true)
*/
private ?DateTimeImmutable $createdAt = null;
/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=true)
*/
private ?User $createdBy = null;
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private ?int $id = null;
/**
* @ORM\ManyToOne(targetEntity=Notification::class, inversedBy="comments")
* @ORM\JoinColumn(nullable=false)
*/
private ?Notification $notification = null;
/**
* Internal variable which detect if the comment is just persisted.
*
* @internal
*/
private bool $recentlyPersisted = false;
/**
* TODO typo in property (hotfixed).
*
* @ORM\Column(type="datetime_immutable", nullable=true)
*/
private ?DateTimeImmutable $updateAt = null;
/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=true)
*/
private ?User $updatedBy = null;
public function getContent(): string
{
return $this->content;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function getCreatedBy(): ?User
{
return $this->createdBy;
}
public function getId(): ?int
{
return $this->id;
}
public function getNotification(): ?Notification
{
return $this->notification;
}
public function getUpdatedAt(): ?DateTimeImmutable
{
return $this->updateAt;
}
public function getUpdatedBy(): ?User
{
return $this->updatedBy;
}
/**
* @ORM\PreFlush
*/
public function onFlushMarkNotificationAsUnread(PreFlushEventArgs $eventArgs): void
{
if ($this->recentlyPersisted) {
foreach ($this->getNotification()->getAddressees() as $addressee) {
if ($this->getCreatedBy() !== $addressee) {
$this->getNotification()->markAsUnreadBy($addressee);
}
}
if ($this->getNotification()->getSender() !== $this->getCreatedBy()) {
$this->getNotification()->markAsUnreadBy($this->getNotification()->getSender());
}
}
}
/**
* @ORM\PrePersist
*/
public function onPrePersist(LifecycleEventArgs $eventArgs): void
{
$this->recentlyPersisted = true;
}
public function setContent(string $content): self
{
$this->content = $content;
return $this;
}
public function setCreatedAt(DateTimeInterface $datetime): self
{
$this->createdAt = $datetime;
return $this;
}
public function setCreatedBy(User $user): self
{
$this->createdBy = $user;
return $this;
}
/**
* @internal use Notification::addComment
*/
public function setNotification(?Notification $notification): self
{
$this->notification = $notification;
return $this;
}
/**
* @deprecated use @see{self::setUpdatedAt} instead
*/
public function setUpdateAt(?DateTimeImmutable $updateAt): self
{
return $this->setUpdatedAt($updateAt);
}
public function setUpdatedAt(DateTimeInterface $datetime): self
{
$this->updateAt = $datetime;
return $this;
}
public function setUpdatedBy(User $user): self
{
$this->updatedBy = $user;
return $this;
}
}

View File

@@ -0,0 +1,26 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Form\Type\ChillTextareaType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
class NotificationCommentType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('content', ChillTextareaType::class, [
'required' => false,
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Form\Type\ChillTextareaType;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class NotificationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title', TextType::class, [
'label' => 'Title',
'required' => true,
])
->add('addressees', PickUserDynamicType::class, [
'multiple' => true,
])
->add('message', ChillTextareaType::class, [
'required' => false,
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('class', Notification::class);
}
}

View File

@@ -0,0 +1,81 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Form\Type\DataTransformer;
use Chill\MainBundle\Entity\User;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;
use function array_key_exists;
class UserToJsonTransformer implements DataTransformerInterface
{
private DenormalizerInterface $denormalizer;
private bool $multiple;
private SerializerInterface $serializer;
public function __construct(DenormalizerInterface $denormalizer, SerializerInterface $serializer, bool $multiple)
{
$this->denormalizer = $denormalizer;
$this->serializer = $serializer;
$this->multiple = $multiple;
}
public function reverseTransform($value)
{
if ($this->multiple) {
return array_map(
function ($item) { return $this->denormalizeOne($item); },
json_decode($value, true)
);
}
return $this->denormalizeOne(json_decode($value, true));
}
/**
* @param User|User[] $value
*/
public function transform($value): string
{
if (null === $value) {
return $this->multiple ? 'null' : '[]';
}
return $this->serializer->serialize($value, 'json', [
AbstractNormalizer::GROUPS => ['read'],
]);
}
private function denormalizeOne(array $item): User
{
if (!array_key_exists('type', $item)) {
throw new TransformationFailedException('the key "type" is missing on element');
}
if (!array_key_exists('id', $item)) {
throw new TransformationFailedException('the key "id" is missing on element');
}
return
$this->denormalizer->denormalize(
['type' => $item['type'], 'id' => $item['id']],
User::class,
'json',
[AbstractNormalizer::GROUPS => ['read']],
);
}
}

View File

@@ -0,0 +1,63 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Form\Type;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\DataTransformer\UserToJsonTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Pick user dymically, using vuejs module "AddPerson".
*/
class PickUserDynamicType extends AbstractType
{
private DenormalizerInterface $denormalizer;
private SerializerInterface $serializer;
public function __construct(DenormalizerInterface $denormalizer, SerializerInterface $serializer)
{
$this->denormalizer = $denormalizer;
$this->serializer = $serializer;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addViewTransformer(new UserToJsonTransformer($this->denormalizer, $this->serializer, $options['multiple']));
}
public function buildView(FormView $view, FormInterface $form, array $options)
{
$view->vars['multiple'] = $options['multiple'];
$view->vars['types'] = ['user'];
$view->vars['uniqid'] = uniqid('pick_user_dyn');
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setDefault('multiple', false)
->setAllowedTypes('multiple', ['bool'])
->setDefault('compound', false);
}
public function getBlockPrefix()
{
return 'pick_user_dynamic';
}
}

View File

@@ -0,0 +1,95 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Notification\Counter;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\NotificationComment;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\NotificationRepository;
use Chill\MainBundle\Templating\UI\NotificationCounterInterface;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreFlushEventArgs;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Security\Core\User\UserInterface;
final class NotificationByUserCounter implements NotificationCounterInterface
{
private CacheItemPoolInterface $cacheItemPool;
private NotificationRepository $notificationRepository;
public function __construct(CacheItemPoolInterface $cacheItemPool, NotificationRepository $notificationRepository)
{
$this->cacheItemPool = $cacheItemPool;
$this->notificationRepository = $notificationRepository;
}
public function addNotification(UserInterface $u): int
{
if (!$u instanceof User) {
return 0;
}
return $this->countUnreadByUser($u);
}
public function countUnreadByUser(User $user): int
{
$key = self::generateCacheKeyUnreadNotificationByUser($user);
$item = $this->cacheItemPool->getItem($key);
if ($item->isHit()) {
return $item->get();
}
$unreads = $this->notificationRepository->countUnreadByUser($user);
$item
->set($unreads)
// keep in cache for 15 minutes
->expiresAfter(60 * 15);
$this->cacheItemPool->save($item);
return $unreads;
}
public static function generateCacheKeyUnreadNotificationByUser(User $user): string
{
return 'chill_main_notif_unread_by_' . $user->getId();
}
public function onEditNotificationComment(NotificationComment $notificationComment, LifecycleEventArgs $eventArgs): void
{
$this->resetCacheForNotification($notificationComment->getNotification());
}
public function onPreFlushNotification(Notification $notification, PreFlushEventArgs $eventArgs): void
{
$this->resetCacheForNotification($notification);
}
private function resetCacheForNotification(Notification $notification): void
{
$keys = [];
if (null !== $notification->getSender()) {
$keys[] = self::generateCacheKeyUnreadNotificationByUser($notification->getSender());
}
foreach ($notification->getAddressees() as $addressee) {
$keys[] = self::generateCacheKeyUnreadNotificationByUser($addressee);
}
$this->cacheItemPool->deleteItems($keys);
}
}

View File

@@ -0,0 +1,110 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Notification\Email;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\NotificationComment;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Contracts\Translation\TranslatorInterface;
class NotificationMailer
{
private LoggerInterface $logger;
private MailerInterface $mailer;
private TranslatorInterface $translator;
public function __construct(MailerInterface $mailer, LoggerInterface $logger, TranslatorInterface $translator)
{
$this->mailer = $mailer;
$this->logger = $logger;
$this->translator = $translator;
}
public function postPersistComment(NotificationComment $comment, LifecycleEventArgs $eventArgs): void
{
foreach (
array_merge(
$comment->getNotification()->getAddressees()->toArray(),
[$comment->getNotification()->getSender()]
) as $dest
) {
if (null === $dest->getEmail() || $comment->getCreatedBy() !== $dest) {
continue;
}
$email = new TemplatedEmail();
$email
->to($dest->getEmail())
->subject('Re: [Chill] ' . $comment->getNotification()->getTitle())
->textTemplate('@ChillMain/Notification/email_notification_comment_persist.fr.md.twig')
->context([
'comment' => $comment,
'dest' => $dest,
]);
try {
$this->mailer->send($email);
} catch (TransportExceptionInterface $e) {
$this->logger->warning('[NotificationMailer] could not send an email notification about comment', [
'to' => $dest->getEmail(),
'error_message' => $e->getMessage(),
'error_trace' => $e->getTraceAsString(),
]);
}
}
}
/**
* Send a email after a notification is persisted.
*/
public function postPersistNotification(Notification $notification, LifecycleEventArgs $eventArgs): void
{
foreach ($notification->getAddressees() as $addressee) {
if (null === $addressee->getEmail()) {
continue;
}
if ($notification->isSystem()) {
$email = new Email();
$email
->text($notification->getMessage())
->subject('[Chill] ' . $notification->getTitle());
} else {
$email = new TemplatedEmail();
$email
->textTemplate('@ChillMain/Notification/email_non_system_notification_content.fr.md.twig')
->context([
'notification' => $notification,
'dest' => $addressee,
]);
}
$email->to($addressee->getEmail());
try {
$this->mailer->send($email);
} catch (TransportExceptionInterface $e) {
$this->logger->warning('[NotificationMailer] could not send an email notification', [
'to' => $addressee->getEmail(),
'error_message' => $e->getMessage(),
'error_trace' => $e->getTraceAsString(),
]);
}
}
}
}

View File

@@ -0,0 +1,18 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Notification\Exception;
use RuntimeException;
class NotificationHandlerNotFound extends RuntimeException
{
}

View File

@@ -0,0 +1,32 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Notification;
use Chill\MainBundle\Entity\Notification;
interface NotificationHandlerInterface
{
/**
* Return the template path (twig file).
*/
public function getTemplate(Notification $notification, array $options = []): string;
/**
* Return an array which will be passed as data for the template.
*/
public function getTemplateData(Notification $notification, array $options = []): array;
/**
* Return true if the handler supports the handling for this notification.
*/
public function supports(Notification $notification, array $options = []): bool;
}

View File

@@ -0,0 +1,55 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Notification;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Notification\Exception\NotificationHandlerNotFound;
use Doctrine\ORM\EntityManagerInterface;
final class NotificationHandlerManager
{
private EntityManagerInterface $em;
private iterable $handlers;
public function __construct(
iterable $handlers,
EntityManagerInterface $em
) {
$this->handlers = $handlers;
$this->em = $em;
}
/**
* @throw NotificationHandlerNotFound if handler is not found
*/
public function getHandler(Notification $notification, array $options = []): NotificationHandlerInterface
{
foreach ($this->handlers as $renderer) {
if ($renderer->supports($notification, $options)) {
return $renderer;
}
}
throw new NotificationHandlerNotFound();
}
public function getTemplate(Notification $notification, array $options = []): string
{
return $this->getHandler($notification, $options)->getTemplate($notification, $options);
}
public function getTemplateData(Notification $notification, array $options = []): array
{
return $this->getHandler($notification, $options)->getTemplateData($notification, $options);
}
}

View File

@@ -0,0 +1,51 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Notification;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\NotificationRepository;
use Symfony\Component\Security\Core\Security;
/**
* Helps to find if a notification exist for a given entity.
*/
class NotificationPresence
{
private NotificationRepository $notificationRepository;
private Security $security;
public function __construct(Security $security, NotificationRepository $notificationRepository)
{
$this->security = $security;
$this->notificationRepository = $notificationRepository;
}
/**
* @return array|Notification[]
*/
public function getNotificationsForClassAndEntity(string $relatedEntityClass, int $relatedEntityId): array
{
$user = $this->security->getUser();
if ($user instanceof User) {
return $this->notificationRepository->findNotificationByRelatedEntityAndUserAssociated(
$relatedEntityClass,
$relatedEntityId,
$user
);
}
return [];
}
}

View File

@@ -1,54 +0,0 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Notification;
use Chill\ActivityBundle\Notification\ActivityNotificationRenderer;
use Chill\MainBundle\Entity\Notification;
use Chill\PersonBundle\Notification\AccompanyingPeriodNotificationRenderer;
use Exception;
final class NotificationRenderer
{
private array $renderers;
public function __construct(
AccompanyingPeriodNotificationRenderer $accompanyingPeriodNotificationRenderer,
ActivityNotificationRenderer $activityNotificationRenderer
) {
// TODO configure automatically
// TODO CREER UNE INTERFACE POUR ETRE SUR QUE LES RENDERERS SONT OK
$this->renderers[] = $accompanyingPeriodNotificationRenderer;
$this->renderers[] = $activityNotificationRenderer;
}
public function getTemplate(Notification $notification)
{
return $this->getRenderer($notification)->getTemplate();
}
public function getTemplateData(Notification $notification)
{
return $this->getRenderer($notification)->getTemplateData($notification);
}
private function getRenderer(Notification $notification)
{
foreach ($this->renderers as $renderer) {
if ($renderer->supports($notification)) {
return $renderer;
}
}
throw new Exception('No renderer for ' . $notification);
}
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Notification\Templating;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class NotificationTwigExtension extends AbstractExtension
{
public function getFunctions()
{
return [
new TwigFunction('chill_list_notifications', [NotificationTwigExtensionRuntime::class, 'listNotificationsFor'], [
'needs_environment' => true,
'is_safe' => ['html'],
]),
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Notification\Templating;
use Chill\MainBundle\Notification\NotificationPresence;
use Twig\Environment;
use Twig\Extension\RuntimeExtensionInterface;
class NotificationTwigExtensionRuntime implements RuntimeExtensionInterface
{
private NotificationPresence $notificationPresence;
public function __construct(NotificationPresence $notificationPresence)
{
$this->notificationPresence = $notificationPresence;
}
public function listNotificationsFor(Environment $environment, string $relatedEntityClass, int $relatedEntityId, array $options = []): string
{
$notifications = $this->notificationPresence->getNotificationsForClassAndEntity($relatedEntityClass, $relatedEntityId);
if ([] === $notifications) {
return '';
}
return $environment->render('@ChillMain/Notification/extension_list_notifications_for.html.twig', [
'notifications' => $notifications,
]);
}
}

View File

@@ -13,25 +13,76 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
final class NotificationRepository implements ObjectRepository
{
private EntityManagerInterface $em;
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
$this->em = $entityManager;
$this->repository = $entityManager->getRepository(Notification::class);
}
public function countAllForAttendee(User $addressee): int // TODO passer à attendees avec S
public function countAllForAttendee(User $addressee): int
{
$query = $this->queryAllForAttendee($addressee, $countQuery = true);
return $this->queryByAddressee($addressee)
->select('count(n)')
->getQuery()
->getSingleScalarResult();
}
return $query->getSingleScalarResult();
public function countAllForSender(User $sender): int
{
return $this->queryBySender($sender)
->select('count(n)')
->getQuery()
->getSingleScalarResult();
}
public function countUnreadByUser(User $user): int
{
$sql = 'SELECT count(*) AS c FROM chill_main_notification_addresses_unread WHERE user_id = :userId';
$rsm = new Query\ResultSetMapping();
$rsm->addScalarResult('c', 'c', Types::INTEGER);
$nq = $this->em->createNativeQuery($sql, $rsm)
->setParameter('userId', $user->getId());
return $nq->getSingleScalarResult();
}
public function countUnreadByUserWhereAddressee(User $user): int
{
$qb = $this->repository->createQueryBuilder('n');
$qb
->select('count(n)')
->where($qb->expr()->isMemberOf(':user', 'n.addressees'))
->andWhere($qb->expr()->isMemberOf(':user', 'n.unreadBy'))
->setParameter('user', $user);
return $qb->getQuery()->getSingleScalarResult();
}
public function countUnreadByUserWhereSender(User $user): int
{
$qb = $this->repository->createQueryBuilder('n');
$qb
->select('count(n)')
->where($qb->expr()->eq('n.sender', ':user'))
->andWhere($qb->expr()->isMemberOf(':user', 'n.unreadBy'))
->setParameter('user', $user);
return $qb->getQuery()->getSingleScalarResult();
}
public function find($id, $lockMode = null, $lockVersion = null): ?Notification
@@ -53,9 +104,9 @@ final class NotificationRepository implements ObjectRepository
*
* @return Notification[]
*/
public function findAllForAttendee(User $addressee, $limit = null, $offset = null): array // TODO passer à attendees avec S
public function findAllForAttendee(User $addressee, $limit = null, $offset = null): array
{
$query = $this->queryAllForAttendee($addressee);
$query = $this->queryByAddressee($addressee)->select('n');
if ($limit) {
$query = $query->setMaxResults($limit);
@@ -65,7 +116,26 @@ final class NotificationRepository implements ObjectRepository
$query = $query->setFirstResult($offset);
}
return $query->getResult();
$query->addOrderBy('n.date', 'DESC');
return $query->getQuery()->getResult();
}
public function findAllForSender(User $sender, $limit = null, $offset = null): array
{
$query = $this->queryBySender($sender)->select('n');
if ($limit) {
$query = $query->setMaxResults($limit);
}
if ($offset) {
$query = $query->setFirstResult($offset);
}
$query->addOrderBy('n.date', 'DESC');
return $query->getQuery()->getResult();
}
/**
@@ -79,6 +149,31 @@ final class NotificationRepository implements ObjectRepository
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
/**
* @return array|Notification[]
*/
public function findNotificationByRelatedEntityAndUserAssociated(string $relatedEntityClass, int $relatedEntityId, User $user): array
{
$qb = $this->repository->createQueryBuilder('n');
$qb
->select('n')
->where($qb->expr()->eq('n.relatedEntityClass', ':relatedEntityClass'))
->andWhere($qb->expr()->eq('n.relatedEntityId', ':relatedEntityId'))
->andWhere($qb->expr()->isNotNull('n.sender'))
->andWhere(
$qb->expr()->orX(
$qb->expr()->isMemberOf(':user', 'n.addressees'),
$qb->expr()->eq('n.sender', ':user')
)
)
->setParameter('relatedEntityClass', $relatedEntityClass)
->setParameter('relatedEntityId', $relatedEntityId)
->setParameter('user', $user);
return $qb->getQuery()->getResult();
}
public function findOneBy(array $criteria, ?array $orderBy = null): ?Notification
{
return $this->repository->findOneBy($criteria, $orderBy);
@@ -89,22 +184,25 @@ final class NotificationRepository implements ObjectRepository
return Notification::class;
}
private function queryAllForAttendee(User $addressee, bool $countQuery = false): Query
private function queryByAddressee(User $addressee, bool $countQuery = false): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('n');
$select = 'n';
if ($countQuery) {
$select = 'count(n)';
}
$qb
->select($select)
->join('n.addressees', 'a')
->where('a = :addressee')
->where($qb->expr()->isMemberOf(':addressee', 'n.addressees'))
->setParameter('addressee', $addressee);
return $qb->getQuery();
return $qb;
}
private function queryBySender(User $sender): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('n');
$qb
->where($qb->expr()->eq('n.sender', ':sender'))
->setParameter('sender', $sender);
return $qb;
}
}

View File

@@ -25,6 +25,8 @@
// Chill flex responsive table/block presentation
@import './scss/flex_table';
// Specific templates
@import './scss/notification';
/*
* BASE LAYOUT POSITION
@@ -416,3 +418,8 @@ span.item-key {
background-color: #0000000a;
//text-decoration: dotted underline;
}
// increase toast message z-index (above all modals)
div.v-toast {
z-index: 10000!important;
}

View File

@@ -20,6 +20,7 @@ $chill-theme-buttons: (
"misc": $gray-300,
"cancel": $gray-300,
"choose": $gray-300,
"notify": $gray-300,
"unlink": $chill-red,
);
@@ -73,6 +74,7 @@ $chill-theme-buttons: (
&.btn-delete::before,
&.btn-remove::before,
&.btn-choose::before,
&.btn-notify::before,
&.btn-cancel::before {
font: normal normal normal 14px/1 ForkAwesome;
margin-right: 0.5em;
@@ -98,6 +100,7 @@ $chill-theme-buttons: (
&.btn-cancel::before { content: "\f060"; } // fa-arrow-left
&.btn-choose::before { content: "\f00c"; } // fa-check // f046 fa-check-square-o
&.btn-unlink::before { content: "\f127"; } // fa-chain-broken
&.btn-notify::before { content: "\f1d8"; } // fa-paper-plane
}

View File

@@ -0,0 +1,62 @@
div.notification {
h2.notification-title,
h6.notification-title {
a {
text-decoration: none;
}
&::before {
font-family: "ForkAwesome";
font-size: 80%;
margin-right: 0.3em;
}
}
div.read {
h2.notification-title,
h6.notification-title {
font-weight: 500;
&::before {
content: "\f2b7"; //envelope-open-o
}
}
}
div.unread {
h2.notification-title,
h6.notification-title {
&::before {
content: "\f003"; //envelope-o
}
}
}
}
/*
* Notifications List
*/
div.notification-list,
div.notification-show {
div.item-bloc {
div.item-row.header {
div.item-col {
&:first-child {
flex-grow: 1;
}
&:last-child {
flex-grow: 0;
}
}
ul.small_in_title {
list-style-type: circle;
li {
span.item-key {
display: inline-block;
width: 3em;
}
}
}
}
}
}

View File

@@ -85,7 +85,9 @@ const fetchScopes = () => {
const ValidationException = (response) => {
const error = {};
error.name = 'ValidationException';
error.violations = response.violations.map((violation) => `${violation.title}`);
error.violations = response.violations.map((violation) => `${violation.title}: ${violation.propertyPath}`);
error.titles = response.violations.map((violation) => violation.title);
error.propertyPaths = response.violations.map((violation) => violation.propertyPath);
return error;
}

View File

@@ -0,0 +1,43 @@
import {createApp} from "vue";
import NotificationReadToggle from "ChillMainAssets/vuejs/_components/Notification/NotificationReadToggle.vue";
import { _createI18n } from "ChillMainAssets/vuejs/_js/i18n";
const i18n = _createI18n({});
window.addEventListener('DOMContentLoaded', function (e) {
document.querySelectorAll('.notification_toggle_read_status')
.forEach(function (el) {
createApp({
template: '<notification-read-toggle ' +
':notificationId="notificationId" ' +
':buttonClass="buttonClass" ' +
':buttonNoText="buttonNoText" ' +
':showUrl="showUrl" ' +
':isRead="isRead"' +
'@markRead="onMarkRead" @markUnread="onMarkUnread"' +
'></notification-read-toggle>',
components: {
NotificationReadToggle,
},
data() {
return {
notificationId: +el.dataset.notificationId,
buttonClass: el.dataset.buttonClass,
buttonNoText: 'false' === el.dataset.buttonText,
showUrl: el.dataset.showButtonUrl,
isRead: 1 === +el.dataset.notificationCurrentIsRead,
}
},
methods: {
onMarkRead() {
this.isRead = false;
},
onMarkUnread() {
this.isRead = true;
},
}
})
.use(i18n)
.mount(el);
});
})

View File

@@ -0,0 +1,69 @@
import { createApp } from 'vue';
import PickEntity from 'ChillMainAssets/vuejs/PickEntity/PickEntity.vue';
import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n';
import { appMessages } from 'ChillMainAssets/vuejs/PickEntity/i18n';
const i18n = _createI18n(appMessages);
window.addEventListener('DOMContentLoaded', function(e) {
let apps = document.querySelectorAll('[data-module="pick-dynamic"]');
apps.forEach(function(el) {
const
isMultiple = parseInt(el.dataset.multiple) === 1,
input = document.querySelector('[data-input-uniqid="'+ el.dataset.uniqid +'"]'),
picked = isMultiple ? JSON.parse(input.value) : [JSON.parse(input.value)];
createApp({
template: '<pick-entity ' +
':multiple="multiple" ' +
':types="types" ' +
':picked="picked" ' +
':uniqid="uniqid" ' +
'@addNewEntity="addNewEntity" ' +
'@removeEntity="removeEntity"></pick-entity>',
components: {
PickEntity,
},
data() {
return {
multiple: isMultiple,
types: JSON.parse(el.dataset.types),
picked,
uniqid: el.dataset.uniqid,
}
},
methods: {
addNewEntity(entity) {
console.log('addNewEntity', entity);
if (this.multiple) {
console.log('adding multiple');
if (!this.picked.some(el => {
return el.type === entity.type && el.id === entity.id;
})) {
this.picked.push(entity);
input.value = JSON.stringify(this.picked);
}
} else {
if (!this.picked.some(el => {
return el.type === entity.type && el.id === entity.id;
})) {
this.picked.splice(0, this.picked.length);
this.picked.push(entity);
input.value = JSON.stringify(this.picked[0]);
}
}
},
removeEntity(entity) {
console.log('removeEntity', entity);
this.picked = this.picked.filter(e => !(e.type === entity.type && e.id === entity.id));
input.value = JSON.stringify(this.picked);
},
}
})
.use(i18n)
.mount(el);
});
});

View File

@@ -98,6 +98,8 @@
v-bind:defaultz="this.defaultz"
v-bind:entity="this.entity"
v-bind:flag="this.flag"
v-bind:errors="this.errors"
v-bind:checkErrors="this.checkErrors"
@getCities="getCities"
@getReferenceAddresses="getReferenceAddresses">
</edit-pane>
@@ -123,6 +125,8 @@
v-bind:defaultz="this.defaultz"
v-bind:entity="this.entity"
v-bind:flag="this.flag"
v-bind:errors="this.errors"
v-bind:checkErrors="this.checkErrors"
v-bind:insideModal="false"
@getCities="getCities"
@getReferenceAddresses="getReferenceAddresses">
@@ -256,8 +260,10 @@ export default {
editPane: false,
datePane: false,
loading: false,
success: false
success: false,
dirty: false
},
errors: [],
defaultz: {
button: {
text: { create: 'add_an_address_title', edit: 'edit_address' },
@@ -359,8 +365,8 @@ export default {
//console.log('validFrom', this.validFrom);
//console.log('validTo', this.validTo);
//console.log('useDatePane', this.useDatePane);
//console.log('Mounted now !');
console.log('Mounted now !');
if (this.context.edit) {
console.log('getInitialAddress', this.context.addressId);
this.getInitialAddress(this.context.addressId);
@@ -380,7 +386,7 @@ export default {
this.openEditPane();
} else {
this.flag.showPane = true;
console.log('step0: open the Show Panel');
//console.log('step0: open the Show Panel');
}
},
closeShowPane() {
@@ -529,6 +535,23 @@ export default {
});
},
checkErrors() {
this.errors = [];
if (this.flag.dirty) {
if (this.entity.selected.country === null) {
this.errors.push("Un pays doit être sélectionné.");
}
if (Object.keys(this.entity.selected.city).length === 0) {
this.errors.push("Une ville doit être sélectionnée.");
}
if (!this.entity.selected.isNoAddress) {
if (this.entity.selected.address.street === null || this.entity.selected.address.streetNumber === null) {
this.errors.push("Une adresse doit être sélectionnée.");
}
}
}
},
/*
* Make form ready for new changes
*/

View File

@@ -6,11 +6,13 @@
v-model="value"
:placeholder="$t('select_address')"
:tag-placeholder="$t('create_address')"
:select-label="$t('press_enter_to_select')"
:select-label="$t('multiselect.select_label')"
:deselect-label="$t('create_address')"
:selected-label="$t('multiselect.selected_label')"
@search-change="listenInputSearch"
ref="addressSelector"
@select="selectAddress"
@remove="remove"
name="field"
track-by="id"
label="value"
@@ -55,7 +57,7 @@ import { searchReferenceAddresses, fetchReferenceAddresses } from '../../api.js'
export default {
name: 'AddressSelection',
components: { VueMultiselect },
props: ['entity', 'context', 'updateMapCenter'],
props: ['entity', 'context', 'updateMapCenter', 'flag', 'checkErrors'],
data() {
return {
value: this.context.edit ? this.entity.address.addressReference : null,
@@ -108,6 +110,13 @@ export default {
this.entity.selected.address.streetNumber = value.streetNumber;
this.entity.selected.writeNew.address = false;
this.updateMapCenter(value.point);
this.flag.dirty = true;
this.checkErrors();
},
remove() {
this.flag.dirty = true;
this.entity.selected.address = {};
this.checkErrors();
},
listenInputSearch(query) {
//console.log('listenInputSearch', query, this.isAddressSelectorOpen);
@@ -148,6 +157,8 @@ export default {
this.entity.selected.address.street = addr.street;
this.entity.selected.address.streetNumber = addr.number;
this.entity.selected.writeNew.address = true;
this.flag.dirty = true;
this.checkErrors();
}
},
splitAddress(address) {

View File

@@ -7,13 +7,15 @@
@search-change="listenInputSearch"
ref="citySelector"
@select="selectCity"
@remove="remove"
name="field"
track-by="id"
label="value"
:custom-label="transName"
:placeholder="$t('select_city')"
:select-label="$t('press_enter_to_select')"
:select-label="$t('multiselect.select_label')"
:deselect-label="$t('create_postal_code')"
:selected-label="$t('multiselect.selected_label')"
:taggable="true"
:multiple="false"
@tag="addPostcode"
@@ -54,12 +56,12 @@ import { searchCities, fetchCities } from '../../api.js';
export default {
name: 'CitySelection',
components: { VueMultiselect },
props: ['entity', 'context', 'focusOnAddress', 'updateMapCenter'],
props: ['entity', 'context', 'focusOnAddress', 'updateMapCenter', 'flag', 'checkErrors'],
emits: ['getReferenceAddresses'],
data() {
return {
value: this.context.edit ? this.entity.address.postcode : null,
isLoading: false
isLoading: false,
}
},
computed: {
@@ -122,6 +124,13 @@ export default {
if (value.center) {
this.updateMapCenter(value.center);
}
this.flag.dirty = true;
this.checkErrors();
},
remove() {
this.flag.dirty = true;
this.entity.selected.city = {};
this.checkErrors();
},
listenInputSearch(query) {
if (query.length > 2) {

View File

@@ -5,13 +5,16 @@
id="countrySelect"
label="name"
track-by="id"
v-bind:custom-label="transName"
v-bind:placeholder="$t('select_country')"
v-bind:options="sortedCountries"
:custom-label="transName"
:placeholder="$t('select_country')"
:options="sortedCountries"
v-model="value"
:select-label="$t('press_enter_to_select')"
:deselect-label="$t('press_enter_to_remove')"
@select="selectCountry">
:select-label="$t('multiselect.select_label')"
:deselect-label="$t('multiselect.deselect_label')"
:selected-label="$t('multiselect.selected_label')"
@select="selectCountry"
@remove="remove"
>
</VueMultiselect>
</div>
</template>
@@ -22,7 +25,7 @@ import VueMultiselect from 'vue-multiselect';
export default {
name: 'CountrySelection',
components: { VueMultiselect },
props: ['context', 'entity'],
props: ['context', 'entity', 'flag', 'checkErrors'],
emits: ['getCities'],
data() {
return {
@@ -33,14 +36,13 @@ export default {
},
computed: {
sortedCountries() {
//console.log('sorted countries');
const countries = this.entity.loaded.countries;
let sortedCountries = [];
sortedCountries.push(...countries.filter(c => c.countryCode === 'FR'))
sortedCountries.push(...countries.filter(c => c.countryCode === 'BE'))
sortedCountries.push(...countries.filter(c => c.countryCode !== 'FR').filter(c => c.countryCode !== 'BE'))
return sortedCountries;
}
},
},
mounted() {
this.init();
@@ -49,6 +51,7 @@ export default {
init() {
if (this.value !== undefined) {
this.selectCountry(this.value);
this.flag.dirty = false;
}
},
selectCountryByCode(countryCode) {
@@ -61,7 +64,13 @@ export default {
//console.log('select country', value);
this.entity.selected.country = value;
this.$emit('getCities', value);
}
this.checkErrors();
},
remove() {
this.flag.dirty = true;
this.entity.selected.country = null;
this.checkErrors();
},
}
};

View File

@@ -7,6 +7,12 @@
<span class="sr-only">Loading...</span>
</div>
<div v-if="errors.length" class="alert alert-warning" >
<ul>
<li v-for="(e, i) in errors" :key="i">{{ e }}</li>
</ul>
</div>
<h4 class="h3">{{ $t('select_an_address_title') }}</h4>
<div class="row my-3">
<div class="col-lg-6">
@@ -25,6 +31,8 @@
<country-selection
v-bind:context="context"
v-bind:entity="entity"
v-bind:flag="flag"
v-bind:checkErrors="checkErrors"
@getCities="$emit('getCities', selected.country)">
</country-selection>
@@ -33,13 +41,17 @@
v-bind:context="context"
v-bind:focusOnAddress="focusOnAddress"
v-bind:updateMapCenter="updateMapCenter"
v-bind:flag="flag"
v-bind:checkErrors="checkErrors"
@getReferenceAddresses="$emit('getReferenceAddresses', selected.city)">
</city-selection>
<address-selection v-if="!isNoAddress"
v-bind:entity="entity"
v-bind:context="context"
v-bind:updateMapCenter="updateMapCenter">
v-bind:updateMapCenter="updateMapCenter"
v-bind:flag="flag"
v-bind:checkErrors="checkErrors">
</address-selection>
</div>
@@ -99,7 +111,9 @@ export default {
'flag',
'entity',
'errorMsg',
'insideModal'
'insideModal',
'errors',
'checkErrors',
],
emits: ['getCities', 'getReferenceAddresses'],
data() {
@@ -128,7 +142,7 @@ export default {
get() {
return this.entity.selected.isNoAddress;
}
}
},
},
methods: {
focusOnAddress() {

View File

@@ -10,7 +10,7 @@
<h4 class="h3">{{ $t('address_suggestions') }}</h4>
<div class="flex-table AddressSuggestionList">
<div v-for="a in context.suggestions" class="item-bloc">
<div v-for="(a, i) in context.suggestions" class="item-bloc" :key="`suggestions-${i}`">
<div class="float-button bottom">
<div class="box">
<div class="action">

View File

@@ -1,7 +1,7 @@
import { multiSelectMessages } from 'ChillMainAssets/vuejs/_js/i18n'
const addressMessages = {
fr: {
press_enter_to_select: 'Appuyer sur Entrée pour sélectionner',
press_enter_to_remove: 'Appuyer sur Entrée pour désélectionner',
add_an_address_title: 'Créer une adresse',
edit_an_address_title: 'Modifier une adresse',
create_a_new_address: 'Créer une nouvelle adresse',
@@ -48,6 +48,8 @@ const addressMessages = {
}
};
Object.assign(addressMessages.fr, multiSelectMessages.fr);
export {
addressMessages
};

View File

@@ -5,6 +5,7 @@
:action="context.action"
:buttonText="options.buttonText"
:displayBadge="options.displayBadge === 'true'"
:parent="options.parent"
@saveFormOnTheFly="saveFormOnTheFly">
</on-the-fly>
</template>

View File

@@ -23,25 +23,37 @@
<template v-slot:body v-if="type === 'person'">
<on-the-fly-person
v-bind:id="id"
v-bind:type="type"
v-bind:action="action"
:id="id"
:type="type"
:action="action"
ref="castPerson">
</on-the-fly-person>
<div v-if="hasResourceComment">
<h3>{{ $t('onthefly.resource_comment_title') }}</h3>
<blockquote class="chill-user-quote">
{{ parent.comment }}
</blockquote>
</div>
</template>
<template v-slot:body v-else-if="type === 'thirdparty'">
<on-the-fly-thirdparty
v-bind:id="id"
v-bind:type="type"
v-bind:action="action"
:id="id"
:type="type"
:action="action"
ref="castThirdparty">
</on-the-fly-thirdparty>
<div v-if="hasResourceComment">
<h3>{{ $t('onthefly.resource_comment_title') }}</h3>
<blockquote class="chill-user-quote">
{{ parent.comment }}
</blockquote>
</div>
</template>
<template v-slot:body v-else>
<on-the-fly-create
v-bind:action="action"
:action="action"
ref="castNew">
</on-the-fly-create>
</template>
@@ -78,18 +90,25 @@ export default {
OnTheFlyThirdparty,
OnTheFlyCreate
},
props: ['type', 'id', 'action', 'buttonText', 'displayBadge'],
props: ['type', 'id', 'action', 'buttonText', 'displayBadge', 'parent', 'canCloseModal'],
emits: ['saveFormOnTheFly'],
data() {
return {
modal: {
showModal: false,
modalDialogClass: "modal-dialog-scrollable modal-xl"
},
//action: this.action
}
}
},
computed: {
hasResourceComment() {
//console.log('hasResourceComment', this.parent);
return (typeof this.parent !== 'undefined' && this.parent !== null)
&& this.action === 'show'
&& this.parent.type === 'accompanying_period_resource'
&& (this.parent.comment !== null && this.parent.comment !== '')
;
},
classAction() {
switch (this.action) {
case 'show':
@@ -143,10 +162,23 @@ export default {
return 'entity-' + this.type + ' badge-' + this.type;
}
},
watch: {
canCloseModal: {
handler: function(val, oldVal) {
if (val) {
this.closeModal();
}
},
deep: true
}
},
methods: {
closeModal() {
this.modal.showModal = false;
},
openModal() {
console.log('## OPEN ON THE FLY MODAL');
console.log('## type:', this.type, ', action:', this.action);
//console.log('## OPEN ON THE FLY MODAL');
//console.log('## type:', this.type, ', action:', this.action);
this.modal.showModal = true;
this.$nextTick(function() {
//this.$refs.search.focus();
@@ -181,8 +213,6 @@ export default {
// pass datas to parent
this.$emit('saveFormOnTheFly', { type: type, data: data });
this.modal.showModal = false;
},
buildLocation(id, type) {
if (type === 'person') {

View File

@@ -17,6 +17,7 @@ const ontheflyMessages = {
person: "un nouvel usager",
thirdparty: "un nouveau tiers professionnel"
},
resource_comment_title: "Un commentaire est associé à cet interlocuteur"
}
}
}

View File

@@ -21,7 +21,8 @@ containers.forEach((container) => {
},
options: {
buttonText: container.dataset.buttonText || null,
displayBadge: container.dataset.displayBadge || false
displayBadge: container.dataset.displayBadge || false,
parent: JSON.parse(container.dataset.parent) || null,
}
}
}

View File

@@ -0,0 +1,89 @@
<template>
<ul class="list-suggest remove-items">
<li v-for="p in picked" @click="removeEntity(p)" :key="p.type+p.id">
<span class="chill_denomination">{{ p.text }}</span>
</li>
</ul>
<ul class="record_actions">
<li>
<AddPersons
:options="addPersonsOptions"
:key="uniqid"
:buttonTitle="translatedListOfTypes"
:modalTitle="translatedListOfTypes"
ref="addPersons"
@addNewPersons="addNewEntity"
>
</AddPersons>
</li>
</ul>
</template>
<script>
import AddPersons from "ChillPersonAssets/vuejs/_components/AddPersons.vue";
import { appMessages } from "./i18n";
export default {
name: "PickEntity",
props: {
multiple: {
type: Boolean,
required: true,
},
types: {
type: Array,
required: true,
},
picked: {
required: true,
},
uniqid: {
type: String,
required: true,
}
},
emits: ['addNewEntity', 'removeEntity'],
components: {
AddPersons,
},
data() {
return {
key: ''
};
},
computed: {
addPersonsOptions() {
return {
uniq: !this.multiple,
type: this.types,
priority: null,
button: {
size: 'btn-sm',
class: 'btn-submit',
},
};
},
translatedListOfTypes() {
let trans = [];
this.types.forEach(t => {
trans.push(appMessages.fr.pick_entity[t].toLowerCase());
})
return appMessages.fr.pick_entity.modal_title + trans.join(', ');
}
},
methods: {
addNewEntity({ selected, modal }) {
selected.forEach((item) => {
this.$emit('addNewEntity', item.result);
}, this
);
this.$refs.addPersons.resetSearch(); // to cast child method
modal.showModal = false;
},
removeEntity(entity) {
console.log('remove entity', entity);
this.$emit('removeEntity', entity);
}
},
}
</script>

View File

@@ -0,0 +1,17 @@
import { personMessages } from 'ChillPersonAssets/vuejs/_js/i18n';
const appMessages = {
fr: {
pick_entity: {
add: 'Ajouter',
modal_title: 'Ajouter des ',
user: 'Utilisateurs',
person: 'Usagers',
thirdparty: 'Tiers',
}
}
}
Object.assign(appMessages.fr, personMessages.fr);
export { appMessages };

View File

@@ -0,0 +1,112 @@
<template>
<div :class="{'btn-group btn-group-sm float-end': isButtonGroup }"
role="group" aria-label="Notification actions">
<button v-if="isRead"
class="btn"
:class="overrideClass"
type="button"
:title="$t('markAsUnread')"
@click="markAsUnread"
>
<i class="fa fa-sm fa-envelope-o"></i>
<span v-if="!buttonNoText" class="ps-2">
{{ $t('markAsUnread') }}
</span>
</button>
<button v-if="!isRead"
class="btn"
:class="overrideClass"
type="button"
:title="$t('markAsRead')"
@click="markAsRead"
>
<i class="fa fa-sm fa-envelope-open-o"></i>
<span v-if="!buttonNoText" class="ps-2">
{{ $t('markAsRead') }}
</span>
</button>
<a v-if="isButtonGroup"
type="button"
class="btn btn-outline-primary"
:href="showUrl"
:title="$t('action.show')"
>
<i class="fa fa-sm fa-eye"></i>
</a>
</div>
</template>
<script>
import { makeFetch } from 'ChillMainAssets/lib/api/apiMethods.js';
export default {
name: "NotificationReadToggle",
props: {
isRead: {
required: true,
type: Boolean,
},
notificationId: {
required: true,
type: Number,
},
// Optional
buttonClass: {
required: false,
type: String
},
buttonNoText: {
required: false,
type: Boolean,
},
showUrl: {
required: false,
type: String
}
},
emits: ['markRead', 'markUnread'],
computed: {
/// [Option] override default button appearance (btn-misc)
overrideClass() {
return this.buttonClass ? this.buttonClass : 'btn-misc'
},
/// [Option] don't display text on button
buttonHideText() {
return this.buttonNoText;
},
/// [Option] showUrl is href for show page second button.
// When passed, the component return a button-group with 2 buttons.
isButtonGroup() {
return !!this.showUrl
}
},
methods: {
markAsUnread() {
makeFetch('POST', `/api/1.0/main/notification/${this.notificationId}/mark/unread`, []).then(response => {
this.$emit('markRead', { notificationId: this.notificationId });
})
},
markAsRead() {
makeFetch('POST', `/api/1.0/main/notification/${this.notificationId}/mark/read`, []).then(response => {
this.$emit('markUnread', { notificationId: this.notificationId });
})
},
},
i18n: {
messages: {
fr: {
markAsUnread: 'Marquer comme non-lu',
markAsRead: 'Marquer comme lu'
}
}
}
}
</script>
<style lang="scss">
</style>

View File

@@ -84,3 +84,17 @@ const _createI18n = (appMessages) => {
};
export { _createI18n }
export const multiSelectMessages = {
fr: {
multiselect: {
placeholder: 'Choisir',
tag_placeholder: 'Créer un nouvel élément',
select_label: 'Appuyer sur "Entrée" pour sélectionner',
deselect_label: 'Appuyer sur "Entrée" pour désélectionner',
select_group_label: 'Appuyer sur "Entrée" pour sélectionner ce groupe',
deselect_group_label: 'Appuyer sur "Entrée" pour désélectionner ce groupe',
selected_label: 'Sélectionné'
}
}
};

View File

@@ -9,7 +9,7 @@
* with_delimiter bool add a delimiter between fragments
* has_no_address bool
* multiline bool multiline display
* extended_infos bool add extra informations (step, floor, etc.)
* extended_infos bool add extra informations (step, floor, etc.) DEPRECATED
#}
@@ -33,25 +33,9 @@
{{ 'address.consider homeless'|trans }}
</span>
{% else %}
{% if options['extended_infos'] %}
<span class="address{% if options['multiline'] %} multiline{% endif %}{% if options['with_delimiter'] %} delimiter{% endif %}">
<span class="address{% if options['multiline'] %} multiline{% endif %}{% if options['with_delimiter'] %} delimiter{% endif %}">
{{ _self.raw(lines) }}
</span>
{% else %}
<span class="address{% if options['multiline'] %} multiline{% endif %}{% if options['with_delimiter'] %} delimiter{% endif %}">
{% if address.street is not empty %}
<p>{{ streetLine }}</p>
{% endif %}
{% if address.postCode is not empty %}
<p class="postcode">
<span class="code">{{ address.postCode.code }}</span>
<span class="name">{{ address.postCode.name }}</span>
<span class="name">{{ address.distribution }}</span>
</p>
<p class="country">{{ address.postCode.country.name|localize_translatable_string }}</p>
{% endif %}
</span>
{% endif %}
</span>
{% endif %}
{{ _self.validity(address, options) }}
{% endmacro %}
@@ -103,12 +87,12 @@
<span class="name">{{ address.distribution }}</span>
</p>
<p class="country">{{ address.postCode.country.name|localize_translatable_string }}</p>
</div>
</div>
{% endif %}
<div class="noaddress">
{{ 'address.consider homeless'|trans }}
</div>
{% else %}
<div class="address{% if options['multiline'] %} multiline{% endif %}{% if options['with_delimiter'] %} delimiter{% endif %}">
{% if options['with_picto'] %}

View File

@@ -215,3 +215,8 @@
{{ form_widget(form.center) }}
{% endif %}
{% endblock %}
{% block pick_user_dynamic_widget %}
<input type="hidden" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value }}" {% endif %} data-input-uniqid="{{ form.vars['uniqid'] }}"/>
<div data-module="pick-dynamic" data-types="{{ form.vars['types']|json_encode }}" data-multiple="{{ form.vars['multiple'] }}" data-uniqid="{{ form.vars['uniqid'] }}"></div>
{% endblock %}

View File

@@ -0,0 +1,88 @@
<div class="item-bloc {% if not notification.isReadBy(app.user) %}unread{% else %}read{% endif %}">
<div class="item-row title">
<h2 class="notification-title">
<a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': notification.id}) }}">
{{ notification.title }}
</a>
</h2>
</div>
<div class="item-row mt-2 header">
<div class="item-col">
<ul class="small_in_title">
{% if step is not defined or step == 'inbox' %}
<li class="notification-from">
<span class="item-key">
<abbr title="{{ 'notification.received_from'|trans }}">
{{ 'notification.from'|trans }} :
</abbr>
</span>
{% if not notification.isSystem %}
<span class="badge-user">
{{ notification.sender|chill_entity_render_string }}
</span>
{% else %}
<span class="badge-user system">{{ 'notification.is_system'|trans }}</span>
{% endif %}
</li>
{% endif %}
{% if notification.addressees|length > 0 %}
<li class="notification-to">
<span class="item-key">
<abbr title="{{ 'notification.sent_to'|trans }}">
{{ 'notification.to'|trans }} :
</abbr>
</span>
{% for a in notification.addressees %}
<span class="badge-user">
{{ a|chill_entity_render_string }}
</span>
{% endfor %}
</li>
{% endif %}
</ul>
</div>
<div class="item-col">
{{ notification.date|format_datetime('long', 'short') }}
</div>
</div>
<div class="item-row separator">
<div class="mx-3 flex-grow-1">
{% include data.template with data.template_data %}
</div>
</div>
<div class="item-row row">
<div>
<blockquote class="chill-user-quote">
{{ notification.message|u.truncate(250, '…', false)|chill_markdown_to_html }}
</blockquote>
</div>
</div>
{% if action_button is not defined or action_button != 'false' %}
<div class="item-row separator">
<ul class="record_actions">
<li>
{# Vue component #}
<span class="notification_toggle_read_status"
data-notification-id="{{ notification.id }}"
data-notification-current-is-read="{{ notification.isReadBy(app.user) }}"
></span>
</li>
{% if is_granted('CHILL_MAIN_NOTIFICATION_UPDATE', notification) %}
<li>
<a href="{{ chill_path_add_return_path('chill_main_notification_edit', {'id': notification.id}) }}"
class="btn btn-edit" title="{{ 'Edit'|trans }}"></a>
</li>
{% endif %}
{% if is_granted('CHILL_MAIN_NOTIFICATION_SEE', notification) %}
<li>
<a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': notification.id}) }}"
class="btn btn-show" title="{{ 'Show'|trans }}"></a>
</li>
{% endif %}
</ul>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,46 @@
{% extends "@ChillMain/layout.html.twig" %}
{% block title 'notification.Notify'|trans %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{% endblock %}
{% block content %}
<div class="col-8 notification notification-new">
<h1 class="mb-5">{{ block('title') }}</h1>
{{ form_start(form, { 'attr': { 'id': 'notification' }}) }}
{{ form_row(form.title, { 'label': 'notification.subject'|trans }) }}
{{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }}
{% include handler.template(notification) with handler.templateData(notification) %}
<div class="mb-3 row">
<label class="col-form-label col-sm-4" for="notification_message">{{ form_label(form.message) }}</label>
<div class="col-12">
{{ form_widget(form.message) }}
</div>
</div>
{{ form_end(form) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ chill_return_path_or('chill_main_homepage') }}" class="btn btn-cancel">{{ 'Cancel'|trans|chill_return_path_label }}</a>
</li>
<li>
<button type="submit" form="notification" class="btn btn-save change-icon">
<i class="fa fa-paper-plane fa-fw"></i> {{ 'notification.Send'|trans }}
</button>
</li>
</ul>
</div>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "@ChillMain/layout.html.twig" %}
{% block title 'notification.Edit notification'|trans %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{% endblock %}
{% block content %}
<div class="col-8 notification notification-edit">
<h1 class="mb-5">{{ block('title') }}</h1>
{{ form_start(form, { 'attr': { 'id': 'notification' }}) }}
{{ form_row(form.title, { 'label': 'notification.subject'|trans }) }}
{{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }}
{% include handler.template(notification) with handler.templateData(notification) %}
<div class="mb-3 row">
<label class="col-form-label col-sm-4" for="notification_message">{{ form_label(form.message) }}</label>
<div class="col-12">
{{ form_widget(form.message) }}
</div>
</div>
{{ form_end(form) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ chill_return_path_or('chill_main_homepage') }}" class="btn btn-cancel">{{ 'Cancel'|trans|chill_return_path_label }}</a>
</li>
<li>
<button type="submit" form="notification" class="btn btn-save">{{ 'Save'|trans }}</button>
</li>
</ul>
</div>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{{ dest.label }},
{{ notification.sender.label }} a créé une notification pour vous:
> {{ notification.title }}
>
>
{%- for line in notification.message|split("\n") %}
> {{ line }}
{%- if not loop.last %}
>
{%- endif %}
{%- endfor %}
Vous pouvez visualiser la notification et y répondre ici:
{{ absolute_url(path('chill_main_notification_show', {'_locale': 'fr', 'id': notification.id }, false)) }}
--
Le logiciel Chill

View File

@@ -0,0 +1,19 @@
{{ dest.label }},
{{ comment.createdBy.label }} a créé un commentaire sur la notification "{{ comment.notification.title }}".
Commentaire:
{% for line in comment.content|split("\n") %}
> {{ line }}
{%- if not loop.last %}
>
{%- endif %}
{%- endfor %}
Vous pouvez visualiser la notification et y répondre ici:
{{ absolute_url(path('chill_main_notification_show', {'_locale': 'fr', 'id': comment.notification.id }, false)) }}
--
Le logiciel Chill

View File

@@ -0,0 +1,45 @@
<div class="list-group my-2 notification notification-box">
<div class="list-group-item">
<h4>{{ 'notification.Sent'|trans }}</h4>
</div>
{# TODO pagination or limit #}
{% for notification in notifications %}
<div class="list-group-item {% if notification.isReadBy(app.user) %}read{% else %}unread{% endif %}">
{% if not notification.isSystem %}
{% if notification.sender == app.user %}
<h6 class="notification-title">
<abbr title="{{ 'Le ' ~ notification.date|format_date('long') ~ '\n' ~ notification.title }}">
{{ notification.date|format_datetime('short','short') }}
</abbr>
{# Vue component #}
<span class="notification_toggle_read_status"
data-notification-id="{{ notification.id }}"
data-notification-current-is-read="{{ notification.isReadBy(app.user) }}"
data-show-button-url="{{ chill_path_add_return_path('chill_main_notification_show', {'id': notification.id}) }}"
data-button-class="btn-outline-primary"
data-button-text="false"
></span>
</h6>
{% if notification.addressees|length > 0 %}
<abbr title="{{ 'notification.sent_to'|trans }}">{{ 'notification.to'|trans }}:</abbr>
{% endif %}
{% for a in notification.addressees %}
<span class="badge-user">
{{ a|chill_entity_render_string }}
</span>
{% endfor %}
{% else %}
<div>{{ 'notification.you were notified by %sender%'|trans({'%sender%': notification.sender|chill_entity_render_string }) }}</div>
{% endif %}
{% else %}
<div>{{ 'notification.you were notified by system'|trans }}</div>
{% endif %}
</div>
{% endfor %}
</div>

View File

@@ -0,0 +1,57 @@
{% extends "@ChillMain/layout.html.twig" %}
{% block title 'notification.My own notifications'|trans %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_notification_toggle_read_status') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_notification_toggle_read_status') }}
{% endblock %}
{% block content %}
<div class="col-10 notification notification-list">
<h1>{{ block('title') }}</h1>
<ul class="nav nav-pills justify-content-center">
<li class="nav-item">
<a class="nav-link {% if step == 'inbox' %}active{% endif %}" href="{{ path('chill_main_notification_my') }}">
{{ 'notification.Notifications received'|trans }}
{% if unreads['inbox'] > 0 %}
<span class="badge rounded-pill bg-danger">
{{ unreads['inbox'] }}
</span>
{% endif %}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if step == 'sent' %}active{% endif %}" href="{{ path('chill_main_notification_sent') }}">
{{ 'notification.Notifications sent'|trans }}
{% if unreads['sent'] > 0 %}
<span class="badge rounded-pill bg-danger">
{{ unreads['sent'] }}
</span>
{% endif %}
</a>
</li>
</ul>
{% if datas|length == 0 %}
{% if step == 'inbox' %}
<p class="chill-no-data-statement">{{ 'notification.Any notification received'|trans }}</p>
{% else %}
<p class="chill-no-data-statement">{{ 'notification.Any notification sent'|trans }}</p>
{% endif %}
{% else %}
<div class="flex-table">
{% for data in datas %}
{% set notification = data.notification %}
{% include 'ChillMainBundle:Notification:_list_item.html.twig' %}
{% endfor %}
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -1,42 +1,121 @@
{% extends "@ChillMain/layout.html.twig" %}
{% block title 'notification.show notification from %sender%'|trans(
{ '%sender%': notification.sender|chill_entity_render_string }
) ~ ' ' ~ notification.title %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_notification_toggle_read_status') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_notification_toggle_read_status') }}
{% endblock %}
{% import '@ChillPerson/AccompanyingCourse/Comment/macro_showItem.html.twig' as m %}
{% macro recordAction(comment) %}
{% if is_granted('CHILL_MAIN_NOTIFICATION_COMMENT_EDIT', comment) %}
<li>
<a href="{{ chill_path_forward_return_path('chill_main_notification_show', {
'_fragment': 'comment-' ~ comment.id,
'edit': comment.id,
'id': comment.notification.id
}) }}" class="btn btn-edit" title="{{ 'Edit'|trans }}"
></a>
</li>
{% endif %}
{% endmacro %}
{% block content %}
<div id="container content">
<div class="grid-8 centered">
<h1>{{ "Notifications list" | trans }}</h1>
<!-- TODO : UNREAD & READ -->
<div class="col-10 notification notification-show">
{%for data in datas %}
{% set notification = data.notification %}
<h1>{{ 'notification.Notification'|trans }}</h1>
<dl class="chill_view_data">
<dt class="inline">{{ 'Message'|trans }}</dt>
<dd>{{ notification.message }}</dd>
</dl>
<div class="flex-table">
{% include 'ChillMainBundle:Notification:_list_item.html.twig' with {
'data': {
'template': handler.getTemplate(notification),
'template_data': handler.getTemplateData(notification)
},
'action_button': 'false'
} %}
</div>
<dl class="chill_view_data">
<dt class="inline">{{ 'Date'|trans }}</dt>
<dd>{{ notification.date | date('long') }}</dd>
</dl>
<div class="notification-comment-list my-5">
<h2 class="chill-blue">{{ 'notification.comments_list'|trans }}</h2>
{% if notification.comments|length > 0 %}
<div class="flex-table">
{% for comment in notification.comments %}
<dl class="chill_view_data">
<dt class="inline">{{ 'Sender'|trans }}</dt>
<dd>{{ notification.sender }}</dd>
</dl>
{% if editedCommentForm is null or editedCommentId != comment.id %}
{{ m.show_comment(comment, {
'recordAction': _self.recordAction(comment)
}) }}
{% else %}
<div class="item-bloc">
<div class="item-row row">
<a id="comment-{{ comment.id }}"></a>
<dl class="chill_view_data">
<dt class="inline">{{ 'Addressees'|trans }}</dt>
<dd>{{ notification.addressees |join(', ') }}</dd>
</dl>
{{ form_start(editedCommentForm) }}
{{ form_errors(editedCommentForm) }}
{{ form_widget(editedCommentForm.content) }}
<input type="hidden" name="form" value="edit" />
<ul class="record_actions">
<li class="cancel">
<a href="{{ chill_path_forward_return_path('chill_main_notification_show', {
'_fragment': 'comment-' ~ comment.id,
'id': notification.id }) }}" class="btn btn-cancel">
{{ 'cancel'|trans }}
</a>
</li>
<li>
<button class="btn btn-save" type="submit">{{ 'Save'|trans }}</button>
</li>
</ul>
{{ form_end(editedCommentForm) }}
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
<div class="new-comment my-5">
<h2 class="chill-blue">{{ 'Write a new comment'|trans }}</h2>
{{ form_start(appendCommentForm) }}
{{ form_errors(appendCommentForm) }}
{{ form_widget(appendCommentForm.content) }}
<input type="hidden" name="form" value="append" />
<ul class="record_actions">
<li>
<button class="btn btn-create" type="submit">{{ 'notification.append_comment'|trans }}</button>
</li>
</ul>
{{ form_end(appendCommentForm) }}
<dl class="chill_view_data">
<dt class="inline">{{ 'Entity'|trans }}</dt>
<dd>
{% include data.template with data.template_data %}
</dd>
</dl>
{% endfor %}
</div>
</div>
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ chill_return_path_or('chill_main_notification_my') }}" class="btn btn-cancel">
{{ 'Cancel'|trans|chill_return_path_label }}
</a>
</li>
<li>
{# Vue component #}
<span class="notification_toggle_read_status"
data-notification-id="{{ notification.id }}"
data-notification-current-is-read="1"
></span>
</li>
</ul>
</div>
{% endblock content %}

View File

@@ -10,6 +10,7 @@
* action string 'show', 'edit', 'create'
* buttonText string
* displayBadge boolean (default: false) replace button by badge, need to define buttonText for content
* parent object (optional) pass parent context of the targetEntity (used for course resource comment)
#}
<span class="onthefly-container"
@@ -31,6 +32,10 @@
data-display-badge="true"
{% endif %}
{% if parent is defined %}
data-parent='{{ parent|json_encode }}'
{% endif %}
></span>
{{ encore_entry_script_tags('vue_onthefly') }}

View File

@@ -6,14 +6,20 @@
<p class="message-confirm">{{ confirm_question }}</p>
{% endif %}
{% if display_content is defined and display_content is not empty %}
{{ display_content|raw }}
{% endif %}
{{ form_start(form) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ path(cancel_route, cancel_parameters|default( { } ) ) }}" class="btn btn-cancel">
{{ 'Cancel'|trans }}
</a>
</li>
{% if cancel_route is defined %}
<li class="cancel">
<a href="{{ chill_path_forward_return_path(cancel_route, cancel_parameters|default( { } ) ) }}" class="btn btn-cancel">
{{ 'Cancel'|trans }}
</a>
</li>
{% endif %}
<li>
{{ form_widget(form.submit, { 'attr' : { 'class' : "btn btn-delete" } } ) }}
</li>

View File

@@ -106,6 +106,6 @@
});
</script>
{% block js%}<!-- nothing added to js -->{% endblock %}
{% block js %}<!-- nothing added to js -->{% endblock %}
</body>
</html>

View File

@@ -12,16 +12,27 @@ declare(strict_types=1);
namespace Chill\MainBundle\Routing\MenuBuilder;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Notification\Counter\NotificationByUserCounter;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Translation\TranslatorInterface;
class UserMenuBuilder implements LocalMenuBuilderInterface
{
private NotificationByUserCounter $notificationByUserCounter;
private Security $security;
public function __construct(Security $security)
{
private TranslatorInterface $translator;
public function __construct(
NotificationByUserCounter $notificationByUserCounter,
Security $security,
TranslatorInterface $translator
) {
$this->notificationByUserCounter = $notificationByUserCounter;
$this->security = $security;
$this->translator = $translator;
}
public function buildMenu($menuId, \Knp\Menu\MenuItem $menu, array $parameters)
@@ -44,6 +55,20 @@ class UserMenuBuilder implements LocalMenuBuilderInterface
'order' => -9999999,
'icon' => 'map-marker',
]);
$nbNotifications = $this->notificationByUserCounter->countUnreadByUser($user);
$menu
->addChild(
$this->translator->trans('notification.My notifications with counter', ['nb' => $nbNotifications]),
['route' => 'chill_main_notification_my']
)
->setExtras([
'order' => 600,
'icon' => 'envelope',
'counter' => $nbNotifications,
]);
$menu
->addChild(
'Change password',

View File

@@ -0,0 +1,89 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\MainBundle\Security\Authorization;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\NotificationComment;
use Chill\MainBundle\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use UnexpectedValueException;
final class NotificationVoter extends Voter
{
/**
* Allow to add a comment on a notification.
*
* May apply on both @see{NotificationComment::class} and @see{Notification::class}.
*/
public const COMMENT_ADD = 'CHILL_MAIN_NOTIFICATION_COMMENT_ADD';
public const COMMENT_EDIT = 'CHILL_MAIN_NOTIFICATION_COMMENT_EDIT';
public const NOTIFICATION_SEE = 'CHILL_MAIN_NOTIFICATION_SEE';
public const NOTIFICATION_TOGGLE_READ_STATUS = 'CHILL_MAIIN_NOTIFICATION_TOGGLE_READ_STATUS';
public const NOTIFICATION_UPDATE = 'CHILL_MAIN_NOTIFICATION_UPDATE';
protected function supports($attribute, $subject): bool
{
return $subject instanceof Notification || $subject instanceof NotificationComment;
}
/**
* @param string $attribute
* @param mixed $subject
*/
protected function voteOnAttribute($attribute, $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
if ($subject instanceof Notification) {
switch ($attribute) {
case self::COMMENT_ADD:
return false === $subject->isSystem() && (
$subject->getAddressees()->contains($user) || $subject->getSender() === $user
);
case self::NOTIFICATION_SEE:
case self::NOTIFICATION_TOGGLE_READ_STATUS:
return $subject->getSender() === $user || $subject->getAddressees()->contains($user);
case self::NOTIFICATION_UPDATE:
return $subject->getSender() === $user && false === $subject->isSystem();
default:
throw new UnexpectedValueException("this subject {$attribute} is not implemented");
}
} elseif ($subject instanceof NotificationComment) {
switch ($attribute) {
case self::COMMENT_ADD:
return false === $subject->getNotification()->isSystem() && (
$subject->getNotification()->getAddressees()->contains($user) || $subject->getNotification()->getSender() === $user
);
case self::COMMENT_EDIT:
return $subject->getCreatedBy() === $user && false === $subject->getNotification()->isSystem();
default:
throw new UnexpectedValueException("this subject {$attribute} is not implemented");
}
}
throw new UnexpectedValueException();
}
}

View File

@@ -56,12 +56,11 @@ class AddressNormalizer implements ContextAwareNormalizerInterface, NormalizerAw
/**
* @param Address $address
* @param null|string $format
* @param string|null $format
*/
public function normalize($address, $format = null, array $context = [])
{
if ($address instanceof Address) {
$data = [
'address_id' => $address->getId(),
'text' => $address->isNoAddress() ? null : $this->addressRender->renderStreetLine($address, []),

View File

@@ -22,7 +22,7 @@ class CollectionNormalizer implements NormalizerAwareInterface, NormalizerInterf
/**
* @param Collection $collection
* @param null|string $format
* @param string|null $format
*/
public function normalize($collection, $format = null, array $context = [])
{

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Serializer\Normalizer;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Location;
use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserJob;
@@ -55,16 +56,21 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
$context,
['docgen:expects' => Center::class, 'groups' => 'docgen:read']
);
$locationContext = array_merge(
$context,
['docgen:expects' => Location::class, 'groups' => 'dogen:read']
);
if (null === $user && 'docgen' === $format) {
return array_merge(self::NULL_USER, [
'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),
]);
}
return [
$data = [
'type' => 'user',
'id' => $user->getId(),
'username' => $user->getUsername(),
@@ -75,6 +81,12 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware
'main_center' => $this->normalizer->normalize($user->getMainCenter(), $format, $centerContext),
'main_scope' => $this->normalizer->normalize($user->getMainScope(), $format, $scopeContext),
];
if ('docgen' === $format) {
$data['current_location'] = $this->normalizer->normalize($user->getCurrentLocation(), $format, $locationContext);
}
return $data;
}
public function supportsNormalization($data, $format = null, array $context = []): bool

View File

@@ -14,7 +14,6 @@ namespace Chill\MainBundle\Templating\Entity;
use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Templating\TranslatableStringHelper;
use Symfony\Component\Templating\EngineInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use function array_merge;
use function strtr;
@@ -28,21 +27,18 @@ class AddressRender implements ChillEntityRenderInterface
'with_delimiter' => false,
'has_no_address' => false,
'multiline' => true,
/* deprecated */
'extended_infos' => false,
];
private EngineInterface $templating;
private TranslatableStringHelper $translatableStringHelper;
protected ParameterBagInterface $parameterBag;
public function __construct(
EngineInterface $templating,
TranslatableStringHelper $translatableStringHelper,
ParameterBagInterface $parameterBag
) {
private TranslatableStringHelper $translatableStringHelper;
public function __construct(EngineInterface $templating, TranslatableStringHelper $translatableStringHelper)
{
$this->templating = $templating;
$this->translatableStringHelper = $translatableStringHelper;
$this->parameterBag = $parameterBag;
}
/**
@@ -72,6 +68,7 @@ class AddressRender implements ChillEntityRenderInterface
public function renderLines($addr): array
{
$lines = [];
if (null !== $addr->getPostCode()) {
if ($addr->getPostCode()->getCountry()->getCountryCode() === 'FR') {
$lines[] = $this->renderIntraBuildingLine($addr);
@@ -88,32 +85,19 @@ class AddressRender implements ChillEntityRenderInterface
$lines[] = $this->renderCountryLine($addr);
}
}
return array_values(array_filter($lines, fn ($l) => null !== $l));
return array_values(array_filter($lines, static fn ($l) => null !== $l));
}
/**
* @param Address addr
* @param mixed $addr
*/
public function renderString($addr, array $options): string
public function renderStreetLine(Address $addr): ?string
{
return implode(' - ', $this->renderLines($addr));
}
public function supports($entity, array $options): bool
{
return $entity instanceof Address;
}
public function renderStreetLine($addr): ?string
{
if (!empty($addr->getStreet())) {
if (null !== $addr->getStreet() && $addr->getStreet() !== '') {
$street = $addr->getStreet();
} else {
$street = '';
}
if (!empty($addr->getStreetNumber())) {
if (null !== $addr->getStreetNumber() && $addr->getStreetNumber() !== '') {
$streetNumber = $addr->getStreetNumber();
} else {
$streetNumber = '';
@@ -134,18 +118,30 @@ class AddressRender implements ChillEntityRenderInterface
return $res;
}
private function renderBuildingLine($addr): ?string
/**
* @param Address addr
* @param mixed $addr
*/
public function renderString($addr, array $options): string
{
return implode(' - ', $this->renderLines($addr));
}
if (!empty($addr->getBuildingName())) {
public function supports($entity, array $options): bool
{
return $entity instanceof Address;
}
private function renderBuildingLine(Address $addr): ?string
{
if (null !== $addr->getBuildingName() && $addr->getBuildingName() !== '') {
$building = $addr->getBuildingName();
} else {
$building = '';
}
$intraBuilding = $this->renderIntraBuildingLine($addr);
if (!empty($this->renderIntraBuildingLine($addr))) {
$intraBuilding = $this->renderIntraBuildingLine($addr);
} else {
if (null === $intraBuilding) {
$intraBuilding = '';
}
@@ -166,36 +162,29 @@ class AddressRender implements ChillEntityRenderInterface
private function renderCityLine($addr): string
{
if (!empty($addr->getPostcode())) {
if (null !== $addr->getPostcode()) {
$res = strtr('{postcode} {label}', [
'{postcode}' => $addr->getPostcode()->getCode(),
'{label}' => $addr->getPostcode()->getName(),
]);
}
if (null !== $addr->getPostCode()->getCountry()->getCountryCode()) {
if ($addr->getPostCode()->getCountry()->getCountryCode() === 'FR') {
if ($addr->getDistribution()) {
$res = $res . ' ' . $addr->getDistribution();
if (null !== $addr->getPostCode()->getCountry()->getCountryCode()) {
if ($addr->getPostCode()->getCountry()->getCountryCode() === 'FR') {
if ($addr->getDistribution()) {
$res = $res . ' ' . $addr->getDistribution();
}
}
}
}
return $res;
return $res ?? '';
}
private function renderCountryLine($addr): ?string
{
$preferredCountries = $this->parameterBag->get('chill_main.available_countries');
if (in_array($addr->getPostCode()->getCountry()->getCountryCode(), $preferredCountries)) {
$res = null;
} else {
$res = $this->translatableStringHelper->localize(
$addr->getPostCode()->getCountry()->getName()
);
}
return $res;
return $this->translatableStringHelper->localize(
$addr->getPostCode()->getCountry()->getName()
);
}
private function renderDeliveryLine($addr): ?string
@@ -224,10 +213,11 @@ class AddressRender implements ChillEntityRenderInterface
}
$res = implode(' - ', $arr);
if ('' === $res) {
$res = null;
}
return $res;
}
}

View File

@@ -0,0 +1,98 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Controller;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\UserRepository;
use Chill\MainBundle\Test\PrepareClientTrait;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
* @coversNothing
*/
final class NotificationApiControllerTest extends WebTestCase
{
use PrepareClientTrait;
private array $toDelete = [];
protected function tearDown(): void
{
$em = self::$container->get(EntityManagerInterface::class);
foreach ($this->toDelete as [$className, $id]) {
$object = $em->find($className, $id);
$em->remove($object);
}
$em->flush();
}
public function generateDataMarkAsRead()
{
self::bootKernel();
$em = self::$container->get(EntityManagerInterface::class);
$userRepository = self::$container->get(UserRepository::class);
$userA = $userRepository->findOneBy(['username' => 'center a_social']);
$userB = $userRepository->findOneBy(['username' => 'center b_social']);
$notification = new Notification();
$notification
->setMessage('Test generated')
->setRelatedEntityClass(AccompanyingPeriod::class)
->setRelatedEntityId(0)
->setSender($userB)
->addAddressee($userA)
->setUpdatedAt(new DateTimeImmutable());
$em->persist($notification);
$em->refresh($notification);
$em->flush();
$this->toDelete[] = [Notification::class, $notification->getId()];
yield [$notification->getId()];
}
/**
* @dataProvider generateDataMarkAsRead
*/
public function testMarkAsReadOrUnRead(int $notificationId)
{
$client = $this->getClientAuthenticated();
$client->request('POST', "/api/1.0/main/notification/{$notificationId}/mark/read");
$this->assertResponseIsSuccessful('test marking as read');
$em = self::$container->get(EntityManagerInterface::class);
/** @var Notification $notification */
$notification = $em->find(Notification::class, $notificationId);
$user = self::$container->get(UserRepository::class)->findOneBy(['username' => 'center a_social']);
$this->assertTrue($notification->isReadBy($user));
$client->request('POST', "/api/1.0/main/notification/{$notificationId}/mark/unread");
$this->assertResponseIsSuccessful('test marking as unread');
$notification = $em->find(Notification::class, $notificationId);
$user = $em->find(User::class, $user->getId());
$em->refresh($notification);
$em->refresh($user);
$this->assertFalse($notification->isReadBy($user));
}
}

View File

@@ -0,0 +1,125 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Entity;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\UserRepository;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use function count;
/**
* @internal
* @coversNothing
*/
final class NotificationTest extends KernelTestCase
{
private array $toDelete = [];
protected function setUp(): void
{
self::bootKernel();
}
protected function tearDown(): void
{
$em = self::$container->get(EntityManagerInterface::class);
foreach ($this->toDelete as [$className, $id]) {
$object = $em->find($className, $id);
$em->remove($object);
}
$em->flush();
}
public function generateNotificationData()
{
self::bootKernel();
$userRepository = self::$container->get(UserRepository::class);
$senderId = $userRepository
->findOneBy(['username' => 'center b_social'])
->getId();
$addressesIds = [];
$addressesIds[] = $userRepository
->findOneBy(['username' => 'center b_direction'])
->getId();
yield [
$senderId,
$addressesIds,
];
}
public function testAddAddresseeStoreAnUread()
{
$notification = new Notification();
$notification->addAddressee($user1 = new User());
$notification->addAddressee($user2 = new User());
$notification->getAddressees()->add($user3 = new User());
$notification->getAddressees()->add($user4 = new User());
$this->assertCount(4, $notification->getAddressees());
// launch listener
$notification->registerUnread();
$this->assertCount(4, $notification->getUnreadBy());
$this->assertContains($user1, $notification->getUnreadBy()->toArray());
$this->assertContains($user2, $notification->getUnreadBy()->toArray());
$this->assertContains($user3, $notification->getUnreadBy()->toArray());
$notification->markAsReadBy($user1);
$this->assertCount(3, $notification->getUnreadBy());
$this->assertNotContains($user1, $notification->getUnreadBy()->toArray());
}
/**
* @dataProvider generateNotificationData
*/
public function testPrePersistComputeUnread(int $senderId, array $addressesIds)
{
$em = self::$container->get(EntityManagerInterface::class);
$notification = new Notification();
$notification
->setSender($em->find(User::class, $senderId))
->setRelatedEntityId(0)
->setRelatedEntityClass(AccompanyingPeriod::class)
->setUpdatedAt(new DateTimeImmutable())
->setMessage('Fake message');
foreach ($addressesIds as $addresseeId) {
$notification
->getAddressees()->add($em->find(User::class, $addresseeId));
}
$em->persist($notification);
$em->flush();
$em->refresh($notification);
$this->toDelete[] = [Notification::class, $notification->getId()];
$this->assertEquals($senderId, $notification->getSender()->getId());
$this->assertCount(count($addressesIds), $notification->getUnreadBy());
$unreadIds = $notification->getUnreadBy()->map(static function (User $u) { return $u->getId(); });
foreach ($addressesIds as $addresseeId) {
$this->assertContains($addresseeId, $unreadIds);
}
}
}

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