Merge branch 'master' into issue394_address_confidential

This commit is contained in:
Julien Fastré 2022-01-27 15:39:10 +01:00
commit 7c8b08c3a7
222 changed files with 9243 additions and 1925 deletions

View File

@ -13,18 +13,46 @@ and this project adheres to
<!-- write down unreleased development here -->
* [main] increase length of 4 Address fields (change to TEXT, no size limits) (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/277)
* [main] Add confidential option for address, in edit and view (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/165)
* [person] name suggestions within create person form when person is created departing from a search input (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/377)
* [parcours] bugfix if deathdate is not defined (eg. for a thirdparty) parcours is still displayed. Gave error before.
## Test releases
### test release 2021-01-26
* [parcours] comments truncated if too long + link added (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/406)
* [person]: possibility to add person resources (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/382)
* [person ressources]: module added
* [parcours] bugfix if deathdate is not defined (eg. for a thirdparty) parcours is still displayed. Gave error before.
* dispatching list
### test release 2022-01-24
* [person] name suggestions within create person form when person is created departing from a search input (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/377)
* [notification: formulaire création] descend la box avec la description dans le bas du formulaire
* [notification for activity]: fix link to activity
* [notification] add "URGENT" before accompanying course with emergency = true
* [notification] add a "read more" button on system notification
* [notification] add `[Chill]` in the subject of each notification, automatically
* [notification] add a counter for notification in activity list and accompanying period list, and search results
* [parcours] bugfix if deathdate is not defined (eg. for a thirdparty) parcours is still displayed. Gave error before.
* [workflow] add breadcrumb to show steps
* [popover] add popover html popup mechanism (used by workflow breadcrumb)
* [templates] improve updatedBy macro in item metadatas
* [parcours]: bug fix when comment is pinned all other comments remain in the collection (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/385)
* [workflow]
* add My workflow section with my opened subscriptions
* apply workflow on documents, accompanyingCourseWork and Evaluations
* [wopi-link] a new vue component allow to open wopi link in a fullscreen chill-themed modal
### test release 2022-01-19
* vuejs: add dead information on all on-the-fly person render boxes, in vis graph and other templates (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/271)
* [thirdparty] fix bug in 3rd party view: types was replaced by thirdPartyTypes
* [main] location form type: fix unmapped address field (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/246)
* [activity] fix wrong import of js assets for adding and viewing documents in activity (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/83 & https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/176)
* [person]: space added between deathdate and age in twig renderbox (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/380)
* [forms] dynamic picker types for user/person/thirdparty types created (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/386)
### test release 2022-01-17
@ -34,10 +62,14 @@ and this project adheres to
* [main] Add mainLocation field to User entity and add it in user form type
* [course list in person context] show full username/label for ref
* [accompanying period work] remove the possibility to generate document from an accompanying period work
* vuejs: add validation on required fields for AddPerson, Address and Location components
* vuejs: treat 422 validation errors in locations and AddPerson components
* [person]: space added between deathdate and age in twig renderbox (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/380)
## Test releases
* vuejs: add validation on required fields for AddPerson, Address and Location components
* vuejs: treat 422 validation errors in locations and AddPerson components
* [person]: space added between deathdate and age in twig renderbox (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/380)
### test release 2022-01-12

View File

@ -52,9 +52,6 @@
"twig/string-extra": "^3.3",
"twig/twig": "^3.0"
},
"conflict": {
"symfony/symfony": "*"
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.3",
"drupol/php-conventions": "^5",
@ -71,23 +68,17 @@
"symfony/var-dumper": "^4.4",
"symfony/web-profiler-bundle": "^4.4"
},
"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
}
"conflict": {
"symfony/symfony": "*"
},
"autoload": {
"psr-4": {
"Chill\\ActivityBundle\\": "src/Bundle/ChillActivityBundle",
"Chill\\AsideActivityBundle\\": "src/Bundle/ChillAsideActivityBundle/src",
"Chill\\BudgetBundle\\": "src/Bundle/ChillBudgetBundle",
"Chill\\CalendarBundle\\": "src/Bundle/ChillCalendarBundle",
"Chill\\CustomFieldsBundle\\": "src/Bundle/ChillCustomFieldsBundle",
"Chill\\DocGeneratorBundle\\": "src/Bundle/ChillDocGeneratorBundle",
"Chill\\DocStoreBundle\\": "src/Bundle/ChillDocStoreBundle",
"Chill\\EventBundle\\": "src/Bundle/ChillEventBundle",
"Chill\\FamilyMemberBundle\\": "src/Bundle/ChillFamilyMemberBundle",
@ -96,9 +87,6 @@
"Chill\\ReportBundle\\": "src/Bundle/ChillReportBundle",
"Chill\\TaskBundle\\": "src/Bundle/ChillTaskBundle",
"Chill\\ThirdPartyBundle\\": "src/Bundle/ChillThirdPartyBundle",
"Chill\\AsideActivityBundle\\": "src/Bundle/ChillAsideActivityBundle/src",
"Chill\\DocGeneratorBundle\\": "src/Bundle/ChillDocGeneratorBundle",
"Chill\\CalendarBundle\\": "src/Bundle/ChillCalendarBundle",
"Chill\\WopiBundle\\": "src/Bundle/ChillWopiBundle/src"
}
},
@ -111,10 +99,10 @@
"config": {
"allow-plugins": {
"composer/package-versions-deprecated": true,
"phpstan/extension-installer": true,
"ergebnis/composer-normalize": true,
"ocramius/package-versions": true,
"phpro/grumphp": true,
"ocramius/package-versions": true
"phpstan/extension-installer": true
},
"bin-dir": "bin",
"optimize-autoloader": true,
@ -123,8 +111,8 @@
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
"assets:install %PUBLIC_DIR%": "symfony-cmd",
"cache:clear": "symfony-cmd"
}
}
}

View File

@ -1,3 +0,0 @@
add npm/yarn dependency in package.json :
"select2-bootstrap-theme": "0.1.0-beta.10",

View File

@ -31,6 +31,7 @@ use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@ -38,8 +39,8 @@ use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Role\Role;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Serializer\SerializerInterface;
use function array_key_exists;
final class ActivityController extends AbstractController
@ -471,20 +472,21 @@ final class ActivityController extends AbstractController
public function showAction(Request $request, int $id): Response
{
$view = null;
$entity = $this->activityRepository->find($id);
[$person, $accompanyingPeriod] = $this->getEntity($request);
if (null === $entity) {
throw $this->createNotFoundException('Unable to find Activity entity.');
}
$accompanyingPeriod = $entity->getAccompanyingPeriod();
$person = $entity->getPerson();
if ($accompanyingPeriod instanceof AccompanyingPeriod) {
$view = 'ChillActivityBundle:Activity:showAccompanyingCourse.html.twig';
} elseif ($person instanceof Person) {
$view = 'ChillActivityBundle:Activity:showPerson.html.twig';
}
$entity = $this->activityRepository->find($id);
if (null === $entity) {
throw $this->createNotFoundException('Unable to find Activity entity.');
} else {
throw new RuntimeException('the activity should be linked with a period or person');
}
if (null !== $accompanyingPeriod) {
@ -493,8 +495,7 @@ final class ActivityController extends AbstractController
$entity->personsNotAssociated = $entity->getPersonsNotAssociated();
}
// TODO revoir le Voter de Activity pour tenir compte qu'une activité peut appartenir a une période
// $this->denyAccessUnlessGranted('CHILL_ACTIVITY_SEE', $entity);
$this->denyAccessUnlessGranted(ActivityVoter::SEE, $entity);
$deleteForm = $this->createDeleteForm($entity->getId(), $person, $accompanyingPeriod);

View File

@ -12,7 +12,7 @@ declare(strict_types=1);
namespace Chill\ActivityBundle\Entity;
use Chill\ActivityBundle\Validator\Constraints as ActivityValidator;
use Chill\DocStoreBundle\Entity\Document;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable;
use Chill\MainBundle\Entity\HasCenterInterface;
@ -67,7 +67,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
/**
* @ORM\ManyToOne(targetEntity="Chill\ActivityBundle\Entity\ActivityType")
* @Groups({"read"})
* @Groups({"read", "docgen:read"})
* @SerializedName("activityType")
* @ORM\JoinColumn(name="type_id")
*/
@ -75,16 +75,19 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
/**
* @ORM\ManyToOne(targetEntity="Chill\ActivityBundle\Entity\ActivityPresence")
* @Groups({"docgen:read"})
*/
private ?ActivityPresence $attendee = null;
/**
* @ORM\Embedded(class="Chill\MainBundle\Entity\Embeddable\CommentEmbeddable", columnPrefix="comment_")
* @Groups({"docgen:read"})
*/
private CommentEmbeddable $comment;
/**
* @ORM\Column(type="datetime")
* @Groups({"docgen:read"})
*/
private DateTime $date;
@ -100,6 +103,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
/**
* @ORM\Column(type="boolean", options={"default": false})
* @Groups({"docgen:read"})
*/
private bool $emergency = false;
@ -107,13 +111,13 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
* @ORM\Id
* @ORM\Column(name="id", type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
* @Groups({"read"})
* @Groups({"read", "docgen:read"})
*/
private ?int $id = null;
/**
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Location")
* @groups({"read"})
* @groups({"read", "docgen:read"})
*/
private ?Location $location = null;
@ -124,42 +128,45 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
/**
* @ORM\ManyToMany(targetEntity="Chill\PersonBundle\Entity\Person")
* @Groups({"read"})
* @Groups({"read", "docgen:read"})
*/
private ?Collection $persons = null;
/**
* @ORM\ManyToMany(targetEntity="Chill\ActivityBundle\Entity\ActivityReason")
* @Groups({"docgen:read"})
*/
private Collection $reasons;
/**
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\Scope")
* @Groups({"docgen:read"})
*/
private ?Scope $scope = null;
/**
* @ORM\Column(type="string", options={"default": ""})
* @Groups({"docgen:read"})
*/
private string $sentReceived = '';
/**
* @ORM\ManyToMany(targetEntity="Chill\PersonBundle\Entity\SocialWork\SocialAction")
* @ORM\JoinTable(name="chill_activity_activity_chill_person_socialaction")
* @Groups({"read"})
* @Groups({"read", "docgen:read"})
*/
private Collection $socialActions;
/**
* @ORM\ManyToMany(targetEntity="Chill\PersonBundle\Entity\SocialWork\SocialIssue")
* @ORM\JoinTable(name="chill_activity_activity_chill_person_socialissue")
* @Groups({"read"})
* @Groups({"read", "docgen:read"})
*/
private Collection $socialIssues;
/**
* @ORM\ManyToMany(targetEntity="Chill\ThirdPartyBundle\Entity\ThirdParty")
* @Groups({"read"})
* @Groups({"read", "docgen:read"})
*/
private ?Collection $thirdParties = null;
@ -170,12 +177,13 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
/**
* @ORM\ManyToOne(targetEntity="Chill\MainBundle\Entity\User")
* @Groups({"docgen:read"})
*/
private User $user;
/**
* @ORM\ManyToMany(targetEntity="Chill\MainBundle\Entity\User")
* @Groups({"read"})
* @Groups({"read", "docgen:read"})
*/
private ?Collection $users = null;
@ -191,7 +199,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
$this->socialActions = new ArrayCollection();
}
public function addDocument(Document $document): self
public function addDocument(StoredObject $document): self
{
$this->documents[] = $document;
@ -302,6 +310,18 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
return $this->documents;
}
/**
* @Groups({"docgen:read"})
*/
public function getDurationMinute(): int
{
if (null === $this->durationTime) {
return 0;
}
return (int) round(($this->durationTime->getTimestamp() + $this->durationTime->getOffset()) / 60.0, 0);
}
public function getDurationTime(): ?DateTime
{
return $this->durationTime;
@ -402,6 +422,18 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
return $this->travelTime;
}
/**
* @Groups({"docgen:read"})
*/
public function getTravelTimeMinute(): int
{
if (null === $this->travelTime) {
return 0;
}
return (int) round(($this->travelTime->getTimestamp() + $this->travelTime->getOffset()) / 60.0, 0);
}
/**
* @deprecated
*/
@ -425,7 +457,7 @@ class Activity implements AccompanyingPeriodLinkedWithSocialIssuesEntityInterfac
return $this->getEmergency();
}
public function removeDocument(Document $document): void
public function removeDocument(StoredObject $document): void
{
$this->documents->removeElement($document);
}

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\ActivityBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
/**
* Class ActivityPresence.
@ -31,11 +32,14 @@ class ActivityPresence
* @ORM\Id
* @ORM\Column(name="id", type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
* @Serializer\Groups({"docgen:read"})
*/
private ?int $id;
/**
* @ORM\Column(type="json")
* @Serializer\Groups({"docgen:read"})
* @Serializer\Context({"is-translatable": true}, groups={"docgen:read"})
*/
private array $name = [];

View File

@ -13,6 +13,7 @@ namespace Chill\ActivityBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
@ -118,6 +119,7 @@ class ActivityType
* @ORM\Id
* @ORM\Column(name="id", type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
* @Groups({"docgen:read"})
*/
private ?int $id;
@ -133,7 +135,8 @@ class ActivityType
/**
* @ORM\Column(type="json")
* @Groups({"read"})
* @Groups({"read", "docgen:read"})
* @Serializer\Context({"is-translatable": true}, groups={"docgen:read"})
*/
private array $name = [];

View File

@ -143,9 +143,17 @@
</div>
<div class="item-row separator">
<ul class="record_actions">
{{ recordAction }}
</ul>
<div class="item-col item-meta">
{% set notif_counter = chill_count_notifications('Chill\\ActivityBundle\\Entity\\Activity', activity.id) %}
{% if notif_counter.total > 0 %}
{{ chill_counter_notifications('Chill\\ActivityBundle\\Entity\\Activity', activity.id) }}
{% endif %}
</div>
<div class="item-col">
<ul class="record_actions">
{{ recordAction }}
</ul>
</div>
</div>
</div>

View File

@ -8,7 +8,7 @@
action: 'show', displayBadge: true,
targetEntity: { name: type, id: entity.id },
buttonText: entity|chill_entity_render_string,
isDead: entity.deathdate is not null,
isDead: entity.deathdate is defined and entity.deathdate is not null,
parent: parent
} %}
{% endmacro %}

View File

@ -83,15 +83,15 @@
{{ form_row(edit_form.comment) }}
{% endif %}
{%- if edit_form.documents is defined -%}
{{ form_row(edit_form.documents) }}
{% endif %}
{%- if edit_form.attendee is defined -%}
{{ form_row(edit_form.attendee) }}
{% endif %}
{# TODO .. status #}
{%- if edit_form.documents is defined -%}
{{ form_row(edit_form.documents) }}
{% endif %}
<div data-docgen-template-picker="data-docgen-template-picker" data-entity-class="Chill\ActivityBundle\Entity\Activity" data-entity-id="{{ entity.id }}"></div>
{% set person_id = null %}
{% if entity.person %}

View File

@ -24,10 +24,12 @@
window.activity = {{ activity_json|json_encode|raw }};
</script>
{{ encore_entry_script_tags('vue_activity') }}
{{ encore_entry_script_tags('mod_docgen_picktemplate') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_async_upload') }}
{{ encore_entry_link_tags('vue_activity') }}
{{ encore_entry_link_tags('mod_docgen_picktemplate') }}
{% endblock %}

View File

@ -39,9 +39,11 @@
window.activity = {{ activity_json|json_encode|raw }};
</script>
{{ encore_entry_script_tags('vue_activity') }}
{{ encore_entry_script_tags('mod_docgen_picktemplate') }}
{% endblock %}
{% block css %}
{{ encore_entry_link_tags('mod_async_upload') }}
{{ encore_entry_link_tags('vue_activity') }}
{{ encore_entry_link_tags('mod_docgen_picktemplate') }}
{% endblock %}

View File

@ -1,58 +1,60 @@
{% 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) %}
{% if is_granted('CHILL_ACTIVITY_SEE_DETAILS', activity) %}
{% if no_action is not defined or no_action == false %}
<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>
<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 is_granted('CHILL_ACTIVITY_DELETE', activity) %}
{% 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="{{ path('chill_activity_activity_delete', {'id': activity.id,
'person_id': person_id,
<a href="{{ chill_path_add_return_path('chill_activity_activity_list',{
'accompanying_period_id': accompanying_course_id
}) }}"
class="btn btn-delete"
title="{{ 'Delete'|trans }}"></a>
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 %}
{% endif %}
{% endmacro %}

View File

@ -86,7 +86,7 @@
{% include 'ChillActivityBundle:Activity:concernedGroups.html.twig' with {
'context': context,
'render': 'bloc',
'badge_person': 'true'
'badge_person': true
} %}
<h2 class="chill-blue">{{ 'Activity data'|trans }}</h2>

View File

@ -0,0 +1,220 @@
<?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\Service\DocGenerator;
use Chill\ActivityBundle\Entity\Activity;
use Chill\DocGeneratorBundle\Context\DocGeneratorContextWithAdminFormInterface;
use Chill\DocGeneratorBundle\Context\DocGeneratorContextWithPublicFormInterface;
use Chill\DocGeneratorBundle\Context\Exception\UnexpectedTypeException;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\DocGeneratorBundle\Service\Context\BaseContextData;
use Chill\DocStoreBundle\Entity\Document;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Repository\DocumentCategoryRepository;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Templating\Entity\PersonRender;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class ActivityContext implements
DocGeneratorContextWithAdminFormInterface,
DocGeneratorContextWithPublicFormInterface
{
private BaseContextData $baseContextData;
private DocumentCategoryRepository $documentCategoryRepository;
private EntityManagerInterface $em;
private NormalizerInterface $normalizer;
private PersonRender $personRender;
private TranslatableStringHelperInterface $translatableStringHelper;
private TranslatorInterface $translator;
public function __construct(
DocumentCategoryRepository $documentCategoryRepository,
NormalizerInterface $normalizer,
TranslatableStringHelperInterface $translatableStringHelper,
EntityManagerInterface $em,
PersonRender $personRender,
TranslatorInterface $translator,
BaseContextData $baseContextData
) {
$this->documentCategoryRepository = $documentCategoryRepository;
$this->normalizer = $normalizer;
$this->translatableStringHelper = $translatableStringHelper;
$this->em = $em;
$this->personRender = $personRender;
$this->translator = $translator;
$this->baseContextData = $baseContextData;
}
public function adminFormReverseTransform(array $data): array
{
return $data;
}
public function adminFormTransform(array $data): array
{
return [
'mainPerson' => $data['mainPerson'] ?? false,
'mainPersonLabel' => $data['mainPersonLabel'] ?? $this->translator->trans('docgen.Main person'),
'person1' => $data['person1'] ?? false,
'person1Label' => $data['person1Label'] ?? $this->translator->trans('docgen.person 1'),
'person2' => $data['person2'] ?? false,
'person2Label' => $data['person2Label'] ?? $this->translator->trans('docgen.person 2'),
];
}
public function buildAdminForm(FormBuilderInterface $builder): void
{
$builder
->add('mainPerson', CheckboxType::class, [
'required' => false,
'label' => 'docgen.Ask for main person',
])
->add('mainPersonLabel', TextType::class, [
'label' => 'main person label',
'required' => true,
])
->add('person1', CheckboxType::class, [
'required' => false,
'label' => 'docgen.Ask for person 1',
])
->add('person1Label', TextType::class, [
'label' => 'person 1 label',
'required' => true,
])
->add('person2', CheckboxType::class, [
'required' => false,
'label' => 'docgen.Ask for person 2',
])
->add('person2Label', TextType::class, [
'label' => 'person 2 label',
'required' => true,
]);
}
/**
* @param Activity $entity
*/
public function buildPublicForm(FormBuilderInterface $builder, DocGeneratorTemplate $template, $entity): void
{
$options = $template->getOptions();
$persons = $entity->getPersons();
foreach (['mainPerson', 'person1', 'person2'] as $key) {
if ($options[$key] ?? false) {
$builder->add($key, EntityType::class, [
'class' => Person::class,
'choices' => $persons,
'choice_label' => function (Person $p) {
return $this->personRender->renderString($p, []);
},
'multiple' => false,
'expanded' => true,
'label' => $options[$key . 'Label'],
]);
}
}
}
public function getData(DocGeneratorTemplate $template, $entity, array $contextGenerationData = []): array
{
if (!$entity instanceof Activity) {
throw new UnexpectedTypeException($entity, Activity::class);
}
$options = $template->getOptions();
$data = [];
$data = array_merge($data, $this->baseContextData->getData());
$data['activity'] = $this->normalizer->normalize($entity, 'docgen', ['docgen:expects' => Activity::class, 'groups' => 'docgen:read']);
$data['course'] = $this->normalizer->normalize($entity->getAccompanyingPeriod(), 'docgen', ['docgen:expects' => AccompanyingPeriod::class, 'groups' => 'docgen:read']);
$data['person'] = $this->normalizer->normalize($entity->getPerson(), 'docgen', ['docgen:expects' => Person::class, 'groups' => 'docgen:read']);
foreach (['mainPerson', 'person1', 'person2'] as $k) {
if ($options[$k]) {
$data[$k] = $this->normalizer->normalize($contextGenerationData[$k], 'docgen', [
'docgen:expects' => Person::class,
'groups' => 'docgen:read',
'docgen:person:with-household' => true,
'docgen:person:with-relations' => true,
]);
}
}
return $data;
}
public function getDescription(): string
{
return 'docgen.A basic context for activity';
}
public function getEntityClass(): string
{
return Activity::class;
}
public function getFormData(DocGeneratorTemplate $template, $entity): array
{
return [
'activity' => $entity,
];
}
public static function getKey(): string
{
return self::class;
}
public function getName(): string
{
return 'docgen.Activity basic';
}
public function hasAdminForm(): bool
{
return true;
}
public function hasPublicForm(DocGeneratorTemplate $template, $entity): bool
{
$options = $template->getOptions();
return $options['mainPerson'] || $options['person1'] || $options['person2'];
}
/**
* @param Activity $entity
*/
public function storeGenerated(DocGeneratorTemplate $template, StoredObject $storedObject, object $entity, array $contextGenerationData): void
{
$doc = new StoredObject();
// TODO push document to remote
$this->em->persist($doc);
$entity->addDocument($doc);
}
}

View File

@ -32,3 +32,8 @@ services:
autowire: true
autoconfigure: true
resource: '../Validator/Constraints/'
Chill\ActivityBundle\Service\DocGenerator\:
autowire: true
autoconfigure: true
resource: '../Service/DocGenerator/'

View File

@ -228,3 +228,7 @@ See activity in accompanying course context: Voir l'activité dans le contexte d
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°
docgen:
Activity basic: Echange
A basic context for activity: Contexte pour les échanges

View File

@ -17,7 +17,7 @@
<select class="form-select" v-model="template">
<option disabled selected value="">{{ $t('choose_a_template') }}</option>
<template v-for="t in templates">
<option v-bind:value="t.id">{{ t.name.fr }}</option>
<option v-bind:value="t.id">{{ t.name.fr || 'Aucun nom défini' }}</option>
</template>
</select>
<button v-if="canGenerate" class="btn btn-update btn-sm change-icon" type="button" @click="generateDocument"><i class="fa fa-fw fa-cog"></i></button>
@ -48,6 +48,7 @@ export default {
required: true,
},
templates: {
type: Array,
required: true,
},
// beforeMove execute "something" before
@ -73,7 +74,11 @@ export default {
return true;
},
getDescription() {
return this.templates.find(t => t.id === this.template).description || '';
let desc = this.templates.find(t => t.id === this.template);
if (null === desc) {
return '';
}
return desc.description || '';
},
},
methods: {

View File

@ -0,0 +1,52 @@
{% import "@ChillDocStore/Macro/macro.html.twig" as m %}
<div class="flex-table accompanying_course_work-list">
<div class="item-bloc document-item bg-chill-llight-gray">
<div class="row justify-content-center my-4">
<div class="col-2">
<i class="fa fa-4x fa-file-text-o text-success"></i>
</div>
<div class="col-8">
<h3>{{ document.title }}</h3>
<small>{{ document.object.type }}</small>
{% if document.description is not empty %}
<blockquote class="chill-user-quote mt-2">
{{ document.description }}
</blockquote>
{% endif %}
</div>
</div>
</div>
</div>
{% if display_action is defined and display_action == true %}
<ul class="record_actions">
<li>
{{ m.download_button(document.object, document.title) }}
</li>
<li>
{#
data-button is optional !
OPTIONS:
'changeIcon' string
'changeClass' string
'noText' boolean
#}{% set button = {
'changeIcon': 'fa-unlock',
} %}
{# vue component #}
<span
data-module="wopi-link"
data-wopi-url="{{ path('chill_wopi_file_edit', {'fileId': document.object.uuid}) }}"
data-doc-title="{{ document.title|e('html_attr') }}"
data-doc-type="{{ document.object.type|e('html_attr') }}"
data-button="{{ button|json_encode }}"
></span>
</li>
</ul>
{% endif %}

View File

@ -0,0 +1,19 @@
{% import '@ChillMain/Workflow/macro_breadcrumb.html.twig' as m %}
<div class="flex-grow-1 {% if add_classes is defined %}{{ add_classes }}{% else %}h2{% endif %}">
<div>
{% if concerne is defined and concerne == true %}
<span class="item-key">{{ 'Concerne'|trans }}: </span>
{% endif %}
{{ 'workflow.Document (n°%doc%)'|trans({'%doc%': document.id}) }}
{% if description is defined and description == true %}
{{ ' — ' ~ document.title }}
{% endif %}
</div>
{% if breadcrumb is defined and breadcrumb == true %}
{{ m.breadcrumb(_context) }}
{% endif %}
</div>

View File

@ -6,61 +6,71 @@
{% block title %}
{# {{ 'Detail of document of %name%'|trans({ '%name%': accompanyingCourse|chill_entity_render_string } ) }} #}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_async_upload') }}
{{ 'Document %title%' | trans({ '%title%': document.title }) }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_async_upload') }}
{{ encore_entry_link_tags('mod_entity_workflow_pick') }}
{% endblock %}
{% block content %}
<h1>{{ 'Document %title%' | trans({ '%title%': document.title }) }}</h1>
<dl class="chill_view_data">
<dt>{{ 'Title'|trans }}</dt>
<dd>{{ document.title }}</dd>
<div class="document-show">
<h1>{{ block('title') }}</h1>
<dl class="chill_view_data">
<dt>{{ 'Title'|trans }}</dt>
<dd>{{ document.title }}</dd>
{% if document.category is not null %}
<dt>{{ 'Category'|trans }}</dt>
<dd>{{ document.category.name|localize_translatable_string }}</dd>
{% endif %}
<dt>{{ 'Description' | trans }}</dt>
<dd>
{% if document.description is empty %}
<span class="chill-no-data-statement">{{ 'Any description'|trans }}</span>
{% else %}
<blockquote class="chill-user-quote">
{{ document.description|chill_markdown_to_html }}
</blockquote>
{% endif %}
</dd>
</dl>
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a href="{{ path('accompanying_course_document_index', {'course': accompanyingCourse.id}) }}" class="btn btn-cancel">
{{ 'Back to the list' | trans }}
</a>
</li>
<li>
{{ m.download_button(document.object, document.title) }}
</li>
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document) %}
<li>
<a href="{{ path('accompanying_course_document_edit', {'id': document.id, 'course': accompanyingCourse.id}) }}" class="btn btn-edit">
{{ 'Edit' | trans }}
</a>
</li>
{% endif %}
</ul>
</div>
{% endblock %}
{% block block_post_menu %}
<div class="post-menu pt-4">
{% set workflows_frame = chill_entity_workflow_list('Chill\\DocStoreBundle\\Entity\\AccompanyingCourseDocument', document.id) %}
{% if workflows_frame is not empty %}
{{ workflows_frame|raw }}
{% endif %}
</div>
{% endblock %}
<dt>{{ 'Description' | trans }}</dt>
<dd>
{% if document.description is empty %}
<span class="chill-no-data-statement">{{ 'Any description'|trans }}</span>
{% else %}
<blockquote class="chill-user-quote">
{{ document.description|chill_markdown_to_html }}
</blockquote>
{% endif %}
</dd>
</dl>
<ul class="record_actions">
<li class="cancel">
<a href="{{ path('accompanying_course_document_index', {'course': accompanyingCourse.id}) }}" class="btn btn-cancel">
{{ 'Back to the list' | trans }}
</a>
</li>
<li>
{{ m.download_button(document.object, document.title) }}
</li>
{% if is_granted('CHILL_ACCOMPANYING_COURSE_DOCUMENT_UPDATE', document) %}
<li>
<a href="{{ path('accompanying_course_document_edit', {'id': document.id, 'course': accompanyingCourse.id}) }}" class="btn btn-edit">
{{ 'Edit' | trans }}
</a>
</li>
{% endif %}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_async_upload') }}
{{ encore_entry_script_tags('mod_entity_workflow_pick') }}
{% endblock %}

View File

@ -0,0 +1,74 @@
<?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\DocStoreBundle\Workflow;
use Chill\DocStoreBundle\Entity\AccompanyingCourseDocument;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
class AccompanyingCourseDocumentWorkflowHandler implements EntityWorkflowHandlerInterface
{
private EntityRepository $repository;
/**
* TODO: injecter le repository directement.
*/
public function __construct(EntityManagerInterface $em)
{
$this->repository = $em->getRepository(AccompanyingCourseDocument::class);
}
public function getRelatedEntity(EntityWorkflow $entityWorkflow): ?AccompanyingCourseDocument
{
return $this->repository->find($entityWorkflow->getRelatedEntityId());
}
public function getRoleShow(EntityWorkflow $entityWorkflow): ?string
{
return null;
}
public function getTemplate(EntityWorkflow $entityWorkflow, array $options = []): string
{
return '@ChillDocStore/AccompanyingCourseDocument/_workflow.html.twig';
}
public function getTemplateData(EntityWorkflow $entityWorkflow, array $options = []): array
{
return [
'entity_workflow' => $entityWorkflow,
'document' => $this->getRelatedEntity($entityWorkflow),
];
}
public function getTemplateTitle(EntityWorkflow $entityWorkflow, array $options = []): string
{
return '@ChillDocStore/AccompanyingCourseDocument/_workflow.title.html.twig';
}
public function getTemplateTitleData(EntityWorkflow $entityWorkflow, array $options = []): array
{
return $this->getTemplateData($entityWorkflow, $options);
}
public function supports(EntityWorkflow $entityWorkflow, array $options = []): bool
{
return $entityWorkflow->getRelatedEntityClass() === AccompanyingCourseDocument::class;
}
public function supportsFreeze(EntityWorkflow $entityWorkflow, array $options = []): bool
{
return true;
}
}

View File

@ -27,3 +27,8 @@ services:
autoconfigure: true
tags:
- { name: chill.role }
Chill\DocStoreBundle\Workflow\:
resource: './../Workflow/'
autoconfigure: true
autowire: true

View File

@ -9,6 +9,7 @@ Create new document: Créer un nouveau document
New document for %name%: Nouveau document pour %name%
Editing document for %name%: Modification d'un document pour %name%
Edit Document: Modification d'un document
Update document: Modifier le document
Existing document: Document existant
No document to download: Aucun document à télécharger
'Choose a document category': Choisissez une catégorie de document

View File

@ -31,6 +31,7 @@ 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 Chill\MainBundle\Workflow\EntityWorkflowHandlerInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
@ -56,6 +57,8 @@ class ChillMainBundle extends Bundle
->addTag('chill_main.notification_handler');
$container->registerForAutoconfiguration(NotificationCounterInterface::class)
->addTag('chill.count_notification.user');
$container->registerForAutoconfiguration(EntityWorkflowHandlerInterface::class)
->addTag('chill_main.workflow_handler');
$container->addCompilerPass(new SearchableServicesCompilerPass());
$container->addCompilerPass(new ConfigConsistencyCompilerPass());

View File

@ -0,0 +1,118 @@
<?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\Workflow\EntityWorkflow;
use Doctrine\ORM\EntityManagerInterface;
use LogicException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Core\Security;
class WorkflowApiController
{
private EntityManagerInterface $entityManager;
private Security $security;
public function __construct(Security $security, EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
$this->security = $security;
}
/**
* @Route("/api/1.0/main/workflow/{id}/subscribe", methods={"POST"})
*/
public function subscribe(EntityWorkflow $entityWorkflow, Request $request): Response
{
return $this->handleSubscription($entityWorkflow, $request, 'subscribe');
}
/**
* @Route("/api/1.0/main/workflow/{id}/unsubscribe", methods={"POST"})
*/
public function unsubscribe(EntityWorkflow $entityWorkflow, Request $request): Response
{
return $this->handleSubscription($entityWorkflow, $request, 'unsubscribe');
}
private function handleSubscription(EntityWorkflow $entityWorkflow, Request $request, string $action): JsonResponse
{
if (!$this->security->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
throw new AccessDeniedException();
}
if (!$request->query->has('subscribe')) {
throw new BadRequestHttpException('missing subscribe parameter');
}
$user = $this->security->getUser();
switch ($request->query->get('subscribe')) {
case 'final':
switch ($action) {
case 'subscribe':
$entityWorkflow->addSubscriberToFinal($user);
break;
case 'unsubscribe':
$entityWorkflow->removeSubscriberToFinal($user);
break;
default:
throw new LogicException();
}
break;
case 'step':
switch ($action) {
case 'subscribe':
$entityWorkflow->addSubscriberToStep($user);
break;
case 'unsubscribe':
$entityWorkflow->removeSubscriberToStep($user);
break;
default:
throw new LogicException();
}
break;
default:
throw new BadRequestHttpException('subscribe parameter must be equal to "step" or "final"');
}
$this->entityManager->flush();
return new JsonResponse(
[
'step' => $entityWorkflow->isUserSubscribedToStep($user),
'final' => $entityWorkflow->isUserSubscribedToFinal($user),
],
JsonResponse::HTTP_OK,
[],
false
);
}
}

View File

@ -0,0 +1,257 @@
<?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\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowComment;
use Chill\MainBundle\Form\EntityWorkflowCommentType;
use Chill\MainBundle\Form\WorkflowStepType;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\TransitionBlocker;
use Symfony\Contracts\Translation\TranslatorInterface;
use function count;
class WorkflowController extends AbstractController
{
private EntityManagerInterface $entityManager;
private EntityWorkflowManager $entityWorkflowManager;
private EntityWorkflowRepository $entityWorkflowRepository;
private PaginatorFactory $paginatorFactory;
private Registry $registry;
private TranslatorInterface $translator;
private ValidatorInterface $validator;
public function __construct(EntityWorkflowManager $entityWorkflowManager, EntityWorkflowRepository $entityWorkflowRepository, ValidatorInterface $validator, PaginatorFactory $paginatorFactory, Registry $registry, EntityManagerInterface $entityManager, TranslatorInterface $translator)
{
$this->entityWorkflowManager = $entityWorkflowManager;
$this->entityWorkflowRepository = $entityWorkflowRepository;
$this->validator = $validator;
$this->paginatorFactory = $paginatorFactory;
$this->registry = $registry;
$this->entityManager = $entityManager;
$this->translator = $translator;
}
/**
* @Route("/{_locale}/main/workflow/create", name="chill_main_workflow_create")
*/
public function create(Request $request): Response
{
if (!$request->query->has('entityClass')) {
throw new BadRequestHttpException('Missing entityClass parameter');
}
if (!$request->query->has('entityId')) {
throw new BadRequestHttpException('missing entityId parameter');
}
if (!$request->query->has('workflow')) {
throw new BadRequestHttpException('missing workflow parameter');
}
$entityWorkflow = new EntityWorkflow();
$entityWorkflow
->setRelatedEntityClass($request->query->get('entityClass'))
->setRelatedEntityId($request->query->getInt('entityId'))
->setWorkflowName($request->query->get('workflow'));
$errors = $this->validator->validate($entityWorkflow, null, ['creation']);
if (count($errors) > 0) {
$msg = [];
foreach ($errors as $error) {
/** @var \Symfony\Component\Validator\ConstraintViolationInterface $error */
$msg[] = $error->getMessage();
}
return new Response(implode("\n", $msg), Response::HTTP_UNPROCESSABLE_ENTITY);
}
$this->denyAccessUnlessGranted(EntityWorkflowVoter::CREATE, $entityWorkflow);
$em = $this->getDoctrine()->getManager();
$em->persist($entityWorkflow);
$em->flush();
return $this->redirectToRoute('chill_main_workflow_show', ['id' => $entityWorkflow->getId()]);
}
/**
* @Route("/{_locale}/main/workflow/list/dest", name="chill_main_workflow_list_dest")
*/
public function myWorkflowsDest(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$total = $this->entityWorkflowRepository->countByDest($this->getUser());
$paginator = $this->paginatorFactory->create($total);
$workflows = $this->entityWorkflowRepository->findByDest(
$this->getUser(),
['createdAt' => 'DESC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);
return $this->render(
'@ChillMain/Workflow/list.html.twig',
[
'workflows' => $this->buildHandler($workflows),
'paginator' => $paginator,
'step' => 'dest',
]
);
}
/**
* @Route("/{_locale}/main/workflow/list/subscribed", name="chill_main_workflow_list_subscribed")
*/
public function myWorkflowsSubscribed(Request $request): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$total = $this->entityWorkflowRepository->countBySubscriber($this->getUser());
$paginator = $this->paginatorFactory->create($total);
$workflows = $this->entityWorkflowRepository->findBySubscriber(
$this->getUser(),
['createdAt' => 'DESC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);
return $this->render(
'@ChillMain/Workflow/list.html.twig',
[
'workflows' => $this->buildHandler($workflows),
'paginator' => $paginator,
'step' => 'subscribed',
]
);
}
/**
* @Route("/{_locale}/main/workflow/{id}/show", name="chill_main_workflow_show")
*/
public function show(EntityWorkflow $entityWorkflow, Request $request): Response
{
$this->denyAccessUnlessGranted(EntityWorkflowVoter::SEE, $entityWorkflow);
$handler = $this->entityWorkflowManager->getHandler($entityWorkflow);
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
if (count($workflow->getEnabledTransitions($entityWorkflow)) > 0) {
// possible transition
$transitionForm = $this->createForm(
WorkflowStepType::class,
$entityWorkflow->getCurrentStep(),
['transition' => true, 'entity_workflow' => $entityWorkflow]
);
$transitionForm->handleRequest($request);
if ($transitionForm->isSubmitted() && $transitionForm->isValid()) {
if (!$workflow->can($entityWorkflow, $transition = $transitionForm['transition']->getData()->getName())) {
$blockers = $workflow->buildTransitionBlockerList($entityWorkflow, $transition);
$msgs = array_map(function (TransitionBlocker $tb) {
return $this->translator->trans(
$tb->getMessage(),
$tb->getParameters()
);
}, iterator_to_array($blockers));
throw $this->createAccessDeniedException(
sprintf(
"not allowed to apply transition {$transition}: %s",
implode(', ', $msgs)
)
);
}
$workflow->apply($entityWorkflow, $transition);
foreach ($transitionForm['future_dest_users']->getData() as $user) {
$entityWorkflow->getCurrentStep()->addDestUser($user);
}
$this->entityManager->flush();
return $this->redirectToRoute('chill_main_workflow_show', ['id' => $entityWorkflow->getId()]);
}
if ($transitionForm->isSubmitted() && !$transitionForm->isValid()) {
$this->addFlash('error', $this->translator->trans('This form contains errors'));
}
}
/*
$commentForm = $this->createForm(EntityWorkflowCommentType::class, $newComment = new EntityWorkflowComment());
$commentForm->handleRequest($request);
if ($commentForm->isSubmitted() && $commentForm->isValid()) {
$this->entityManager->persist($newComment);
$this->entityManager->flush();
$this->addFlash('success', $this->translator->trans('workflow.Comment added'));
return $this->redirectToRoute('chill_main_workflow_show', ['id' => $entityWorkflow->getId()]);
} elseif ($commentForm->isSubmitted() && !$commentForm->isValid()) {
$this->addFlash('error', $this->translator->trans('This form contains errors'));
}
*/
return $this->render(
'@ChillMain/Workflow/index.html.twig',
[
'handler_template' => $handler->getTemplate($entityWorkflow),
'handler_template_title' => $handler->getTemplateTitle($entityWorkflow),
'handler_template_data' => $handler->getTemplateData($entityWorkflow),
'transition_form' => isset($transitionForm) ? $transitionForm->createView() : null,
'entity_workflow' => $entityWorkflow,
//'comment_form' => $commentForm->createView(),
]
);
}
private function buildHandler(array $workflows): array
{
$lines = [];
foreach ($workflows as $workflow) {
$handler = $this->entityWorkflowManager->getHandler($workflow);
$lines[] = [
'handler' => $handler,
'entity_workflow' => $workflow,
];
}
return $lines;
}
}

View File

@ -168,6 +168,7 @@ class ChillMainExtension extends Extension implements
$loader->load('services/timeline.yaml');
$loader->load('services/search.yaml');
$loader->load('services/serializer.yaml');
$loader->load('services/mailer.yaml');
$this->configureCruds($container, $config['cruds'], $config['apis'], $loader);
}
@ -391,6 +392,26 @@ class ChillMainExtension extends Extension implements
],
],
],
[
'class' => \Chill\MainBundle\Entity\UserJob::class,
'name' => 'user_job',
'base_path' => '/api/1.0/main/user-job',
'base_role' => 'ROLE_USER',
'actions' => [
'_index' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true,
],
],
'_entity' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true,
],
],
],
],
[
'controller' => \Chill\MainBundle\Controller\AddressReferenceAPIController::class,
'class' => \Chill\MainBundle\Entity\AddressReference::class,

View File

@ -0,0 +1,56 @@
<?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\Doctrine\Model;
use Chill\MainBundle\Entity\User;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
trait TrackCreationTrait
{
/**
* @ORM\Column(type="datetime_immutable", nullable=true, options={"default": NULL})
*/
private ?DateTimeImmutable $createdAt = null;
/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=true)
*/
private ?User $createdBy = null;
public function getCreatedAt(): ?DateTimeInterface
{
return $this->createdAt;
}
public function getCreatedBy(): ?User
{
return $this->createdBy;
}
public function setCreatedAt(DateTimeInterface $datetime): self
{
$this->createdAt = $datetime instanceof DateTime ? DateTimeImmutable::createFromMutable($datetime) : $datetime;
return $this;
}
public function setCreatedBy(User $user): self
{
$this->createdBy = $user;
return $this;
}
}

View File

@ -0,0 +1,56 @@
<?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\Doctrine\Model;
use Chill\MainBundle\Entity\User;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM;
trait TrackUpdateTrait
{
/**
* @ORM\Column(type="datetime_immutable", nullable=true, options={"default": NULL})
*/
private ?DateTimeImmutable $updatedAt = null;
/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=true)
*/
private ?User $updatedBy = null;
public function getUpdatedAt(): ?DateTimeInterface
{
return $this->updatedAt;
}
public function getUpdatedBy(): ?User
{
return $this->updatedBy;
}
public function setUpdatedAt(DateTimeInterface $datetime): self
{
$this->updatedAt = $datetime instanceof DateTime ? DateTimeImmutable::createFromMutable($datetime) : $datetime;
return $this;
}
public function setUpdatedBy(User $user): self
{
$this->updatedBy = $user;
return $this;
}
}

View File

@ -64,7 +64,7 @@ class Location implements TrackCreationInterface, TrackUpdateInterface
/**
* @ORM\Column(type="string", length=255, nullable=true)
* @Serializer\Groups({"read", "write"})
* @Serializer\Groups({"read", "write", "docgen:read"})
*/
private ?string $email = null;

View File

@ -23,6 +23,9 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ORM\Entity
* @ORM\Table(
* name="chill_main_notification",
* indexes={
* @ORM\Index(name="chill_main_notification_related_entity_idx", columns={"relatedentityclass", "relatedentityid"})
* }
* )
* @ORM\HasLifecycleCallbacks
*/

View File

@ -0,0 +1,439 @@
<?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\Workflow;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Workflow\Validator\EntityWorkflowCreation;
use DateTimeInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Iterator;
use RuntimeException;
use Symfony\Component\Serializer\Annotation as Serializer;
use function count;
/**
* @ORM\Entity
* @ORM\Table("chill_main_workflow_entity")
* @EntityWorkflowCreation(groups={"creation"})
* @Serializer\DiscriminatorMap(typeProperty="type", mapping={
* "entity_workflow": EntityWorkflow::class
* })
*/
class EntityWorkflow implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
/**
* @ORM\OneToMany(targetEntity=EntityWorkflowComment::class, mappedBy="entityWorkflow", orphanRemoval=true)
*
* @var Collection|EntityWorkflowComment[]
*/
private Collection $comments;
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
* @Serializer\Groups({"read"})
*/
private ?int $id = null;
/**
* @ORM\Column(type="string", length=255)
*/
private string $relatedEntityClass = '';
/**
* @ORM\Column(type="integer")
*/
private int $relatedEntityId;
/**
* @ORM\OneToMany(targetEntity=EntityWorkflowStep::class, mappedBy="entityWorkflow", orphanRemoval=true, cascade={"persist"})
* @ORM\OrderBy({"transitionAt": "ASC", "id": "ASC"})
*
* @var Collection|EntityWorkflowStep[]
*/
private Collection $steps;
/**
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_workflow_entity_subscriber_to_final")
*
* @var Collection|User[]
*/
private Collection $subscriberToFinal;
/**
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_workflow_entity_subscriber_to_step")
*
* @var Collection|User[]
*/
private Collection $subscriberToStep;
/**
* a step which will store all the transition data.
*/
private ?EntityWorkflowStep $transitionningStep = null;
/**
* @ORM\Column(type="text")
*/
private string $workflowName;
public function __construct()
{
$this->subscriberToFinal = new ArrayCollection();
$this->subscriberToStep = new ArrayCollection();
$this->comments = new ArrayCollection();
$this->steps = new ArrayCollection();
$initialStep = new EntityWorkflowStep();
$initialStep
->setCurrentStep('initial');
$this->addStep($initialStep);
}
public function addComment(EntityWorkflowComment $comment): self
{
if (!$this->comments->contains($comment)) {
$this->comments[] = $comment;
$comment->setEntityWorkflow($this);
}
return $this;
}
/**
* @internal You should prepare a step and run a workflow transition instead of manually adding a step
*/
public function addStep(EntityWorkflowStep $step): self
{
if (!$this->steps->contains($step)) {
$this->steps[] = $step;
$step->setEntityWorkflow($this);
if ($this->isFinalize()) {
$step->setFinalizeAfter(true);
}
}
return $this;
}
public function addSubscriberToFinal(User $user): self
{
if (!$this->subscriberToFinal->contains($user)) {
$this->subscriberToFinal[] = $user;
}
return $this;
}
public function addSubscriberToStep(User $user): self
{
if (!$this->subscriberToStep->contains($user)) {
$this->subscriberToStep[] = $user;
}
return $this;
}
public function getComments(): Collection
{
return $this->comments;
}
public function getCurrentStep(): ?EntityWorkflowStep
{
$step = $this->steps->last();
if (false !== $step) {
return $step;
}
return null;
}
public function getCurrentStepCreatedAt(): ?DateTimeInterface
{
if (null !== $previous = $this->getPreviousStepIfAny()) {
return $previous->getTransitionAt();
}
return null;
}
public function getCurrentStepCreatedBy(): ?User
{
if (null !== $previous = $this->getPreviousStepIfAny()) {
return $previous->getTransitionBy();
}
return null;
}
public function getId(): ?int
{
return $this->id;
}
public function getRelatedEntityClass(): string
{
return $this->relatedEntityClass;
}
public function getRelatedEntityId(): int
{
return $this->relatedEntityId;
}
/**
* Method used by MarkingStore.
*
* get a string representation of the step
*/
public function getStep(): string
{
return $this->getCurrentStep()->getCurrentStep();
}
public function getStepAfter(EntityWorkflowStep $step): ?EntityWorkflowStep
{
$iterator = $this->steps->getIterator();
if ($iterator instanceof Iterator) {
$iterator->rewind();
while ($iterator->valid()) {
$curStep = $iterator->current();
if ($curStep === $step) {
$iterator->next();
if ($iterator->valid()) {
return $iterator->current();
}
return null;
}
$iterator->next();
}
return null;
}
throw new RuntimeException();
}
/**
* @return ArrayCollection|Collection
*/
public function getSteps()
{
return $this->steps;
}
public function getStepsChained(): array
{
$iterator = $this->steps->getIterator();
$previous = $next = $current = null;
$steps = [];
$iterator->rewind();
while ($iterator->valid()) {
$previous = $current;
$steps[] = $current = $iterator->current();
$current->setPrevious($previous);
$iterator->next();
if ($iterator->valid()) {
$next = $iterator->current();
} else {
$next = null;
}
$current->setNext($next);
}
return $steps;
}
/**
* @return ArrayCollection|Collection
*/
public function getSubscriberToFinal()
{
return $this->subscriberToFinal;
}
/**
* @return ArrayCollection|Collection
*/
public function getSubscriberToStep()
{
return $this->subscriberToStep;
}
/**
* get the step which is transitionning. Should be called only by event which will
* concern the transition.
*/
public function getTransitionningStep(): ?EntityWorkflowStep
{
return $this->transitionningStep;
}
public function getWorkflowName(): string
{
return $this->workflowName;
}
public function isFinalize(): bool
{
$steps = $this->getStepsChained();
if (1 === count($steps)) {
// the initial step cannot be finalized
return false;
}
/** @var EntityWorkflowStep $last */
$last = end($steps);
return $last->getPrevious()->isFinalizeAfter();
}
public function isFreeze(): bool
{
$steps = $this->getStepsChained();
if (1 === count($steps)) {
// the initial step cannot be finalized
return false;
}
/** @var EntityWorkflowStep $last */
$last = end($steps);
return $last->getPrevious()->isFreezeAfter();
}
public function isUserSubscribedToFinal(User $user): bool
{
return $this->subscriberToFinal->contains($user);
}
public function isUserSubscribedToStep(User $user): bool
{
return $this->subscriberToStep->contains($user);
}
public function prepareStepBeforeTransition(EntityWorkflowStep $step): self
{
$this->transitionningStep = $step;
return $this;
}
public function removeComment(EntityWorkflowComment $comment): self
{
if ($this->comments->removeElement($comment)) {
$comment->setEntityWorkflow(null);
}
return $this;
}
public function removeStep(EntityWorkflowStep $step): self
{
if ($this->steps->removeElement($step)) {
$step->setEntityWorkflow(null);
}
return $this;
}
public function removeSubscriberToFinal(User $user): self
{
$this->subscriberToFinal->removeElement($user);
return $this;
}
public function removeSubscriberToStep(User $user): self
{
$this->subscriberToStep->removeElement($user);
return $this;
}
public function setRelatedEntityClass(string $relatedEntityClass): EntityWorkflow
{
$this->relatedEntityClass = $relatedEntityClass;
return $this;
}
public function setRelatedEntityId(int $relatedEntityId): EntityWorkflow
{
$this->relatedEntityId = $relatedEntityId;
return $this;
}
/**
* Method use by marking store.
*
* @return $this
*/
public function setStep(string $step): self
{
$newStep = new EntityWorkflowStep();
$newStep->setCurrentStep($step);
// copy the freeze
if ($this->getCurrentStep()->isFreezeAfter()) {
$newStep->setFreezeAfter(true);
}
$this->addStep($newStep);
return $this;
}
public function setWorkflowName(string $workflowName): EntityWorkflow
{
$this->workflowName = $workflowName;
return $this;
}
private function getPreviousStepIfAny(): ?EntityWorkflowStep
{
if (1 === count($this->steps)) {
return null;
}
return $this->steps->get($this->steps->count() - 2);
}
}

View File

@ -0,0 +1,77 @@
<?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\Workflow;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table("chill_main_workflow_entity_comment")
*/
class EntityWorkflowComment implements TrackCreationInterface, TrackUpdateInterface
{
use TrackCreationTrait;
use TrackUpdateTrait;
/**
* @ORM\Column(type="text", options={"default": ""})
*/
private string $comment = '';
/**
* @ORM\ManyToOne(targetEntity=EntityWorkflow::class, inversedBy="comments")
*/
private ?EntityWorkflow $entityWorkflow = null;
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private ?int $id = null;
public function getComment(): string
{
return $this->comment;
}
public function getEntityWorkflow(): ?EntityWorkflow
{
return $this->entityWorkflow;
}
public function getId(): ?int
{
return $this->id;
}
public function setComment(string $comment): self
{
$this->comment = $comment;
return $this;
}
/**
* @internal use @see{EntityWorkflow::addComment}
*/
public function setEntityWorkflow(?EntityWorkflow $entityWorkflow): self
{
$this->entityWorkflow = $entityWorkflow;
return $this;
}
}

View File

@ -0,0 +1,336 @@
<?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\Workflow;
use Chill\MainBundle\Entity\User;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use function count;
use function in_array;
/**
* @ORM\Entity
* @ORM\Table("chill_main_workflow_entity_step")
*/
class EntityWorkflowStep
{
/**
* @ORM\Column(type="text", options={"default": ""})
*/
private string $comment = '';
/**
* @ORM\Column(type="text")
*/
private ?string $currentStep = '';
/**
* @ORM\Column(type="json")
*/
private array $destEmail = [];
/**
* @ORM\ManyToMany(targetEntity=User::class)
* @ORM\JoinTable(name="chill_main_workflow_entity_step_user")
*/
private Collection $destUser;
/**
* @ORM\ManyToOne(targetEntity=EntityWorkflow::class, inversedBy="steps")
*/
private ?EntityWorkflow $entityWorkflow = null;
/**
* @ORM\Column(type="boolean", options={"default": false})
*/
private bool $finalizeAfter = false;
/**
* @ORM\Column(type="boolean", options={"default": false})
*/
private bool $freezeAfter = false;
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private ?int $id = null;
/**
* filled by @see{EntityWorkflow::getStepsChained}.
*/
private ?EntityWorkflowStep $next = null;
/**
* filled by @see{EntityWorkflow::getStepsChained}.
*/
private ?EntityWorkflowStep $previous = null;
/**
* @ORM\Column(type="text", nullable=true, options={"default": null})
*/
private ?string $transitionAfter = null;
/**
* @ORM\Column(type="datetime_immutable")
*/
private ?DateTimeImmutable $transitionAt = null;
/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=true)
*/
private ?User $transitionBy = null;
/**
* @ORM\Column(type="text", nullable=true)
*/
private ?string $transitionByEmail = null;
public function __construct()
{
$this->destUser = new ArrayCollection();
}
public function addDestEmail(string $email): self
{
if (!in_array($email, $this->destEmail, true)) {
$this->destEmail[] = $email;
}
return $this;
}
public function addDestUser(User $user): self
{
if (!$this->destUser->contains($user)) {
$this->destUser[] = $user;
}
return $this;
}
public function getComment(): string
{
return $this->comment;
}
public function getCurrentStep(): ?string
{
return $this->currentStep;
}
public function getDestEmail(): array
{
return $this->destEmail;
}
/**
* @return ArrayCollection|Collection
*/
public function getDestUser()
{
return $this->destUser;
}
public function getEntityWorkflow(): ?EntityWorkflow
{
return $this->entityWorkflow;
}
public function getId(): ?int
{
return $this->id;
}
public function getNext(): ?EntityWorkflowStep
{
return $this->next;
}
public function getPrevious(): ?EntityWorkflowStep
{
return $this->previous;
}
public function getTransitionAfter(): ?string
{
return $this->transitionAfter;
}
public function getTransitionAt(): ?DateTimeImmutable
{
return $this->transitionAt;
}
public function getTransitionBy(): ?User
{
return $this->transitionBy;
}
public function getTransitionByEmail(): ?string
{
return $this->transitionByEmail;
}
public function isFinalizeAfter(): bool
{
return $this->finalizeAfter;
}
public function isFreezeAfter(): bool
{
return $this->freezeAfter;
}
public function removeDestEmail(string $email): self
{
$this->destEmail = array_filter($this->destEmail, static function (string $existing) use ($email) {
return $email !== $existing;
});
return $this;
}
public function removeDestUser(User $user): self
{
$this->destUser->removeElement($user);
return $this;
}
public function setComment(?string $comment): EntityWorkflowStep
{
$this->comment = (string) $comment;
return $this;
}
public function setCurrentStep(?string $currentStep): EntityWorkflowStep
{
$this->currentStep = $currentStep;
return $this;
}
public function setDestEmail(array $destEmail): EntityWorkflowStep
{
$this->destEmail = $destEmail;
return $this;
}
/**
* @internal use @see(EntityWorkflow::addStep} instead
*/
public function setEntityWorkflow(?EntityWorkflow $entityWorkflow): EntityWorkflowStep
{
$this->entityWorkflow = $entityWorkflow;
return $this;
}
public function setFinalizeAfter(bool $finalizeAfter): EntityWorkflowStep
{
$this->finalizeAfter = $finalizeAfter;
return $this;
}
public function setFreezeAfter(bool $freezeAfter): EntityWorkflowStep
{
$this->freezeAfter = $freezeAfter;
return $this;
}
/**
* @return EntityWorkflowStep
*
* @internal
*/
public function setNext(?EntityWorkflowStep $next): self
{
$this->next = $next;
return $this;
}
/**
* @return EntityWorkflowStep
*
* @internal
*/
public function setPrevious(?EntityWorkflowStep $previous): self
{
$this->previous = $previous;
return $this;
}
public function setTransitionAfter(?string $transitionAfter): EntityWorkflowStep
{
$this->transitionAfter = $transitionAfter;
return $this;
}
public function setTransitionAt(?DateTimeImmutable $transitionAt): EntityWorkflowStep
{
$this->transitionAt = $transitionAt;
return $this;
}
public function setTransitionBy(?User $transitionBy): EntityWorkflowStep
{
$this->transitionBy = $transitionBy;
return $this;
}
public function setTransitionByEmail(?string $transitionByEmail): EntityWorkflowStep
{
$this->transitionByEmail = $transitionByEmail;
return $this;
}
/**
* @Assert\Callback
*
* @param mixed $payload
*/
public function validateOnCreation(ExecutionContextInterface $context, $payload): void
{
return;
if ($this->isFinalizeAfter()) {
if (0 !== count($this->getDestUser())) {
$context->buildViolation('workflow.No dest users when the workflow is finalized')
->atPath('finalizeAfter')
->addViolation();
}
} else {
if (0 === count($this->getDestUser())) {
$context->buildViolation('workflow.The next step must count at least one dest')
->atPath('finalizeAfter')
->addViolation();
}
}
}
}

View File

@ -0,0 +1,27 @@
<?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 EntityWorkflowCommentType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('comment', ChillTextareaType::class, [
'required' => false,
]);
}
}

View File

@ -12,14 +12,18 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form\Type\DataTransformer;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
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 UnexpectedValueException;
use function array_key_exists;
class UserToJsonTransformer implements DataTransformerInterface
class EntityToJsonTransformer implements DataTransformerInterface
{
private DenormalizerInterface $denormalizer;
@ -27,23 +31,36 @@ class UserToJsonTransformer implements DataTransformerInterface
private SerializerInterface $serializer;
public function __construct(DenormalizerInterface $denormalizer, SerializerInterface $serializer, bool $multiple)
private string $type;
public function __construct(DenormalizerInterface $denormalizer, SerializerInterface $serializer, bool $multiple, string $type)
{
$this->denormalizer = $denormalizer;
$this->serializer = $serializer;
$this->multiple = $multiple;
$this->type = $type;
}
public function reverseTransform($value)
{
$denormalized = json_decode($value, true);
if ($this->multiple) {
if (null === $denormalized) {
return [];
}
return array_map(
function ($item) { return $this->denormalizeOne($item); },
json_decode($value, true)
$denormalized
);
}
return $this->denormalizeOne(json_decode($value, true));
if ('' === $value) {
return null;
}
return $this->denormalizeOne($denormalized);
}
/**
@ -60,7 +77,7 @@ class UserToJsonTransformer implements DataTransformerInterface
]);
}
private function denormalizeOne(array $item): User
private function denormalizeOne(array $item)
{
if (!array_key_exists('type', $item)) {
throw new TransformationFailedException('the key "type" is missing on element');
@ -70,10 +87,30 @@ class UserToJsonTransformer implements DataTransformerInterface
throw new TransformationFailedException('the key "id" is missing on element');
}
switch ($this->type) {
case 'user':
$class = User::class;
break;
case 'person':
$class = Person::class;
break;
case 'thirdparty':
$class = ThirdParty::class;
break;
default:
throw new UnexpectedValueException('This type is not supported');
}
return
$this->denormalizer->denormalize(
['type' => $item['type'], 'id' => $item['id']],
User::class,
$class,
'json',
[AbstractNormalizer::GROUPS => ['read']],
);

View File

@ -12,7 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form\Type;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\DataTransformer\UserToJsonTransformer;
use Chill\MainBundle\Form\Type\DataTransformer\EntityToJsonTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
@ -38,7 +38,7 @@ class PickUserDynamicType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->addViewTransformer(new UserToJsonTransformer($this->denormalizer, $this->serializer, $options['multiple']));
$builder->addViewTransformer(new EntityToJsonTransformer($this->denormalizer, $this->serializer, $options['multiple'], 'user'));
}
public function buildView(FormView $view, FormInterface $form, array $options)
@ -58,6 +58,6 @@ class PickUserDynamicType extends AbstractType
public function getBlockPrefix()
{
return 'pick_user_dynamic';
return 'pick_entity_dynamic';
}
}

View File

@ -0,0 +1,111 @@
<?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\Workflow\EntityWorkflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflowStep;
use Chill\MainBundle\Form\Type\ChillTextareaType;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use LogicException;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\Transition;
class WorkflowStepType extends AbstractType
{
private EntityWorkflowManager $entityWorkflowManager;
private Registry $registry;
public function __construct(EntityWorkflowManager $entityWorkflowManager, Registry $registry)
{
$this->entityWorkflowManager = $entityWorkflowManager;
$this->registry = $registry;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
/** @var \Chill\MainBundle\Entity\Workflow\EntityWorkflow $entityWorkflow */
$entityWorkflow = $options['entity_workflow'];
$handler = $this->entityWorkflowManager->getHandler($entityWorkflow);
if (true === $options['transition']) {
if (null === $options['entity_workflow']) {
throw new LogicException('if transition is true, entity_workflow should be defined');
}
$transitions = $this->registry
->get($options['entity_workflow'], $entityWorkflow->getWorkflowName())
->getEnabledTransitions($entityWorkflow);
$choices = array_combine(
array_map(static function (Transition $transition) { return $transition->getName(); }, $transitions),
$transitions
);
$builder
->add('transition', ChoiceType::class, [
'label' => 'workflow.Transition',
'mapped' => false,
'multiple' => false,
'expanded' => true,
'choices' => $choices,
'choice_label' => static function (Transition $transition) {
return implode(', ', $transition->getTos());
},
])
->add('future_dest_users', PickUserDynamicType::class, [
'label' => 'workflow.dest for next steps',
'multiple' => true,
'mapped' => false,
]);
}
if (
$handler->supportsFreeze($entityWorkflow)
&& !$entityWorkflow->isFreeze()
) {
$builder
->add('freezeAfter', CheckboxType::class, [
'required' => false,
'label' => 'workflow.Freeze',
'help' => 'workflow.The associated element will be freezed',
]);
}
$builder
->add('finalizeAfter', CheckboxType::class, [
'required' => false,
'label' => 'workflow.Finalize',
'help' => 'workflow.The workflow will be finalized',
])
->add('comment', ChillTextareaType::class, [
'required' => false,
'label' => 'Comment',
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setDefined('class', EntityWorkflowStep::class)
->setRequired('transition')
->setAllowedTypes('transition', 'bool')
->setRequired('entity_workflow')
->setAllowedTypes('entity_workflow', EntityWorkflow::class);
}
}

View File

@ -0,0 +1,36 @@
<?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\Workflow\EntityWorkflow;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class WorkflowTransitionType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('current_step', WorkflowStepType::class, [
'transition' => true,
'entity_workflow' => $options['entity_workflow'],
]);
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver
->setRequired('entity_workflow')
->setAllowedTypes('entity_workflow', EntityWorkflow::class);
}
}

View File

@ -50,7 +50,7 @@ class NotificationMailer
$email = new TemplatedEmail();
$email
->to($dest->getEmail())
->subject('Re: [Chill] ' . $comment->getNotification()->getTitle())
->subject('Re: ' . $comment->getNotification()->getTitle())
->textTemplate('@ChillMain/Notification/email_notification_comment_persist.fr.md.twig')
->context([
'comment' => $comment,
@ -79,11 +79,13 @@ class NotificationMailer
continue;
}
$email = new Email();
$email
->subject($notification->getTitle());
if ($notification->isSystem()) {
$email = new Email();
$email
->text($notification->getMessage())
->subject('[Chill] ' . $notification->getTitle());
->text($notification->getMessage());
} else {
$email = new TemplatedEmail();
$email

View File

@ -15,12 +15,15 @@ use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\NotificationRepository;
use Symfony\Component\Security\Core\Security;
use function array_key_exists;
/**
* Helps to find if a notification exist for a given entity.
*/
class NotificationPresence
{
private array $cache = [];
private NotificationRepository $notificationRepository;
private Security $security;
@ -31,6 +34,29 @@ class NotificationPresence
$this->notificationRepository = $notificationRepository;
}
public function countNotificationsForClassAndEntity(string $relatedEntityClass, int $relatedEntityId): array
{
if (array_key_exists($relatedEntityClass, $this->cache) && array_key_exists($relatedEntityId, $this->cache[$relatedEntityClass])) {
return $this->cache[$relatedEntityClass][$relatedEntityId];
}
$user = $this->security->getUser();
if ($user instanceof User) {
$counter = $this->notificationRepository->countNotificationByRelatedEntityAndUserAssociated(
$relatedEntityClass,
$relatedEntityId,
$user
);
$this->cache[$relatedEntityClass][$relatedEntityId] = $counter;
return $counter;
}
return ['unread' => 0, 'read' => 0];
}
/**
* @return array|Notification[]
*/

View File

@ -23,6 +23,13 @@ class NotificationTwigExtension extends AbstractExtension
'needs_environment' => true,
'is_safe' => ['html'],
]),
new TwigFunction('chill_count_notifications', [NotificationTwigExtensionRuntime::class, 'countNotificationsFor'], [
'is_safe' => [],
]),
new TwigFunction('chill_counter_notifications', [NotificationTwigExtensionRuntime::class, 'counterNotificationFor'], [
'needs_environment' => true,
'is_safe' => ['html'],
]),
];
}
}

View File

@ -24,6 +24,21 @@ class NotificationTwigExtensionRuntime implements RuntimeExtensionInterface
$this->notificationPresence = $notificationPresence;
}
public function counterNotificationFor(Environment $environment, string $relatedEntityClass, int $relatedEntityId, array $options = []): string
{
return $environment->render(
'@ChillMain/Notification/extension_counter_notifications_for.html.twig',
[
'counter' => $this->notificationPresence->countNotificationsForClassAndEntity($relatedEntityClass, $relatedEntityId),
]
);
}
public function countNotificationsFor(string $relatedEntityClass, int $relatedEntityId, array $options = []): array
{
return $this->notificationPresence->countNotificationsForClassAndEntity($relatedEntityClass, $relatedEntityId);
}
public function listNotificationsFor(Environment $environment, string $relatedEntityClass, int $relatedEntityId, array $options = []): string
{
$notifications = $this->notificationPresence->getNotificationsForClassAndEntity($relatedEntityClass, $relatedEntityId);

View File

@ -13,6 +13,7 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Doctrine\DBAL\Statement;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
@ -24,6 +25,8 @@ final class NotificationRepository implements ObjectRepository
{
private EntityManagerInterface $em;
private ?Statement $notificationByRelatedEntityAndUserAssociatedStatement = null;
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
@ -48,6 +51,30 @@ final class NotificationRepository implements ObjectRepository
->getSingleScalarResult();
}
public function countNotificationByRelatedEntityAndUserAssociated(string $relatedEntityClass, int $relatedEntityId, User $user): array
{
if (null === $this->notificationByRelatedEntityAndUserAssociatedStatement) {
$sql =
'SELECT
SUM((EXISTS (SELECT 1 AS c FROM chill_main_notification_addresses_unread cmnau WHERE user_id = 1812 and cmnau.notification_id = cmn.id))::int) AS unread,
SUM((cmn.sender_id = 1812)::int) AS sent,
COUNT(cmn.*) AS total
FROM chill_main_notification cmn
WHERE relatedentityclass = :relatedEntityClass AND relatedentityid = :relatedEntityId AND sender_id IS NOT NULL';
$this->notificationByRelatedEntityAndUserAssociatedStatement =
$this->em->getConnection()->prepare($sql);
}
$results = $this->notificationByRelatedEntityAndUserAssociatedStatement
->executeQuery(['relatedEntityClass' => $relatedEntityClass, 'relatedEntityId' => $relatedEntityId]);
$result = $results->fetchAssociative();
$results->free();
return $result;
}
public function countUnreadByUser(User $user): int
{
$sql = 'SELECT count(*) AS c FROM chill_main_notification_addresses_unread WHERE user_id = :userId';
@ -153,11 +180,29 @@ final class NotificationRepository implements ObjectRepository
* @return array|Notification[]
*/
public function findNotificationByRelatedEntityAndUserAssociated(string $relatedEntityClass, int $relatedEntityId, User $user): array
{
return
$this->buildQueryNotificationByRelatedEntityAndUserAssociated($relatedEntityClass, $relatedEntityId, $user)
->select('n')
->getQuery()
->getResult();
}
public function findOneBy(array $criteria, ?array $orderBy = null): ?Notification
{
return $this->repository->findOneBy($criteria, $orderBy);
}
public function getClassName()
{
return Notification::class;
}
private function buildQueryNotificationByRelatedEntityAndUserAssociated(string $relatedEntityClass, int $relatedEntityId, User $user): QueryBuilder
{
$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'))
@ -171,17 +216,7 @@ final class NotificationRepository implements ObjectRepository
->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);
}
public function getClassName()
{
return Notification::class;
return $qb;
}
private function queryByAddressee(User $addressee, bool $countQuery = false): QueryBuilder

View File

@ -0,0 +1,137 @@
<?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\Repository\Workflow;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ObjectRepository;
class EntityWorkflowRepository implements ObjectRepository
{
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository(EntityWorkflow::class);
}
public function countByDest(User $user): int
{
$qb = $this->buildQueryByDest($user)->select('count(ew)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
public function countBySubscriber(User $user): int
{
$qb = $this->buildQueryBySubscriber($user)->select('count(ew)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
public function find($id): ?EntityWorkflow
{
return $this->repository->find($id);
}
/**
* @return array|EntityWorkflow[]
*/
public function findAll(): array
{
return $this->repository->findAll();
}
/**
* @param null|mixed $limit
* @param null|mixed $offset
*
* @return array|EntityWorkflow[]
*/
public function findBy(array $criteria, ?array $orderBy = null, $limit = null, $offset = null): array
{
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findByDest(User $user, ?array $orderBy = null, $limit = null, $offset = null): array
{
$qb = $this->buildQueryByDest($user)->select('ew');
foreach ($orderBy as $key => $sort) {
$qb->addOrderBy('ew.' . $key, $sort);
}
$qb->setMaxResults($limit)->setFirstResult($offset);
return $qb->getQuery()->getResult();
}
public function findBySubscriber(User $user, ?array $orderBy = null, $limit = null, $offset = null): array
{
$qb = $this->buildQueryBySubscriber($user)->select('ew');
foreach ($orderBy as $key => $sort) {
$qb->addOrderBy('ew.' . $key, $sort);
}
$qb->setMaxResults($limit)->setFirstResult($offset);
return $qb->getQuery()->getResult();
}
public function findOneBy(array $criteria): ?EntityWorkflow
{
return $this->repository->findOneBy($criteria);
}
public function getClassName(): string
{
return EntityWorkflow::class;
}
private function buildQueryByDest(User $user): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('ew');
$qb->join('ew.steps', 'step');
$qb->where(
$qb->expr()->andX(
$qb->expr()->isMemberOf(':user', 'step.destUser'),
$qb->expr()->isNull('step.transitionAfter')
)
);
$qb->setParameter('user', $user);
return $qb;
}
private function buildQueryBySubscriber(User $user): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('ew');
$qb->where(
$qb->expr()->orX(
$qb->expr()->isMemberOf(':user', 'ew.subscriberToStep'),
$qb->expr()->isMemberOf(':user', 'ew.subscriberToFinal'),
)
);
$qb->setParameter('user', $user);
return $qb;
}
}

View File

@ -1,5 +1,5 @@
// Access to Bootstrap variables and mixins
@import '~ChillMainAssets/module/bootstrap/shared';
@import 'ChillMainAssets/module/bootstrap/shared';
// Chill variables
@import './scss/chill_variables';
@ -277,11 +277,17 @@ table.table-bordered {
}
}
/// meta-data
div.updatedBy,
div.metadata {
span.user, span.date {
text-decoration: underline dotted;
}
}
div.metadata {
font-size: smaller;
color: $gray-600;
span.user, span.date {
text-decoration: underline dotted;
&:hover {
color: $gray-700;
}
@ -424,7 +430,63 @@ span.item-key {
//text-decoration: dotted underline;
}
/// Workflows
div.workflow {
section.step {
border: 1px solid $chill-l-gray;
padding: 1em 2em;
div.flex-table {
margin: 1.5em -2em;
}
}
div.to-decision,
div.decided {
font-variant: all-small-caps;
margin-left: 1em;
}
div.to-decision {
font-weight: 300;
}
div.decided {
font-weight: 600;
}
div.breadcrumb {
display: initial;
margin-bottom: 0;
padding-right: 0.5em;
background-color: tint-color($chill-yellow, 90%);
border: 1px solid $chill-yellow;
color: $primary;
border-radius: 1.5em;
font-size: 12pt;
font-weight: 500;
font-variant: small-caps;
span, a {
cursor: pointer;
text-decoration: none;
&:hover {
font-weight: 700;
}
}
}
}
// Override bootstrap popover styles
div.popover {
box-shadow: 0 0 10px -5px $dark;
.popover-arrow {}
.popover-header {}
.popover-body {}
// Specific worflow breadcrumb popover
&.workflow-transition {
.popover-header {
font-variant: small-caps;
}
}
}
// increase toast message z-index (above all modals)
div.v-toast {
z-index: 10000!important;
}
}

View File

@ -18,6 +18,7 @@ $chill-theme-buttons: (
"show": $chill-blue,
"view": $chill-blue,
"misc": $gray-300,
"download": $gray-300,
"cancel": $gray-300,
"choose": $gray-300,
"notify": $gray-300,
@ -78,6 +79,7 @@ $chill-theme-buttons: (
&.btn-choose::before,
&.btn-notify::before,
&.btn-tpchild::before,
&.btn-download::before,
&.btn-cancel::before {
font: normal normal normal 14px/1 ForkAwesome;
margin-right: 0.5em;
@ -105,6 +107,7 @@ $chill-theme-buttons: (
&.btn-unlink::before { content: "\f127"; } // fa-chain-broken
&.btn-notify::before { content: "\f1d8"; } // fa-paper-plane
&.btn-tpchild::before { content: "\f007"; } // fa-user
&.btn-download::before { content: "\f019"; } // fa-download
}

View File

@ -41,6 +41,15 @@ div.flex-table {
margin-right: 5px;
}
}
div.item-meta {
flex-grow: 1 !important;
flex-shrink: 1 !important;
width: unset !important;
display: flex;
flex-direction: column;
justify-content: center;
}
}
/*

View File

@ -68,6 +68,7 @@ div.notification-show {
}
// Override bootstrap accordion
div#workflow-fold,
div#notification-fold {
.accordion-button {
padding: 0;
@ -78,3 +79,14 @@ div#notification-fold {
}
}
}
// Counter
div.notification-counter {
span {
&:not(:first-child) {
&::before {
content: '/ ';
}
}
}
}

View File

@ -106,6 +106,8 @@ section.chill-entity {
// used for comment-embeddable
&.entity-comment-embeddable {
width: 100%;
/* already defined !!
div.metadata {
font-size: smaller;
color: $gray-600;
@ -116,5 +118,6 @@ section.chill-entity {
}
}
}
*/
}
}

View File

@ -0,0 +1,12 @@
const buildLinkCreate = function(workflowName, relatedEntityClass, relatedEntityId) {
let params = new URLSearchParams();
params.set('entityClass', relatedEntityClass);
params.set('entityId', relatedEntityId);
params.set('workflow', workflowName);
return `/fr/main/workflow/create?`+params.toString();
};
export {
buildLinkCreate,
};

View File

@ -39,6 +39,7 @@ var ShowHide = function(options) {
contents.push(el);
}
container_content.push(contents);
// console.log('container content', container_content);
}
// attach the listener on each input

View File

@ -9,9 +9,10 @@ import Dropdown from 'bootstrap/js/src/dropdown';
import Modal from 'bootstrap/js/dist/modal';
import Collapse from 'bootstrap/js/src/collapse';
import Carousel from 'bootstrap/js/src/carousel';
import Popover from 'bootstrap/js/src/popover';
//
// ACHeaderSlider is a small slider used in banner of AccompanyingCourse Section
// Carousel: ACHeaderSlider is a small slider used in banner of AccompanyingCourse Section
// Initialize options, and show/hide controls in first/last slides
//
let ACHeaderSlider = document.querySelector('#ACHeaderSlider');
@ -48,3 +49,14 @@ if (ACHeaderSlider) {
}
})
}
//
// Popover: used in workflow breadcrumb,
// (expected in: contextual help, notification-box, workflow-box )
//
const triggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
const popoverList = triggerList.map(function (el) {
return new Popover(el, {
html: true,
});
});

View File

@ -0,0 +1,49 @@
import { createApp } from "vue";
import PickWorkflowVue from 'ChillMainAssets/vuejs/_components/EntityWorkflow/PickWorkflow.vue';
import ListWorkflowVue from 'ChillMainAssets/vuejs/_components/EntityWorkflow/ListWorkflow.vue';
// pick workflow
document.querySelectorAll('[data-pick-workflow]')
.forEach(function(el) {
const app = {
components: {
PickWorkflowVue
},
template:
'<pick-workflow-vue ' +
':relatedEntityClass="relatedEntityClass" ' +
':relatedEntityId="relatedEntityId" ' +
':workflowsAvailables="workflowsAvailables" ' +
'></pick-workflow-vue>',
data() {
return {
relatedEntityClass: el.dataset.relatedEntityClass,
relatedEntityId: Number.parseInt(el.dataset.relatedEntityId),
workflowsAvailables: JSON.parse(el.dataset.workflowsAvailables),
}
}
};
createApp(app).mount(el);
})
;
// list workflow
document.querySelectorAll('[data-list-workflows]')
.forEach(function (el) {
const app = {
components: {
ListWorkflowVue,
},
template:
'<list-workflow-vue ' +
':workflows="workflows" ' +
'></list-workflow-vue>',
data() {
return {
workflows: JSON.parse(el.dataset.workflows),
}
}
};
createApp(app).mount(el);
})
;

View File

@ -0,0 +1,32 @@
import {createApp} from "vue";
import EntityWorkflowVueSubscriber from 'ChillMainAssets/vuejs/_components/EntityWorkflow/EntityWorkflowVueSubscriber.vue';
import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n';
import { appMessages } from 'ChillMainAssets/vuejs/PickEntity/i18n';
const i18n = _createI18n(appMessages);
let containers = document.querySelectorAll('[data-entity-workflow-subscribe]');
containers.forEach(container => {
let app = {
components: {
EntityWorkflowVueSubscriber,
},
template: '<entity-workflow-vue-subscriber :entityWorkflowId="this.entityWorkflowId" :subscriberStep="this.subscriberStep" :subscriberFinal="this.subscriberFinal" @subscriptionUpdated="onUpdate"></entity-workflow-vue-subscriber>',
data() {
return {
entityWorkflowId: Number.parseInt(container.dataset.entityWorkflowId),
subscriberStep: container.dataset.subscribeStep === "1",
subscriberFinal: container.dataset.subscribeFinal === "1",
}
},
methods: {
onUpdate(status) {
this.subscriberStep = status.step;
this.subscriberFinal = status.final;
}
}
}
createApp(app).use(i18n).mount(container);
})

View File

@ -5,18 +5,21 @@ import { appMessages } from 'ChillMainAssets/vuejs/PickEntity/i18n';
const i18n = _createI18n(appMessages);
window.addEventListener('DOMContentLoaded', function(e) {
let appsOnPage = new Map();
let apps = document.querySelectorAll('[data-module="pick-dynamic"]');
function loadDynamicPicker(element) {
let apps = element.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)];
uniqId = el.dataset.uniqid,
input = element.querySelector('[data-input-uniqid="'+ el.dataset.uniqid +'"]'),
picked = (isMultiple) ? (JSON.parse(input.value)) : ((input.value === '[]') ? (null) : ([JSON.parse(input.value)]));
createApp({
const app = createApp({
template: '<pick-entity ' +
':multiple="multiple" ' +
':types="types" ' +
@ -31,7 +34,7 @@ window.addEventListener('DOMContentLoaded', function(e) {
return {
multiple: isMultiple,
types: JSON.parse(el.dataset.types),
picked,
picked: picked === null ? [] : picked,
uniqid: el.dataset.uniqid,
}
},
@ -65,5 +68,36 @@ window.addEventListener('DOMContentLoaded', function(e) {
})
.use(i18n)
.mount(el);
appsOnPage.set(uniqId, app);
});
});
}
document.addEventListener('show-hide-show', function(e) {
console.log('creation event caught')
loadDynamicPicker(e.detail.container)
})
document.addEventListener('show-hide-hide', function(e) {
console.log('hiding event caught')
e.detail.container.querySelectorAll('[data-module="pick-dynamic"]').forEach((el) => {
let uniqId = el.dataset.uniqid;
console.log(uniqId);
if (appsOnPage.has(uniqId)) {
appsOnPage.get(uniqId).unmount();
console.log('App has been unmounted')
appsOnPage.delete(uniqId);
}
})
})
document.addEventListener('DOMContentLoaded', function(e) {
console.log('loaded event', e)
loadDynamicPicker(document)
})

View File

@ -0,0 +1,29 @@
import { createApp } from 'vue';
import OpenWopiLink from 'ChillMainAssets/vuejs/_components/OpenWopiLink';
import {_createI18n} from "ChillMainAssets/vuejs/_js/i18n";
const i18n = _createI18n({});
window.addEventListener('DOMContentLoaded', function (e) {
document.querySelectorAll('span[data-module="wopi-link"]')
.forEach(function (el) {
createApp({
template: '<open-wopi-link :wopiUrl="wopiUrl" :title="title" :type="type" :button="button"></open-wopi-link>',
components: {
OpenWopiLink
},
data() {
return {
wopiUrl: el.dataset.wopiUrl,
title: el.dataset.docTitle,
type: el.dataset.docType,
button: el.dataset.button ? JSON.parse(el.dataset.button) : {}
}
}
})
.use(i18n)
.mount(el)
;
})
;
});

View File

@ -0,0 +1,30 @@
import {ShowHide} from 'ChillMainAssets/lib/show_hide/show_hide.js';
window.addEventListener('DOMContentLoaded', function() {
let
finalizeAfterContainer = document.querySelector('#finalizeAfter'),
futureDestUsersContainer = document.querySelector('#futureDestUsers')
;
if (null === finalizeAfterContainer) {
return;
}
new ShowHide({
load_event: null,
froms: [finalizeAfterContainer],
container: [futureDestUsersContainer],
test: function(containers, arg2, arg3) {
for (let container of containers) {
for (let input of container.querySelectorAll('input')) {
if (!input.checked) {
return true;
} else {
return false;
}
}
}
},
})
});

View File

@ -1,5 +1,5 @@
<template>
<ul class="list-suggest remove-items">
<ul class="list-suggest remove-items" v-if="picked.length">
<li v-for="p in picked" @click="removeEntity(p)" :key="p.type+p.id">
<span class="chill_denomination">{{ p.text }}</span>
</li>
@ -79,11 +79,15 @@ export default {
);
this.$refs.addPersons.resetSearch(); // to cast child method
modal.showModal = false;
console.log(this.picked)
},
removeEntity(entity) {
console.log('remove entity', entity);
this.$emit('removeEntity', entity);
}
},
mounted() {
console.log(this.picked);
}
}
</script>

View File

@ -0,0 +1,101 @@
<template>
<div class="d-grid gap-2 my-3">
<button class="btn btn-misc" type="button" v-if="!subscriberFinal" @click="subscribeTo('subscribe', 'final')">
<i class="fa fa-check fa-fw"></i>
{{ $t('subscribe_final') }}
</button>
<button class="btn btn-misc" type="button" v-if="subscriberFinal" @click="subscribeTo('unsubscribe', 'final')">
<i class="fa fa-times fa-fw"></i>
{{ $t('unsubscribe_final') }}
</button>
<button class="btn btn-misc" type="button" v-if="!subscriberStep" @click="subscribeTo('subscribe', 'step')">
<i class="fa fa-check fa-fw"></i>
{{ $t('subscribe_all_steps') }}
</button>
<button class="btn btn-misc" type="button" v-if="subscriberStep" @click="subscribeTo('unsubscribe', 'step')">
<i class="fa fa-times fa-fw"></i>
{{ $t('unsubscribe_all_steps') }}
</button>
</div>
</template>
<script>
import {makeFetch} from 'ChillMainAssets/lib/api/apiMethods.js';
export default {
name: "EntityWorkflowVueSubscriber",
i18n: {
messages: {
fr: {
subscribe_final: "Recevoir une notification à l'étape finale",
unsubscribe_final: "Ne plus recevoir de notification à l'étape finale",
subscribe_all_steps: "Recevoir une notification à chaque étape du suivi",
unsubscribe_all_steps: "Ne plus recevoir de notification à chaque étape du suivi",
}
}
},
props: {
entityWorkflowId: {
type: Number,
required: true,
},
subscriberStep: {
type: Boolean,
required: true,
},
subscriberFinal: {
type: Boolean,
required: true,
},
},
emits: ['subscriptionUpdated'],
methods: {
subscribeTo(step, to) {
let params = new URLSearchParams();
params.set('subscribe', to);
const url = `/api/1.0/main/workflow/${this.entityWorkflowId}/${step}?` + params.toString();
makeFetch('POST', url).then(response => {
this.$emit('subscriptionUpdated', response);
});
}
},
}
/*
* ALTERNATIVES
*
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="laststep">
<label class="form-check-label" for="laststep">{{ $t('subscribe_final') }}</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="allsteps">
<label class="form-check-label" for="allsteps">{{ $t('subscribe_all_steps') }}</label>
</div>
<div class="list-group my-3">
<label class="list-group-item">
<input class="form-check-input me-1" type="checkbox" value="">
{{ $t('subscribe_final') }}
</label>
<label class="list-group-item">
<input class="form-check-input me-1" type="checkbox" value="">
{{ $t('subscribe_all_steps') }}
</label>
</div>
<div class="btn-group-vertical my-3" role="group">
<button type="button" class="btn btn-outline-primary">
<i class="fa fa-check fa-fw"></i>
{{ $t('subscribe_final') }}
</button>
<button type="button" class="btn btn-outline-primary">
<i class="fa fa-check fa-fw"></i>
{{ $t('subscribe_all_steps') }}
</button>
</div>
*/
</script>
<style scoped></style>

View File

@ -0,0 +1,36 @@
<template>
<div class="list-group my-2 workflow workflow-box">
<div class="list-group-item">
<h4>Workflow associés</h4>
</div>
<div class="list-group-item" v-for="w in workflows">
{{ w.id }}
<ul class="record_actions">
<li>
<a class="btn btn-sm btn-outline-primary"
title="voir"
:href="goToUrl(w)">
<i class="fa fa-eye fa-fw"></i>
</a>
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
name: "ListWorkflow",
props: {
workflows: {
type: Array,
required: true,
}
},
methods: {
goToUrl(w) {
return `/fr/main/workflow/${w.id}/show`;
}
}
}
</script>

View File

@ -0,0 +1,50 @@
<template>
<template v-if="workflowsAvailables.length >= 1">
<div class="dropdown d-grid gap-2">
<button class="btn btn-primary dropdown-toggle" type="button" id="createWorkflowButton" data-bs-toggle="dropdown" aria-expanded="false">
Créer un workflow
</button>
<ul class="dropdown-menu" aria-labelledby="createWorkflowButton">
<li v-for="w in workflowsAvailables" :key="w.name">
<a class="dropdown-item" :href="makeLink(w.name)" @click="goToGenerateWorkflow($event, w.name)">{{ w.text }}</a>
</li>
</ul>
</div>
</template>
</template>
<script>
import {buildLinkCreate} from 'ChillMainAssets/lib/entity-workflow/api.js';
export default {
name: "PickWorkflow",
props: {
relatedEntityClass: {
type: String,
required: true,
},
relatedEntityId: {
type: Number,
required: false,
},
workflowsAvailables: {
type: Array,
required: true,
}
},
emits: ['goToGenerateWorkflow'],
methods: {
makeLink(workflowName) {
return buildLinkCreate(workflowName, this.relatedEntityClass, this.relatedEntityId);
},
goToGenerateWorkflow(event, workflowName) {
this.$emit('goToGenerateWorkflow', {event, workflowName, link: this.makeLink(workflowName)});
}
}
}
</script>
<style scoped>
</style>

View File

@ -15,7 +15,7 @@
<div class="modal-body">
<slot name="body"></slot>
</div>
<div class="modal-footer">
<div class="modal-footer" v-if="!hideFooter">
<button class="btn btn-cancel" @click="$emit('close')">{{ $t('action.close') }}</button>
<slot name="footer"></slot>
</div>
@ -38,7 +38,16 @@
*/
export default {
name: 'Modal',
props: ['modalDialogClass'],
props: {
modalDialogClass: {
type: String,
required: false
},
hideFooter: {
type: Boolean,
required: false
}
},
emits: ['close']
}
</script>

View File

@ -0,0 +1,243 @@
<template>
<a v-if="isOpenDocument"
class="btn change-icon" :class="[isChangeClass ? button.changeClass : 'btn-edit']"
@click="openModal">
<i class="fa me-2" :class="[isChangeIcon ? button.changeIcon : 'fa-pencil']"></i>
<span v-if="!noText">
{{ $t('Update_document') }}
</span>
</a>
<teleport to="body">
<div class="wopi-frame" v-if="isOpenDocument">
<modal v-if="modal.showModal"
:modalDialogClass="modal.modalDialogClass"
:hideFooter=true
@close="modal.showModal = false">
<template v-slot:header>
<img class="logo" :src="logo" height="45"/>
<span class="ms-auto me-3">
{{ this.title }}
</span>
<a class="btn btn-outline-light">
<i class="fa fa-save fa-fw"></i>
{{ $t('save_and_quit') }}
</a>
</template>
<template v-slot:body>
<div v-if="loading" class="loading">
<i class="fa fa-circle-o-notch fa-spin fa-3x" :title="$t('loading')"></i>
</div>
<iframe
:src="this.wopiUrl"
@load="loaded"
></iframe>
</template>
</modal>
</div>
<div v-else>
<modal v-if="modal.showModal"
modalDialogClass="modal-sm"
@close="modal.showModal = false">
<template v-slot:header>
<h3>{{ $t('invalid_title') }}</h3>
</template>
<template v-slot:body>
<div class="alert alert-warning">{{ $t('invalid_message') }}</div>
</template>
</modal>
</div>
</teleport>
</template>
<script>
import Modal from 'ChillMainAssets/vuejs/_components/Modal';
import logo from 'ChillMainAssets/chill/img/logo-chill-sans-slogan_white.png';
export default {
name: "OpenWopiLink",
components: {
Modal
},
props: {
wopiUrl: {
type: String,
required: true
},
title: {
type: String,
required: true
},
type: {
type: String,
required: true
},
button: {
type: Object,
required: false
}
},
data() {
return {
modal: {
showModal: false,
modalDialogClass: "modal-fullscreen" //modal-dialog-scrollable
},
logo: logo,
loading: false,
mime: [
// TODO temporary hardcoded. to be replaced by twig extension or a collabora server query
'application/clarisworks',
'application/coreldraw',
'application/macwriteii',
'application/msword',
'application/pdf',
'application/vnd.lotus-1-2-3',
'application/vnd.ms-excel',
'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
'application/vnd.ms-excel.sheet.macroEnabled.12',
'application/vnd.ms-excel.template.macroEnabled.12',
'application/vnd.ms-powerpoint',
'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
'application/vnd.ms-powerpoint.template.macroEnabled.12',
'application/vnd.ms-visio.drawing',
'application/vnd.ms-word.document.macroEnabled.12',
'application/vnd.ms-word.template.macroEnabled.12',
'application/vnd.ms-works',
'application/vnd.oasis.opendocument.chart',
'application/vnd.oasis.opendocument.formula',
'application/vnd.oasis.opendocument.graphics',
'application/vnd.oasis.opendocument.graphics-flat-xml',
'application/vnd.oasis.opendocument.graphics-template',
'application/vnd.oasis.opendocument.presentation',
'application/vnd.oasis.opendocument.presentation-flat-xml',
'application/vnd.oasis.opendocument.presentation-template',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.spreadsheet-flat-xml',
'application/vnd.oasis.opendocument.spreadsheet-template',
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.text-flat-xml',
'application/vnd.oasis.opendocument.text-master',
'application/vnd.oasis.opendocument.text-master-template',
'application/vnd.oasis.opendocument.text-template',
'application/vnd.oasis.opendocument.text-web',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
'application/vnd.openxmlformats-officedocument.presentationml.template',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
'application/vnd.sun.xml.calc',
'application/vnd.sun.xml.calc.template',
'application/vnd.sun.xml.chart',
'application/vnd.sun.xml.draw',
'application/vnd.sun.xml.draw.template',
'application/vnd.sun.xml.impress',
'application/vnd.sun.xml.impress.template',
'application/vnd.sun.xml.math',
'application/vnd.sun.xml.writer',
'application/vnd.sun.xml.writer.global',
'application/vnd.sun.xml.writer.template',
'application/vnd.visio',
'application/vnd.visio2013',
'application/vnd.wordperfect',
'application/x-abiword',
'application/x-aportisdoc',
'application/x-dbase',
'application/x-dif-document',
'application/x-fictionbook+xml',
'application/x-gnumeric',
'application/x-hwp',
'application/x-iwork-keynote-sffkey',
'application/x-iwork-numbers-sffnumbers',
'application/x-iwork-pages-sffpages',
'application/x-mspublisher',
'application/x-mswrite',
'application/x-pagemaker',
'application/x-sony-bbeb',
'application/x-t602',
]
}
},
computed: {
isOpenDocument() {
if (this.mime.indexOf(this.type) !== -1) {
return true;
}
return false;
},
noText() {
if (typeof this.button.noText !== 'undefined') {
return this.button.noText === true;
}
return false;
},
isChangeIcon() {
if (typeof this.button.changeIcon !== 'undefined') {
return (!(this.button.changeIcon === null || this.button.changeIcon === ''))
}
return false;
},
isChangeClass() {
if (typeof this.button.changeClass !== 'undefined') {
return (!(this.button.changeClass === null || this.button.changeClass === ''))
}
return false;
}
},
methods: {
openModal() {
this.loading = true;
this.modal.showModal = true;
},
loaded() {
this.loading = false;
}
},
i18n: {
messages: {
fr: {
Update_document: "Modifier le document",
save_and_quit: "Enregistrer et quitter",
loading: "Chargement de l'éditeur en ligne",
invalid_title: "Format incompatible",
invalid_message: "Désolé, ce format de document n'est pas éditable en ligne.",
}
}
}
}
</script>
<style lang="scss">
div.wopi-frame {
div.modal-header {
border-bottom: 0;
background-color: var(--bs-primary);
color: white;
}
div.modal-body {
padding: 0;
overflow-y: unset !important;
iframe {
height: 100%;
width: 100%;
}
div.loading {
position: absolute;
color: var(--bs-chill-gray);
top: calc(50% - 30px);
left: calc(50% - 30px);
}
}
}
</style>

View File

@ -3,7 +3,7 @@
{% block navigation_search_bar %}{% endblock %}
{% block navigation_section_menu %}
{{ chill_menu('admin_section', {
'layout': '@ChillMain/Menu/adminSection.html.twig',
'layout': '@ChillMain/Menu/admin.html.twig',
}) }}
{% endblock %}

View File

@ -23,23 +23,23 @@
{% if options['metadata'] %}
<div class="metadata">
{% if user is not empty %}
{{ 'Last updated by'| trans }}
<span class="user">
{{ user|chill_entity_render_box(options['user']) }}
</span>
{% endif %}
{% if comment.date is not empty %}
{% if user is empty %}
{{ 'Last updated on'|trans ~ ' ' }}
{% else %}
{{ 'on'|trans ~ ' ' }}
{% endif %}
{{ 'Last updated on'|trans ~ ' ' }}
<span class="date">
{{ comment.date|format_datetime("medium", "short") }}
</span>
{% endif %}
{% if user is not empty %}
{% if comment.date is empty %}
{{ 'Last updated by'| trans }}
{% else %}
{{ 'by_user'|trans ~ ' ' }}
{% endif %}
<span class="user">
{{ user|chill_entity_render_box(options['user']) }}
</span>
{% endif %}
</div>
{% endif %}
</blockquote>
{{ closing_box|raw }}
{{ closing_box|raw }}

View File

@ -216,7 +216,7 @@
{% endif %}
{% endblock %}
{% block pick_user_dynamic_widget %}
{% block pick_entity_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

@ -21,13 +21,13 @@
layout ../layoutWithVerticalMenu.html.twig.
#}
<ul class="tab-nav follow-href-path">
<li class="title">
<div class="list-group vertical-menu {{ 'menu-' ~ menus.name }}">
<a class="list-group-item title">
{% block v_menu_title %}<!-- title of the verticalMenu is empty -->{% endblock %}
</li>
</a>
{% for menu in menus %}
<li class="{% if menu is knp_menu_current %}current {% endif %}">
<a href="{{ menu.uri }}" >{{ menu.label|trans }}</a>
</li>
<a class="list-group-item list-group-item-action" href="{{ menu.uri }}">
{{ menu.label|upper }}
</a>
{% endfor %}
</ul>
</div>

View File

@ -2,7 +2,7 @@
<div class="item-row title">
<h2 class="notification-title">
<a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}">
{{ 'notification.object_prefix'|trans ~ c.notification.title }}
{{ c.notification.title }}
</a>
</h2>
</div>
@ -58,14 +58,15 @@
</div>
<div class="item-row">
<div class="notification-content">
{% if c.full_content is defined and c.full_content == 'true' %}
{% if c.full_content is defined and c.full_content == true %}
{{ c.notification.message|chill_markdown_to_html }}
{% else %}
{{ c.notification.message|u.truncate(250, '…', false)|chill_markdown_to_html }}
<p class="read-more"><a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}">{{ 'Read more'|trans }}</a></p>
{% endif %}
</div>
</div>
{% if c.action_button is not defined or c.action_button != 'false' %}
{% if c.action_button is not defined or c.action_button != false %}
<div class="item-row separator">
<ul class="record_actions">
<li>
@ -85,7 +86,13 @@
{% if is_granted('CHILL_MAIN_NOTIFICATION_SEE', c.notification) %}
<li>
<a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}"
class="btn btn-show change-icon" title="{{ 'notification.see_comments_thread'|trans }}"><i class="fa fa-comment"></i></a>
class="btn {% if not c.notification.isSystem %}btn-show change-icon{% else %}btn-misc{% endif %}" title="{{ 'notification.see_comments_thread'|trans }}">
{% if not c.notification.isSystem() %}
<i class="fa fa-comment"></i>
{% else %}
{{ 'Read more'|trans }}
{% endif %}
</a>
</li>
{% endif %}
</ul>
@ -95,7 +102,7 @@
<div class="item-bloc notification-status {% if notification.isReadBy(app.user) %}read{% else %}unread{% endif %}">
{% if fold_item is defined and fold_item != 'false' %}
{% if fold_item is defined and fold_item != false %}
<div class="accordion-header" id="flush-heading-{{ notification.id }}">
<button type="button" class="accordion-button collapsed"
data-bs-toggle="collapse" data-bs-target="#flush-collapse-{{ notification.id }}"

View File

@ -21,8 +21,6 @@
{{ 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">
@ -30,6 +28,8 @@
</div>
</div>
{% include handler.template(notification) with handler.templateData(notification) %}
{{ form_end(form) }}
<ul class="record_actions sticky-form-buttons">

View File

@ -0,0 +1,12 @@
<div class="notification-counter">
{% if counter.total > 0 %}
<span>
{{ 'notification.counter total notifications'|trans({'total': counter.total }) }}
</span>
{% endif %}
{% if counter.unread > 0 %}
<span>
{{ 'notification.counter unread notifications'|trans({'unread': counter.unread }) }}
</span>
{% endif %}
</div>

View File

@ -18,7 +18,7 @@
data-notification-id="{{ notification.id }}"
data-notification-current-is-read="{{ notification.isReadBy(app.user) }}"
data-container="notification-status"
data-show-button-url="{{ chill_path_add_return_path('chill_main_notification_show', {'id': notification.id}) }}"
data-show-button-url="{{ chill_path_add_return_path('chill_main_notification_show', {'id': notification.id}, false) }}"
data-button-class="btn-outline-primary"
data-button-text="false"
></span>

View File

@ -50,7 +50,7 @@
{% for data in datas %}
{% set notification = data.notification %}
{% include 'ChillMainBundle:Notification:_list_item.html.twig' with {
'fold_item': 'true'
'fold_item': true
} %}
{% endfor %}
</div>

View File

@ -40,8 +40,8 @@
'template': handler.getTemplate(notification),
'template_data': handler.getTemplateData(notification)
},
'action_button': 'false',
'full_content': 'true'
'action_button': false,
'full_content': true
} %}
</div>

View File

@ -0,0 +1,120 @@
{# TODO Adapt condition #}
{% if random(1) == 0 %}
{# For a document #}
<h2>{{ 'Document'|trans ~ 'target'|trans }}</h2>
<div class="row justify-content-center mt-5">
<div class="col-2">
<i class="fa fa-4x fa-file-text-o text-success"></i>
</div>
<div class="col-8">
<h3>Imprimé unique, parcours n°14635</h3>
<small>Document PDF (6.2 Mo)</small>
<p class="mt-2">
Description du document. Sed euismod nisi porta lorem mollis aliquam. Non curabitur gravida arcu ac tortor.
</p>
</div>
</div>
{% else %}
{# For an action #}
<h2>{{ 'Accompanying Course Action'|trans ~ 'target'|trans }}</h2>
<div class="flex-table accompanying_course_work-list">
{# dynamic insertion
::: TODO delete all static insertion, remove condition and pass work object in inclusion
#}{% if dynamic is defined %}
{% set work = '<pass work object here>' %}
{% include '@ChillPerson/AccompanyingCourseWork/_item.html.twig' with { 'w': work } %}
{% else %}
{# BEGIN static insertion #}
<div class="item-bloc">
<div class="item-row">
<h2 class="badge-title">
<span class="title_label"></span>
<span class="title_action">Exercer un AEB &gt; Conclure l'AEB
<ul class="small_in_title columns mt-1">
<li><span class="item-key">Date de début : </span><b>25/11/2021</b></li>
<li><span class="item-key">Date de fin : </span><b>10/03/2022</b></li>
</ul>
</span>
</h2>
</div>
<div class="item-row separator">
<div class="wrap-list">
<div class="wl-row">
<div class="wl-col title"><h3>Référent</h3></div>
<div class="wl-col list"><p class="wl-item">Fred</p></div>
</div>
<div class="wl-row">
<div class="wl-col title"><h3>Usagers du parcours</h3></div>
<div class="wl-col list"><span class="wl-item">
<span class="onthefly-container" data-target-name="person" data-target-id="1937" data-action="show" data-button-text="Vernon SUBUTEX" data-display-badge="true" data-v-app=""><a data-v-0c1a1125=""><span class="chill-entity entity-person badge-person" data-v-0c1a1125="">Vernon SUBUTEX</span></a><!--teleport start--><!--teleport end--></span></span>
<span class="wl-item"><span class="onthefly-container" data-target-name="person" data-target-id="1941" data-action="show" data-button-text="Juan RAMON" data-display-badge="true" data-v-app=""><a data-v-0c1a1125=""><span class="chill-entity entity-person badge-person" data-v-0c1a1125="">Juan RAMON</span></a><!--teleport start--><!--teleport end--></span></span>
</div>
</div>
<div class="wl-row">
<div class="wl-col title"><h3>Problématique sociale</h3></div>
<div class="wl-col list">
<p class="wl-item social-issues">
<span class="chill-entity entity-social-issue"><span class="badge bg-chill-l-gray text-dark"><span class="parent-0">AD - PREVENTION, ACCES AUX DROITS, BUDGET &gt;</span><span class="child">SOUTIEN EQUILIBRE BUDGET</span></span></span>
</p>
</div>
</div>
</div>
</div>
<div class="item-row column">
<table class="obj-res-eval my-3">
<thead>
<tr><th class="obj"><h4 class="title_label">Objectif - motif - dispositif</h4></th>
<th class="res"><h4 class="title_label">Résultats - orientations</h4></th>
</tr></thead>
<tbody>
<tr>
<td class="obj">
<p class="chill-no-data-statement">Aucun objectif - motif - dispositif</p>
</td>
<td class="res">
<ul class="result_list">
<li>Résultat : Arrêt à l'initiative du ménage pour déménagement</li>
<li>Orientation vers une MASP</li>
</ul>
</td>
</tr>
</tbody>
</table>
</div>
<div class="item-row separator">
<div class="updatedBy">
Dernière mise à jour par
<b><span class="chill-entity entity-user">Fred<span class="user-job">(Responsable tous les territoires)</span><span class="main-scope">(ASE)</span></span></b>,<br>
le 3 décembre 2021 à 15:19
</div>
</div>
</div>
{# END static insertion #}
{% endif %}
</div>
{% endif %}
<ul class="record_actions">
<li>
<button type="button" class="btn btn-misc">
<i class="fa fa-download fa-fw"></i>{{ 'Download'|trans }}
</button>
</li>
<li>
{% set x = random(1) %}
<button class="btn btn-update change-icon {% if x == 1 %}disabled{% endif %}">
<i class="fa fa-fw fa-{% if x == 0 %}un{% endif %}lock"></i>
{{ 'Edit'|trans }}
</button>
</li>
</ul>

View File

@ -0,0 +1,13 @@
<h2>{{ 'Join a comment'|trans }}</h2>
{{ form_start(comment_form) }}
{{ form_widget(comment_form.comment) }}
<ul class="record_actions">
<li>
<button type="submit" class="btn btn-save">{{ 'Save'|trans }}</button>
</li>
</ul>
{{ form_end(comment_form) }}

View File

@ -0,0 +1,48 @@
<h2>{{ 'Decision'|trans }}</h2>
{% if transition_form is not null %}
{{ form_start(transition_form) }}
{{ form_row(transition_form.transition) }}
<div id="finalizeAfter">
{{ form_row(transition_form.finalizeAfter) }}
</div>
{% if transition_form.freezeAfter is defined %}
{{ form_row(transition_form.freezeAfter) }}
{% endif %}
<div id="futureDestUsers">
{{ form_row(transition_form.future_dest_users) }}
</div>
<p>{{ form_label(transition_form.comment) }}</p>
{{ form_widget(transition_form.comment) }}
<ul class="record_actions">
<li>
<button type="submit" class="btn btn-save">{{ 'Save'|trans }}</button>
</li>
</ul>
{{ form_end(transition_form) }}
{% else %}
<div class="alert alert-chill-yellow">
{% if entity_workflow.currentStep.isFinalizeAfter %}
<p>{{ 'workflow.This workflow is finalized'|trans }}</p>
{% else %}
<p>{{ 'workflow.You are not allowed to apply a transition on this workflow'|trans }}</p>
<p>{{ 'workflow.Only those users are allowed'|trans }}:</p>
<ul>
{% for u in entity_workflow.currentStep.destUser -%}
<li>{{ u|chill_entity_render_box }}</li>
{%- endfor %}
</ul>
{% endif %}
</div>
{% endif %}

View File

@ -0,0 +1,13 @@
{% if is_granted('CHILL_MAIN_WORKFLOW_CREATE', blank_workflow) %}
{# vue component #}
<div data-pick-workflow="1"
data-related-entity-class="{{ blank_workflow.relatedEntityClass }}"
data-related-entity-id="{{ blank_workflow.relatedEntityId }}"
data-workflows-availables="{{ workflows_availables|json_encode()|e('html_attr') }}"
></div>
{% endif %}
{% if entity_workflows|length > 0 %}
{# vue component #}
<div data-list-workflows="1" data-workflows="{{ entity_workflows_json|json_encode|e('html_attr') }}"></div>
{% endif %}

View File

@ -0,0 +1,8 @@
<h2>{{ 'Follow workflow'|trans }}</h2>
{# vue component #}
<div data-entity-workflow-subscribe="1"
data-entity-workflow-id="{{ entity_workflow.id }}"
data-subscribe-step="{{ entity_workflow.isUserSubscribedToStep(app.user)|e('html_attr') }}"
data-subscribe-final="{{ entity_workflow.isUserSubscribedToFinal(app.user)|e('html_attr') }}"
></div>

View File

@ -0,0 +1,59 @@
<h2>{{ 'Workflow history'|trans }}</h2>
<div class="flex-table">
{% for step in entity_workflow.stepsChained %}
<div class="item-bloc {{ 'bloc' ~ step.id }} {% if loop.first %}initial{% endif %}">
<div class="item-row">
{% if loop.first and step.next is null %}
<div class="item-col">
{{ 'workflow.No transitions'|trans }}
</div>
{% endif %}
<div class="item-col flex-column align-items-end">
<div class="decided">
{% if not loop.first %}
<i class="fa fa-check fa-fw text-success"></i>
{% endif %}
{{ step.currentStep }}
</div>
{#
<div class="decided">
<i class="fa fa-times fa-fw text-danger"></i>
Refusé
</div>
#}
</div>
</div>
{% if step.next is not null %}
<div class="item-row separator">
<div class="item-col" style="width: inherit;">
{% if step.transitionBy is not null %}
<div>
{{ step.transitionBy|chill_entity_render_box }}
</div>
{% endif %}
<div>
<span>{{ step.transitionAt|format_datetime('long', 'medium') }}</span>
</div>
</div>
<div class="item-col flex-column align-items-end">
<div class="to-decision">
<i class="fa fa-share fa-fw text-secondary" title="transféré"></i>
{{ step.next.currentStep }}
</div>
</div>
</div>
{% endif %}
{% if step.comment is not empty %}
<div class="item-row separator">
<blockquote class="chill-user-quote col">
{{ step.comment|chill_markdown_to_html }}
</blockquote>
</div>
{% endif %}
</div>
{% endfor %}
</div>

View File

@ -0,0 +1,12 @@
<div class="item-row col">
<h2>
{{ 'workflow_'|trans }}
</h2>
{% include handler.templateTitle(l.entity_workflow) with handler.templateTitleData(entity_workflow)|merge({
'description': true,
'breadcrumb': true,
'add_classes': 'ms-3 h3'
}) %}
</div>

View File

@ -0,0 +1,56 @@
{% extends '@ChillMain/layout.html.twig' %}
{% block title %}
{{ 'Workflow'|trans }}
{% endblock %}
{% block js %}
{{ parent() }}
{{ encore_entry_script_tags('mod_async_upload') }}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{{ encore_entry_script_tags('mod_entity_workflow_subscribe') }}
{{ encore_entry_script_tags('page_workflow_show') }}
{{ encore_entry_script_tags('mod_wopi_link') }}
{% endblock %}
{% block css %}
{{ parent() }}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{{ encore_entry_link_tags('mod_entity_workflow_subscribe') }}
{{ encore_entry_link_tags('page_workflow_show') }}
{{ encore_entry_link_tags('mod_wopi_link') }}
{% endblock %}
{% block content %}
<div class="col-10 workflow">
<h1 class="mb-5">{{ block('title') }}</h1>
{# handler_template:
- src/Bundle/ChillPersonBundle/Resources/views/Workflow/_evaluation.html.twig
- src/Bundle/ChillPersonBundle/Resources/views/Workflow/_accompanying_period_work.html.twig
- src/Bundle/ChillDocStoreBundle/Resources/views/AccompanyingCourseDocument/_workflow.html.twig
#}
<section class="step my-4">
<div class="mb-5">
{% include handler_template_title with handler_template_data|merge({'breadcrumb': true }) %}
</div>
{% include handler_template with handler_template_data|merge({'display_action': true }) %}
</section>
<section class="step my-4">{% include '@ChillMain/Workflow/_follow.html.twig' %}</section>
<section class="step my-4">{% include '@ChillMain/Workflow/_decision.html.twig' %}</section>{#
<section class="step my-4">{% include '@ChillMain/Workflow/_comment.html.twig' %}</section> #}
<section class="step my-4">{% include '@ChillMain/Workflow/_history.html.twig' %}</section>
{# useful ?
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a class="btn btn-cancel" href="{{ path('chill_main_workflow_list_dest') }}">
{{ 'Back to the list'|trans }}
</a>
</li>
</ul>
#}
</div>
{% endblock %}

View File

@ -0,0 +1,97 @@
{% extends 'ChillMainBundle::layout.html.twig' %}
{% import '@ChillMain/Workflow/macro_breadcrumb.html.twig' as macro %}
{% block title %}
{{ 'workflow.My workflows'|trans }}
{% endblock %}
{% block content %}
<div class="col-10 workflow">
<h1 class="mb-5">{{ block('title') }}</h1>
<ul class="nav nav-pills justify-content-center">
<li class="nav-item">
<a href="{{ path('chill_main_workflow_list_subscribed') }}"
class="nav-link {% if step == 'subscribed' %}active{% endif %}">
{{ 'workflow.subscribed'|trans }}
</a>
</li>
<li class="nav-item">
<a href="{{ path('chill_main_workflow_list_dest') }}"
class="nav-link {% if step == 'dest' %}active{% endif %}">
{{ 'workflow.dest'|trans }}
</a>
</li>
</ul>
{% if workflows|length == 0 %}
<p class="chill-no-data-statement">{{ 'workflow.No workflow'|trans }}</p>
{% else %}
<div class="flex-table accordion accordion-flush" id="workflow-fold">
{% for l in workflows %}
<div class="item-bloc">
<div class="accordion-header" id="flush-heading-{{ l.entity_workflow.id }}">
<button type="button" class="accordion-button collapsed"
data-bs-toggle="collapse" data-bs-target="#flush-collapse-{{ l.entity_workflow.id }}"
aria-expanded="false" aria-controls="flush-collapse-{{ l.entity_workflow.id }}">
<div class="item-row col">
<h2>
{{ 'workflow_'|trans }}
</h2>
{% include l.handler.templateTitle(l.entity_workflow) with l.handler.templateTitleData(l.entity_workflow)|merge({
'description': true,
'add_classes': 'ms-3 h3'
}) %}
</div>
</button>
{{ macro.breadcrumb(l) }}
</div>
<div id="flush-collapse-{{ l.entity_workflow.id }}"
class="accordion-collapse collapse"
aria-labelledby="flush-heading-{{ l.entity_workflow.id }}"
data-bs-parent="#workflow-fold">
<div class="item-row flex-column">
{% include l.handler.template(l.entity_workflow) with l.handler.templateData(l.entity_workflow)|merge({
'display_action': false
}) %}
</div>
<div class="item-row">
<div class="item-col flex-grow-1">
<p>
{% if l.entity_workflow.isUserSubscribedToStep(app.user) %}
<i class="fa fa-check fa-fw"></i>
{{ 'workflow.you subscribed to all steps'|trans }}
{% endif %}
</p>
<p>
{% if l.entity_workflow.isUserSubscribedToFinal(app.user) %}
<i class="fa fa-check fa-fw"></i>
{{ 'workflow.you subscribed to final step'|trans }}
{% endif %}
</p>
</div>
<div class="item-col">
<ul class="record_actions">
<li>
<a href="{{ path('chill_main_workflow_show', {'id': l.entity_workflow.id}) }}"
class="btn btn-show">
{{ 'Show'|trans }}
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,45 @@
{% macro popoverContent(step) %}
<ul class="small_in_title">
<li>
<span class="item-key">{{ 'By'|trans ~ ' : ' }}</span>
<b>{{ step.transitionBy|chill_entity_render_box }}</b>
</li>
<li>
<span class="item-key">{{ 'Le'|trans ~ ' : ' }}</span>
<b>{{ step.transitionAt|format_datetime('short', 'short') }}</b>
</li>
</ul>
{% endmacro %}
{% macro breadcrumb(_ctx) %}
<div class="breadcrumb">
{% for step in _ctx.entity_workflow.stepsChained %}
{% if step.previous is null %}
{#
{% set popContent = "Point de départ du workflow" %}
{{ dump(step) }}
#}
{% set popContent = _self.popoverContent(step) %}
{% else %}
{% set popContent = _self.popoverContent(step.previous) %}
{% endif %}
<span class="mx-2"
tabindex="0"
data-bs-trigger="focus hover"
data-bs-toggle="popover"
data-bs-placement="bottom"
data-bs-custom-class="workflow-transition"
title="{{ step.currentStep }}"
data-bs-content="{{ popContent|e('html_attr') }}"
>
{% if step.currentStep == 'initial' %}
<i class="fa fa-circle me-1 text-chill-yellow"></i>
{% endif %}
{{ step.currentStep }}
</span>
{% if not loop.last %}
{% endif %}
{% endfor %}
</div>
{% endmacro %}

View File

@ -0,0 +1,13 @@
{{ dest.label }},
Un suivi "{{ workflow.text }}" a atteint une nouvelle étape: {{ workflow.text }}
{%- if is_dest %}
Vous êtes invités à valider cette étape au plus tôt.
{% endif %}
Vous pouvez visualiser le workflow sur cette page:
{{ absolute_url(path('chill_main_workflow_show', {'id': entity_workflow.id})) }}
Cordialement,

View File

@ -0,0 +1,5 @@
{%- if is_dest -%}
Un suivi {{ workflow.text }} demande votre attention
{%- else -%}
Un suivi {{ workflow.text }} a atteint une nouvelle étape: {{ place.text }}
{%- endif -%}

View File

@ -69,6 +69,15 @@ class UserMenuBuilder implements LocalMenuBuilderInterface
'counter' => $nbNotifications,
]);
$menu
->addChild(
$this->translator->trans('workflow.My workflows'),
['route' => 'chill_main_workflow_list_dest']
)
->setExtras([
'order' => 700,
]);
$menu
->addChild(
'Change password',

View File

@ -0,0 +1,70 @@
<?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\Workflow\EntityWorkflow;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Security;
use UnexpectedValueException;
use function in_array;
class EntityWorkflowVoter extends Voter
{
public const CREATE = 'CHILL_MAIN_WORKFLOW_CREATE';
public const SEE = 'CHILL_MAIN_WORKFLOW_SEE';
private EntityWorkflowManager $manager;
private Security $security;
public function __construct(EntityWorkflowManager $manager, Security $security)
{
$this->manager = $manager;
$this->security = $security;
}
protected function supports($attribute, $subject)
{
return $subject instanceof EntityWorkflow && in_array($attribute, self::getRoles(), true);
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
switch ($attribute) {
case self::CREATE:
case self::SEE:
$handler = $this->manager->getHandler($subject);
$entityAttribute = $handler->getRoleShow($subject);
if (null === $entityAttribute) {
return true;
}
return $this->security->isGranted($entityAttribute, $handler->getRelatedEntity($subject));
default:
throw new UnexpectedValueException("attribute {$attribute} not supported");
}
}
private static function getRoles(): array
{
return [
self::SEE,
self::CREATE,
];
}
}

View File

@ -0,0 +1,88 @@
<?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\Serializer\Normalizer;
use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\UserRepository;
use DateTime;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use function array_key_exists;
class CommentEmbeddableDocGenNormalizer implements ContextAwareNormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
private UserRepository $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
/**
* @param CommentEmbeddable $object
*
* @throws ExceptionInterface
*/
public function normalize($object, ?string $format = null, array $context = []): array
{
if (null === $object) {
return [
'comment' => '',
'isNull' => true,
'date' => $this->normalizer->normalize(null, $format, array_merge($context, [
'docgen:expects' => DateTime::class,
])),
'user' => $this->normalizer->normalize(null, $format, array_merge($context, [
'docgen:expects' => User::class,
])),
];
}
$user = $this->userRepository->find($object->getUserId());
return [
'comment' => (string) $object->getComment(),
'isNull' => false,
'date' => $this->normalizer->normalize($object->getDate(), $format, array_merge($context, [
'docgen:expects' => DateTime::class,
])),
'user' => $this->normalizer->normalize($user, $format, array_merge($context, [
'docgen:expects' => User::class,
])),
];
}
public function supportsNormalization($data, ?string $format = null, array $context = []): bool
{
if ('docgen' !== $format) {
return false;
}
if ($data instanceof CommentEmbeddable) {
return true;
}
if (
null === $data
&& array_key_exists('docgen:expects', $context)
&& CommentEmbeddable::class === $context['docgen:expects']) {
return true;
}
return false;
}
}

View File

@ -0,0 +1,50 @@
<?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\Service\Mailer;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\RawMessage;
class ChillMailer implements MailerInterface
{
private LoggerInterface $chillLogger;
private MailerInterface $initial;
private string $prefix = '[Chill] ';
public function __construct(MailerInterface $initial, LoggerInterface $chillLogger)
{
$this->initial = $initial;
$this->chillLogger = $chillLogger;
}
public function send(RawMessage $message, ?Envelope $envelope = null): void
{
if ($message instanceof Email) {
$message->subject($this->prefix . $message->getSubject());
}
$this->chillLogger->info('chill email sent', [
'to' => array_map(static function (Address $address) {
return $address->getAddress();
}, $message->getTo()),
'subject' => $message->getSubject(),
]);
$this->initial->send($message, $envelope);
}
}

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\Tests\Entity\Workflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use PHPUnit\Framework\TestCase;
/**
* @internal
* @coversNothing
*/
final class EntityWorkflowTest extends TestCase
{
public function testIsFinalizeWith1Steps()
{
$entityWorkflow = new EntityWorkflow();
$entityWorkflow->getCurrentStep()->setFinalizeAfter(true);
$entityWorkflow->setStep('final');
$this->assertTrue($entityWorkflow->isFinalize());
}
public function testIsFinalizeWith4Steps()
{
$entityWorkflow = new EntityWorkflow();
$this->assertFalse($entityWorkflow->isFinalize());
$entityWorkflow->setStep('two');
$this->assertFalse($entityWorkflow->isFinalize());
$entityWorkflow->setStep('previous_final');
$this->assertFalse($entityWorkflow->isFinalize());
$entityWorkflow->getCurrentStep()->setFinalizeAfter(true);
$entityWorkflow->setStep('final');
$this->assertTrue($entityWorkflow->isFinalize());
}
public function testIsFreeze()
{
$entityWorkflow = new EntityWorkflow();
$this->assertFalse($entityWorkflow->isFreeze());
$entityWorkflow->setStep('step_one');
$this->assertFalse($entityWorkflow->isFreeze());
$entityWorkflow->setStep('step_three');
$this->assertFalse($entityWorkflow->isFreeze());
$entityWorkflow->getCurrentStep()->setFreezeAfter(true);
$this->assertFalse($entityWorkflow->isFreeze());
$entityWorkflow->setStep('freezed');
$this->assertTrue($entityWorkflow->isFreeze());
$entityWorkflow->setStep('after_freeze');
$this->assertTrue($entityWorkflow->isFreeze());
$this->assertTrue($entityWorkflow->getCurrentStep()->isFreezeAfter());
}
}

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\Workflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
interface EntityWorkflowHandlerInterface
{
public function getRelatedEntity(EntityWorkflow $entityWorkflow): ?object;
/**
* Return a string representing the role required for seeing the workflow.
*
* Return Null if any check is required, or a role name. The voter will check for
* authorization with the role as attribute, and the
*/
public function getRoleShow(EntityWorkflow $entityWorkflow): ?string;
public function getTemplate(EntityWorkflow $entityWorkflow, array $options = []): string;
public function getTemplateData(EntityWorkflow $entityWorkflow, array $options = []): array;
public function getTemplateTitle(EntityWorkflow $entityWorkflow, array $options = []): string;
public function getTemplateTitleData(EntityWorkflow $entityWorkflow, array $options = []): array;
public function supports(EntityWorkflow $entityWorkflow, array $options = []): bool;
public function supportsFreeze(EntityWorkflow $entityWorkflow, array $options = []): bool;
}

View File

@ -0,0 +1,48 @@
<?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\Workflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Workflow\Exception\HandlerNotFoundException;
use Symfony\Component\Workflow\Registry;
class EntityWorkflowManager
{
/**
* @var iterable|EntityWorkflowHandlerInterface[]
*/
private iterable $handlers;
private Registry $registry;
public function __construct(iterable $handlers, Registry $registry)
{
$this->handlers = $handlers;
$this->registry = $registry;
}
public function getHandler(EntityWorkflow $entityWorkflow, array $options = []): EntityWorkflowHandlerInterface
{
foreach ($this->handlers as $handler) {
if ($handler->supports($entityWorkflow, $options)) {
return $handler;
}
}
throw new HandlerNotFoundException();
}
public function getSupportedWorkflows(EntityWorkflow $entityWorkflow): array
{
return $this->registry->all($entityWorkflow);
}
}

View File

@ -0,0 +1,114 @@
<?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\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Templating\Entity\UserRender;
use DateTimeImmutable;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Workflow\Event\Event;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\TransitionBlocker;
class EntityWorkflowTransitionEventSubscriber implements EventSubscriberInterface
{
private LoggerInterface $chillLogger;
private Security $security;
private UserRender $userRender;
public function __construct(
LoggerInterface $chillLogger,
Security $security,
UserRender $userRender
) {
$this->chillLogger = $chillLogger;
$this->security = $security;
$this->userRender = $userRender;
}
public static function getSubscribedEvents(): array
{
return [
'workflow.transition' => 'onTransition',
'workflow.guard' => [
['guardEntityWorkflow', 0],
],
];
}
public function guardEntityWorkflow(GuardEvent $event)
{
if (!$event->getSubject() instanceof EntityWorkflow) {
return;
}
/** @var EntityWorkflow $entityWorkflow */
$entityWorkflow = $event->getSubject();
if ($entityWorkflow->isFinalize()) {
$event->addTransitionBlocker(
new TransitionBlocker(
'workflow.The workflow is finalized',
'd6306280-7535-11ec-a40d-1f7bee26e2c0'
)
);
return;
}
if (!$entityWorkflow->getCurrentStep()->getDestUser()->contains($this->security->getUser())) {
if (!$event->getMarking()->has('initial')) {
$event->addTransitionBlocker(new TransitionBlocker(
'workflow.You are not allowed to apply a transition on this workflow. Only those users are allowed: %users%',
'f3eeb57c-7532-11ec-9495-e7942a2ac7bc',
[
'%users%' => implode(
', ',
$entityWorkflow->getCurrentStep()->getDestUser()->map(function (User $u) {
return $this->userRender->renderString($u, []);
})->toArray()
),
]
));
}
}
}
public function onTransition(Event $event)
{
if (!$event->getSubject() instanceof EntityWorkflow) {
return;
}
/** @var EntityWorkflow $entityWorkflow */
$entityWorkflow = $event->getSubject();
$step = $entityWorkflow->getCurrentStep();
$step
->setTransitionAfter($event->getTransition()->getName())
->setTransitionAt(new DateTimeImmutable('now'))
->setTransitionBy($this->security->getUser());
$this->chillLogger->info('[workflow] apply transition on entityWorkflow', [
'relatedEntityClass' => $entityWorkflow->getRelatedEntityClass(),
'relatedEntityId' => $entityWorkflow->getRelatedEntityId(),
'transition' => $event->getTransition()->getName(),
'by_user' => $this->security->getUser(),
'entityWorkflow' => $entityWorkflow->getId(),
]);
}
}

View File

@ -0,0 +1,103 @@
<?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\Workflow\EventSubscriber;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Workflow\Helper\MetadataExtractor;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Templating\EngineInterface;
use Symfony\Component\Workflow\Event\Event;
use Symfony\Component\Workflow\Registry;
use function in_array;
class NotificationOnTransition implements EventSubscriberInterface
{
private EngineInterface $engine;
private EntityManagerInterface $entityManager;
private MetadataExtractor $metadataExtractor;
private Registry $registry;
private Security $security;
public function __construct(EntityManagerInterface $entityManager, EngineInterface $engine, MetadataExtractor $metadataExtractor, Security $security, Registry $registry)
{
$this->entityManager = $entityManager;
$this->engine = $engine;
$this->metadataExtractor = $metadataExtractor;
$this->registry = $registry;
$this->security = $security;
}
public static function getSubscribedEvents(): array
{
return [
'workflow.completed' => 'onCompleted',
];
}
public function onCompleted(Event $event): void
{
if (!$event->getSubject() instanceof EntityWorkflow) {
return;
}
/** @var EntityWorkflow $entityWorkflow */
$entityWorkflow = $event->getSubject();
$dests = array_merge(
$entityWorkflow->getSubscriberToStep()->toArray(),
$entityWorkflow->isFinalize() ? $entityWorkflow->getSubscriberToFinal()->toArray() : [],
$entityWorkflow->getCurrentStep()->getDestUser()->toArray()
);
$place = $this->metadataExtractor->buildArrayPresentationForPlace($entityWorkflow);
$workflow = $this->metadataExtractor->buildArrayPresentationForWorkflow(
$this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName())
);
$visited = [];
foreach ($dests as $subscriber) {
if (
$this->security->getUser() === $subscriber
|| in_array($subscriber->getId(), $visited, true)
) {
continue;
}
$context = [
'entity_workflow' => $entityWorkflow,
'dest' => $subscriber,
'place' => $place,
'workflow' => $workflow,
'is_dest' => $entityWorkflow->getCurrentStep()->getDestUser()->contains($subscriber),
];
$notification = new Notification();
$notification
->setRelatedEntityId($entityWorkflow->getId())
->setRelatedEntityClass(EntityWorkflow::class)
->setTitle($this->engine->render('@ChillMain/Workflow/workflow_notification_on_transition_completed_title.fr.txt.twig', $context))
->setMessage($this->engine->render('@ChillMain/Workflow/workflow_notification_on_transition_completed_content.fr.txt.twig', $context))
->addAddressee($subscriber);
$this->entityManager->persist($notification);
$visited[] = $subscriber->getId();
}
}
}

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\Workflow\Exception;
use RuntimeException;
class HandlerNotFoundException extends RuntimeException
{
}

View File

@ -0,0 +1,74 @@
<?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\Workflow\Helper;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\WorkflowInterface;
use function array_key_exists;
class MetadataExtractor
{
private Registry $registry;
private TranslatableStringHelperInterface $translatableStringHelper;
public function __construct(Registry $registry, TranslatableStringHelperInterface $translatableStringHelper)
{
$this->registry = $registry;
$this->translatableStringHelper = $translatableStringHelper;
}
public function availableWorkflowFor(string $relatedEntityClass, ?int $relatedEntityId = 0): array
{
$blankEntityWorkflow = new EntityWorkflow();
$blankEntityWorkflow
->setRelatedEntityId($relatedEntityId)
->setRelatedEntityClass($relatedEntityClass);
// build the list of available workflows, and extract their names from metadata
$workflows = $this->registry->all($blankEntityWorkflow);
$workflowsList = [];
foreach ($workflows as $workflow) {
$metadata = $workflow->getMetadataStore()->getWorkflowMetadata();
$text = array_key_exists('label', $metadata) ?
$this->translatableStringHelper->localize($metadata['label']) : $workflow->getName();
$workflowsList[] = ['name' => $workflow->getName(), 'text' => $text];
}
return $workflowsList;
}
public function buildArrayPresentationForPlace(EntityWorkflow $entityWorkflow): array
{
$workflow = $this->registry->get($entityWorkflow, $entityWorkflow->getWorkflowName());
$markingMetadata = $workflow->getMetadataStore()->getPlaceMetadata($entityWorkflow->getCurrentStep()->getCurrentStep());
$text = array_key_exists('label', $markingMetadata) ?
$this->translatableStringHelper->localize($markingMetadata['label']) : $entityWorkflow->getCurrentStep()->getCurrentStep();
return ['name' => $entityWorkflow->getCurrentStep()->getCurrentStep(), 'text' => $text];
}
public function buildArrayPresentationForWorkflow(WorkflowInterface $workflow): array
{
$metadata = $workflow->getMetadataStore()->getWorkflowMetadata();
$text = array_key_exists('label', $metadata) ?
$this->translatableStringHelper->localize($metadata['label']) : $workflow->getName();
return ['name' => $workflow->getName(), 'text' => $text];
}
}

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\MainBundle\Workflow\Notification;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Notification\NotificationHandlerInterface;
use Chill\MainBundle\Repository\Workflow\EntityWorkflowRepository;
use Chill\MainBundle\Workflow\EntityWorkflowManager;
class WorkflowNotificationHandler implements NotificationHandlerInterface
{
private EntityWorkflowManager $entityWorkflowManager;
private EntityWorkflowRepository $entityWorkflowRepository;
public function getTemplate(Notification $notification, array $options = []): string
{
return '@ChillMain/Workflow/_notification_include.html.twig';
}
public function getTemplateData(Notification $notification, array $options = []): array
{
$entityWorkflow = $this->entityWorkflowRepository->find($notification->getRelatedEntityId());
return [
'entity_workflow' => $entityWorkflow,
'handler' => $this->entityWorkflowManager->getHandler($entityWorkflow),
];
}
public function supports(Notification $notification, array $options = []): bool
{
return $notification->getRelatedEntityClass() === EntityWorkflow::class;
}
}

View File

@ -0,0 +1,35 @@
<?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\Workflow;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Symfony\Component\Workflow\SupportStrategy\WorkflowSupportStrategyInterface;
use Symfony\Component\Workflow\WorkflowInterface;
class RelatedEntityWorkflowSupportsStrategy implements WorkflowSupportStrategyInterface
{
public function supports(WorkflowInterface $workflow, $subject): bool
{
if (!$subject instanceof EntityWorkflow) {
return false;
}
foreach ($workflow->getMetadataStore()->getWorkflowMetadata()['related_entity']
as $relatedEntityClass) {
if ($subject->getRelatedEntityClass() === $relatedEntityClass) {
return true;
}
}
return false;
}
}

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