From 659dff3d2c310951bf3a0dce6474ed375e4d5b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 23 Jun 2023 12:19:40 +0200 Subject: [PATCH 1/6] DX: Add features to filterOrder Allow to add single checkboxes and entitychoices to filter order --- .changes/unreleased/DX-20230623-122408.yaml | 5 ++ .../Form/Type/Listing/FilterOrderType.php | 54 ++++++------- .../views/FilterOrder/base.html.twig | 50 ++++++++++-- .../Templating/Listing/FilterOrderHelper.php | 76 +++++++++++++++++-- .../Listing/FilterOrderHelperBuilder.php | 39 ++++++++++ .../Templating/Listing/Templating.php | 39 +++++++++- 6 files changed, 224 insertions(+), 39 deletions(-) create mode 100644 .changes/unreleased/DX-20230623-122408.yaml diff --git a/.changes/unreleased/DX-20230623-122408.yaml b/.changes/unreleased/DX-20230623-122408.yaml new file mode 100644 index 000000000..58dd96180 --- /dev/null +++ b/.changes/unreleased/DX-20230623-122408.yaml @@ -0,0 +1,5 @@ +kind: DX +body: '[FilterOrderHelper] add entity choice and singleCheckbox' +time: 2023-06-23T12:24:08.133491895+02:00 +custom: + Issue: "" diff --git a/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php b/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php index aaa6afa24..16038515d 100644 --- a/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php +++ b/src/Bundle/ChillMainBundle/Form/Type/Listing/FilterOrderType.php @@ -13,6 +13,8 @@ namespace Chill\MainBundle\Form\Type\Listing; use Chill\MainBundle\Form\Type\ChillDateType; use Chill\MainBundle\Templating\Listing\FilterOrderHelper; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\Extension\Core\Type\SearchType; @@ -27,13 +29,6 @@ use function count; final class FilterOrderType extends \Symfony\Component\Form\AbstractType { - private RequestStack $requestStack; - - public function __construct(RequestStack $requestStack) - { - $this->requestStack = $requestStack; - } - public function buildForm(FormBuilderInterface $builder, array $options) { /** @var FilterOrderHelper $helper */ @@ -71,6 +66,25 @@ final class FilterOrderType extends \Symfony\Component\Form\AbstractType $builder->add($checkboxesBuilder); } + if ([] !== $helper->getEntityChoices()) { + $entityChoicesBuilder = $builder->create('entity_choices', null, ['compound' => true]); + + foreach ($helper->getEntityChoices() as $key => [ + 'label' => $label, 'choices' => $choices, 'options' => $opts, 'class' => $class + ]) { + $entityChoicesBuilder->add($key, EntityType::class, [ + 'label' => $label, + 'choices' => $choices, + 'class' => $class, + 'multiple' => true, + 'expanded' => true, + ...$opts, + ]); + } + + $builder->add($entityChoicesBuilder); + } + if (0 < count($helper->getDateRanges())) { $dateRangesBuilder = $builder->create('dateRanges', null, ['compound' => true]); @@ -97,28 +111,14 @@ final class FilterOrderType extends \Symfony\Component\Form\AbstractType $builder->add($dateRangesBuilder); } - foreach ($this->requestStack->getCurrentRequest()->query->getIterator() as $key => $value) { - switch ($key) { - case 'q': - case 'checkboxes' . $key: - case $key . '_from': - case $key . '_to': - break; + if ([] !== $helper->getSingleCheckbox()) { + $singleCheckBoxBuilder = $builder->create('single_checkboxes', null, ['compound' => true]); - case 'page': - $builder->add($key, HiddenType::class, [ - 'data' => 1, - ]); - - break; - - default: - $builder->add($key, HiddenType::class, [ - 'data' => $value, - ]); - - break; + foreach ($helper->getSingleCheckbox() as $name => ['label' => $label]) { + $singleCheckBoxBuilder->add($name, CheckboxType::class, ['label' => $label, 'required' => false]); } + + $builder->add($singleCheckBoxBuilder); } } diff --git a/src/Bundle/ChillMainBundle/Resources/views/FilterOrder/base.html.twig b/src/Bundle/ChillMainBundle/Resources/views/FilterOrder/base.html.twig index 1f1e54c72..d4a6bbdd4 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/FilterOrder/base.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/FilterOrder/base.html.twig @@ -13,13 +13,13 @@ {% if form.dateRanges is defined %} {% if form.dateRanges|length > 0 %} {% for dateRangeName, _o in form.dateRanges %} -
+
{% if form.dateRanges[dateRangeName].vars.label is not same as(false) %} -
+
{{ form_label(form.dateRanges[dateRangeName])}}
{% endif %} -
+
{{ 'chill_calendar.From'|trans }} {{ form_widget(form.dateRanges[dateRangeName]['from']) }} @@ -27,7 +27,7 @@ {{ form_widget(form.dateRanges[dateRangeName]['to']) }}
-
+
@@ -37,7 +37,7 @@ {% if form.checkboxes is defined %} {% if form.checkboxes|length > 0 %} {% for checkbox_name, options in form.checkboxes %} -
+
{% for c in form['checkboxes'][checkbox_name].children %}
@@ -61,5 +61,45 @@ {% endfor %} {% endif %} {% endif %} + {% if form.entity_choices is defined %} + {% if form.entity_choices |length > 0 %} + {% for checkbox_name, options in form.entity_choices %} +
+ {% if form.entity_choices[checkbox_name].vars.label is not same as(false) %} +
+ {{ form_label(form.entity_choices[checkbox_name])}} +
+ {% endif %} +
+ {% for c in form['entity_choices'][checkbox_name].children %} +
+ {{ form_widget(c) }} + {{ form_label(c) }} +
+ {% endfor %} +
+
+ +
+
+ {% endfor %} + {% endif %} + {% endif %} + {% if form.single_checkboxes is defined %} + {% for name, _o in form.single_checkboxes %} +
+
+ {{ form_widget(form.single_checkboxes[name]) }} +
+
+ +
+
+ {% endfor %} + {% endif %}
+ + {% for k,v in otherParameters %} + + {% endfor %} {{ form_end(form) }} diff --git a/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelper.php b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelper.php index 367cc0861..28cc8e331 100644 --- a/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelper.php +++ b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelper.php @@ -13,6 +13,8 @@ namespace Chill\MainBundle\Templating\Listing; use Chill\MainBundle\Form\Type\Listing\FilterOrderType; use DateTimeImmutable; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\RequestStack; @@ -24,11 +26,16 @@ class FilterOrderHelper { private array $checkboxes = []; + /** + * @var array + */ + private array $singleCheckbox = []; + private array $dateRanges = []; private FormFactoryInterface $formFactory; - private ?string $formName = 'f'; + public const FORM_NAME = 'f'; private array $formOptions = []; @@ -40,6 +47,11 @@ class FilterOrderHelper private ?array $submitted = null; + /** + * @var array + */ + private array $entityChoices = []; + public function __construct( FormFactoryInterface $formFactory, RequestStack $requestStack @@ -48,7 +60,29 @@ class FilterOrderHelper $this->requestStack = $requestStack; } - public function addCheckbox(string $name, array $choices, ?array $default = [], ?array $trans = []): self + public function addSingleCheckbox(string $name, string $label): self + { + $this->singleCheckbox[$name] = ['label' => $label]; + + return $this; + } + + /** + * @param class-string $class + */ + public function addEntityChoice(string $name, string $class, string $label, array $choices, array $options = []): self + { + $this->entityChoices[$name] = ['label' => $label, 'class' => $class, 'choices' => $choices, 'options' => $options]; + + return $this; + } + + public function getEntityChoices(): array + { + return $this->entityChoices; + } + + public function addCheckbox(string $name, array $choices, ?array $default = [], ?array $trans = [], array $options = []): self { $missing = count($choices) - count($trans) - 1; $this->checkboxes[$name] = [ @@ -58,6 +92,7 @@ class FilterOrderHelper 0 < $missing ? array_fill(0, $missing, null) : [] ), + ...$options, ]; return $this; @@ -73,7 +108,7 @@ class FilterOrderHelper public function buildForm(): FormInterface { return $this->formFactory - ->createNamed($this->formName, $this->formType, $this->getDefaultData(), array_merge([ + ->createNamed(self::FORM_NAME, $this->formType, $this->getDefaultData(), array_merge([ 'helper' => $this, 'method' => 'GET', 'csrf_protection' => false, @@ -86,13 +121,31 @@ class FilterOrderHelper return $this->getFormData()['checkboxes'][$name]; } + public function getSingleCheckboxData(string $name): ?bool + { + return $this->getFormData()['single_checkboxes'][$name]; + } + + public function getEntityChoiceData($name): mixed + { + return $this->getFormData()['entity_choices'][$name]; + } + public function getCheckboxes(): array { return $this->checkboxes; } /** - * @return array<'to': DateTimeImmutable, 'from': DateTimeImmutable> + * @return array + */ + public function getSingleCheckbox(): array + { + return $this->singleCheckbox; + } + + /** + * @return array{to: ?DateTimeImmutable, from: ?DateTimeImmutable} */ public function getDateRangeData(string $name): array { @@ -123,7 +176,12 @@ class FilterOrderHelper private function getDefaultData(): array { - $r = []; + $r = [ + 'checkboxes' => [], + 'dateRanges' => [], + 'single_checkboxes' => [], + 'entity_choices' => [] + ]; if ($this->hasSearchBox()) { $r['q'] = ''; @@ -138,6 +196,14 @@ class FilterOrderHelper $r['dateRanges'][$name]['to'] = $defaults['to']; } + foreach ($this->singleCheckbox as $name => $c) { + $r['single_checkboxes'][$name] = false; + } + + foreach ($this->entityChoices as $name => $c) { + $r['entity_choices'][$name] = ($c['options']['multiple'] ?? true) ? [] : null; + } + return $r; } diff --git a/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelperBuilder.php b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelperBuilder.php index d68c8c37a..e176e27c6 100644 --- a/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelperBuilder.php +++ b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelperBuilder.php @@ -27,6 +27,16 @@ class FilterOrderHelperBuilder private ?array $searchBoxFields = null; + /** + * @var array + */ + private array $singleCheckboxes = []; + + /** + * @var array + */ + private array $entityChoices = []; + public function __construct( FormFactoryInterface $formFactory, RequestStack $requestStack @@ -35,6 +45,13 @@ class FilterOrderHelperBuilder $this->requestStack = $requestStack; } + public function addSingleCheckbox(string $name, string $label): self + { + $this->singleCheckboxes[$name] = ['label' => $label]; + + return $this; + } + public function addCheckbox(string $name, array $choices, ?array $default = [], ?array $trans = []): self { $this->checkboxes[$name] = ['choices' => $choices, 'default' => $default, 'trans' => $trans]; @@ -42,6 +59,16 @@ class FilterOrderHelperBuilder return $this; } + /** + * @param class-string $class + */ + public function addEntityChoice(string $name, string $label, string $class, array $choices, ?array $options = []): self + { + $this->entityChoices[$name] = ['label' => $label, 'class' => $class, 'choices' => $choices, 'options' => $options]; + + return $this; + } + public function addDateRange(string $name, ?string $label = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null): self { $this->dateRanges[$name] = ['from' => $from, 'to' => $to, 'label' => $label]; @@ -75,6 +102,18 @@ class FilterOrderHelperBuilder $helper->addCheckbox($name, $choices, $default, $trans); } + foreach ( + $this->singleCheckboxes as $name => ['label' => $label] + ) { + $helper->addSingleCheckbox($name, $label); + } + + foreach ( + $this->entityChoices as $name => ['label' => $label, 'class' => $class, 'choices' => $choices, 'options' => $options] + ) { + $helper->addEntityChoice($name, $class, $label, $choices, $options); + } + foreach ( $this->dateRanges as $name => [ 'from' => $from, diff --git a/src/Bundle/ChillMainBundle/Templating/Listing/Templating.php b/src/Bundle/ChillMainBundle/Templating/Listing/Templating.php index bc256e24f..b91cd86e8 100644 --- a/src/Bundle/ChillMainBundle/Templating/Listing/Templating.php +++ b/src/Bundle/ChillMainBundle/Templating/Listing/Templating.php @@ -11,13 +11,23 @@ declare(strict_types=1); namespace Chill\MainBundle\Templating\Listing; +use Chill\MainBundle\Pagination\PaginatorFactory; +use Symfony\Component\HttpFoundation\RequestStack; use Twig\Environment; +use Twig\Error\LoaderError; +use Twig\Error\RuntimeError; +use Twig\Error\SyntaxError; use Twig\Extension\AbstractExtension; use Twig\TwigFilter; class Templating extends AbstractExtension { - public function getFilters() + public function __construct( + private readonly RequestStack $requestStack, + ) { + } + + public function getFilters(): array { return [ new TwigFilter('chill_render_filter_order_helper', [$this, 'renderFilterOrderHelper'], [ @@ -26,16 +36,41 @@ class Templating extends AbstractExtension ]; } + /** + * @throws SyntaxError + * @throws RuntimeError + * @throws LoaderError + */ public function renderFilterOrderHelper( Environment $environment, FilterOrderHelper $helper, ?string $template = '@ChillMain/FilterOrder/base.html.twig', ?array $options = [] - ) { + ): string { + $otherParameters = []; + + foreach ($this->requestStack->getCurrentRequest()->query->getIterator() as $key => $value) { + switch ($key) { + case FilterOrderHelper::FORM_NAME: + break; + + case PaginatorFactory::DEFAULT_CURRENT_PAGE_KEY: + // when filtering, go back to page 1 + $otherParameters[PaginatorFactory::DEFAULT_CURRENT_PAGE_KEY] = 1; + + break; + default: + $otherParameters[$key] = $value; + + break; + } + } + return $environment->render($template, [ 'helper' => $helper, 'form' => $helper->buildForm()->createView(), 'options' => $options, + 'otherParameters' => $otherParameters, ]); } } From 51544cfc48418e3492d43b232081efabf5d1772e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 23 Jun 2023 12:21:13 +0200 Subject: [PATCH 2/6] DX: improve typing of a property in UserJob --- src/Bundle/ChillMainBundle/Entity/UserJob.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bundle/ChillMainBundle/Entity/UserJob.php b/src/Bundle/ChillMainBundle/Entity/UserJob.php index ed8bc43f3..c6bfe1aa3 100644 --- a/src/Bundle/ChillMainBundle/Entity/UserJob.php +++ b/src/Bundle/ChillMainBundle/Entity/UserJob.php @@ -37,7 +37,7 @@ class UserJob protected ?int $id = null; /** - * @var array|string[]A + * @var array * @ORM\Column(name="label", type="json") * @Serializer\Groups({"read", "docgen:read"}) * @Serializer\Context({"is-translatable": true}, groups={"docgen:read"}) From f7c11d356737351d7fda548b3bb5d0acc7b0fac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 23 Jun 2023 12:22:24 +0200 Subject: [PATCH 3/6] Feature: Add filters on activity list --- .../unreleased/Feature-20230623-122530.yaml | 5 + .../unreleased/Feature-20230623-122702.yaml | 6 + .../Controller/ActivityController.php | 138 ++++---- .../Repository/ActivityACLAwareRepository.php | 240 ++++++++++--- .../ActivityACLAwareRepositoryInterface.php | 36 +- .../Repository/ActivityTypeRepository.php | 4 + .../ActivityTypeRepositoryInterface.php | 4 +- .../Resources/views/Activity/list.html.twig | 3 + .../ActivityACLAwareRepositoryTest.php | 325 ++++++++++++++++++ .../translations/messages.fr.yml | 8 + 10 files changed, 648 insertions(+), 121 deletions(-) create mode 100644 .changes/unreleased/Feature-20230623-122530.yaml create mode 100644 .changes/unreleased/Feature-20230623-122702.yaml create mode 100644 src/Bundle/ChillActivityBundle/Tests/Repository/ActivityACLAwareRepositoryTest.php diff --git a/.changes/unreleased/Feature-20230623-122530.yaml b/.changes/unreleased/Feature-20230623-122530.yaml new file mode 100644 index 000000000..922750ea8 --- /dev/null +++ b/.changes/unreleased/Feature-20230623-122530.yaml @@ -0,0 +1,5 @@ +kind: Feature +body: '[activity list] add filtering for activities list' +time: 2023-06-23T12:25:30.49643551+02:00 +custom: + Issue: "" diff --git a/.changes/unreleased/Feature-20230623-122702.yaml b/.changes/unreleased/Feature-20230623-122702.yaml new file mode 100644 index 000000000..e1d1b0e1f --- /dev/null +++ b/.changes/unreleased/Feature-20230623-122702.yaml @@ -0,0 +1,6 @@ +kind: Feature +body: '[activity list] in person context, show also the activities from the accompanying + periods where the person participates' +time: 2023-06-23T12:27:02.159041095+02:00 +custom: + Issue: "" diff --git a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php index 444c663fc..07e4fcf62 100644 --- a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php +++ b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php @@ -18,11 +18,16 @@ use Chill\ActivityBundle\Repository\ActivityACLAwareRepositoryInterface; use Chill\ActivityBundle\Repository\ActivityRepository; use Chill\ActivityBundle\Repository\ActivityTypeCategoryRepository; use Chill\ActivityBundle\Repository\ActivityTypeRepositoryInterface; +use Chill\ActivityBundle\Repository\ActivityUserJobRepository; use Chill\ActivityBundle\Security\Authorization\ActivityVoter; use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable; +use Chill\MainBundle\Entity\UserJob; use Chill\MainBundle\Repository\LocationRepository; use Chill\MainBundle\Repository\UserRepositoryInterface; use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; +use Chill\MainBundle\Templating\Listing\FilterOrderHelper; +use Chill\MainBundle\Templating\Listing\FilterOrderHelperFactoryInterface; +use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Privacy\PrivacyEvent; @@ -47,68 +52,25 @@ use function array_key_exists; final class ActivityController extends AbstractController { - private AccompanyingPeriodRepository $accompanyingPeriodRepository; - - private ActivityACLAwareRepositoryInterface $activityACLAwareRepository; - - private ActivityRepository $activityRepository; - - private ActivityTypeCategoryRepository $activityTypeCategoryRepository; - - private ActivityTypeRepositoryInterface $activityTypeRepository; - - private CenterResolverManagerInterface $centerResolver; - - private EntityManagerInterface $entityManager; - - private EventDispatcherInterface $eventDispatcher; - - private LocationRepository $locationRepository; - - private LoggerInterface $logger; - - private PersonRepository $personRepository; - - private SerializerInterface $serializer; - - private ThirdPartyRepository $thirdPartyRepository; - - private TranslatorInterface $translator; - - private UserRepositoryInterface $userRepository; - public function __construct( - ActivityACLAwareRepositoryInterface $activityACLAwareRepository, - ActivityTypeRepositoryInterface $activityTypeRepository, - ActivityTypeCategoryRepository $activityTypeCategoryRepository, - PersonRepository $personRepository, - ThirdPartyRepository $thirdPartyRepository, - LocationRepository $locationRepository, - ActivityRepository $activityRepository, - AccompanyingPeriodRepository $accompanyingPeriodRepository, - EntityManagerInterface $entityManager, - EventDispatcherInterface $eventDispatcher, - LoggerInterface $logger, - SerializerInterface $serializer, - UserRepositoryInterface $userRepository, - CenterResolverManagerInterface $centerResolver, - TranslatorInterface $translator + private readonly ActivityACLAwareRepositoryInterface $activityACLAwareRepository, + private readonly ActivityTypeRepositoryInterface $activityTypeRepository, + private readonly ActivityTypeCategoryRepository $activityTypeCategoryRepository, + private readonly PersonRepository $personRepository, + private readonly ThirdPartyRepository $thirdPartyRepository, + private readonly LocationRepository $locationRepository, + private readonly ActivityRepository $activityRepository, + private readonly AccompanyingPeriodRepository $accompanyingPeriodRepository, + private readonly EntityManagerInterface $entityManager, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly LoggerInterface $logger, + private readonly SerializerInterface $serializer, + private readonly UserRepositoryInterface $userRepository, + private readonly CenterResolverManagerInterface $centerResolver, + private readonly TranslatorInterface $translator, + private readonly FilterOrderHelperFactoryInterface $filterOrderHelperFactory, + private readonly TranslatableStringHelperInterface $translatableStringHelper, ) { - $this->activityACLAwareRepository = $activityACLAwareRepository; - $this->activityTypeRepository = $activityTypeRepository; - $this->activityTypeCategoryRepository = $activityTypeCategoryRepository; - $this->personRepository = $personRepository; - $this->thirdPartyRepository = $thirdPartyRepository; - $this->locationRepository = $locationRepository; - $this->activityRepository = $activityRepository; - $this->accompanyingPeriodRepository = $accompanyingPeriodRepository; - $this->entityManager = $entityManager; - $this->eventDispatcher = $eventDispatcher; - $this->logger = $logger; - $this->serializer = $serializer; - $this->userRepository = $userRepository; - $this->centerResolver = $centerResolver; - $this->translator = $translator; } /** @@ -292,11 +254,27 @@ final class ActivityController extends AbstractController // TODO: add pagination [$person, $accompanyingPeriod] = $this->getEntity($request); + $filter = $this->buildFilterOrder($person ?? $accompanyingPeriod); + + $filterArgs = [ + 'my_activities' => $filter->getSingleCheckboxData('my_activities'), + 'types' => $filter->getEntityChoiceData('activity_types'), + 'jobs' => $filter->getEntityChoiceData('jobs'), + 'before' => $filter->getDateRangeData('activity_date')['to'], + 'after' => $filter->getDateRangeData('activity_date')['from'], + ]; if ($person instanceof Person) { $this->denyAccessUnlessGranted(ActivityVoter::SEE, $person); $activities = $this->activityACLAwareRepository - ->findByPerson($person, ActivityVoter::SEE, 0, null, ['date' => 'DESC', 'id' => 'DESC']); + ->findByPerson( + $person, + ActivityVoter::SEE, + 0, + null, + ['date' => 'DESC', 'id' => 'DESC'], + $filterArgs + ); $event = new PrivacyEvent($person, [ 'element_class' => Activity::class, @@ -309,7 +287,14 @@ final class ActivityController extends AbstractController $this->denyAccessUnlessGranted(ActivityVoter::SEE, $accompanyingPeriod); $activities = $this->activityACLAwareRepository - ->findByAccompanyingPeriod($accompanyingPeriod, ActivityVoter::SEE, 0, null, ['date' => 'DESC', 'id' => 'DESC']); + ->findByAccompanyingPeriod( + $accompanyingPeriod, + ActivityVoter::SEE, + 0, + null, + ['date' => 'DESC', 'id' => 'DESC'], + $filterArgs + ); $view = 'ChillActivityBundle:Activity:listAccompanyingCourse.html.twig'; } @@ -320,10 +305,39 @@ final class ActivityController extends AbstractController 'activities' => $activities, 'person' => $person, 'accompanyingCourse' => $accompanyingPeriod, + 'filter' => $filter, ] ); } + private function buildFilterOrder(AccompanyingPeriod|Person $associated): FilterOrderHelper + { + + $filterBuilder = $this->filterOrderHelperFactory->create(self::class); + $types = $this->activityACLAwareRepository->findActivityTypeByAssociated($associated); + $jobs = $this->activityACLAwareRepository->findUserJobByAssociated($associated); + + $filterBuilder + ->addDateRange('activity_date', 'activity.date') + ->addSingleCheckbox('my_activities', 'activity_filter.My activities') + ->addEntityChoice('activity_types', 'activity_filter.Types', \Chill\ActivityBundle\Entity\ActivityType::class, $types, [ + 'choice_label' => function (\Chill\ActivityBundle\Entity\ActivityType $activityType) { + $text = match ($activityType->hasCategory()) { + true => $this->translatableStringHelper->localize($activityType->getCategory()->getName()) . ' > ', + false => '', + }; + + return $text . $this->translatableStringHelper->localize($activityType->getName()); + } + ]) + ->addEntityChoice('jobs', 'activity_filter.Jobs', UserJob::class, $jobs, [ + 'choice_label' => fn (UserJob $u) => $this->translatableStringHelper->localize($u->getLabel()) + ]) + ; + + return $filterBuilder->build(); + } + public function newAction(Request $request): Response { $view = null; diff --git a/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepository.php b/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepository.php index 1f2039a2c..1fd0a57d6 100644 --- a/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepository.php +++ b/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepository.php @@ -18,67 +18,159 @@ use Chill\ActivityBundle\Security\Authorization\ActivityVoter; use Chill\MainBundle\Entity\Location; use Chill\MainBundle\Entity\LocationType; use Chill\MainBundle\Entity\Scope; -use Chill\MainBundle\Security\Authorization\AuthorizationHelper; -use Chill\MainBundle\Security\Resolver\CenterResolverDispatcherInterface; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserJob; +use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface; +use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\Person; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\AbstractQuery; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\Query\ResultSetMappingBuilder; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Role\Role; +use Doctrine\ORM\QueryBuilder; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Security\Core\Security; use function count; use function in_array; -final class ActivityACLAwareRepository implements ActivityACLAwareRepositoryInterface +final readonly class ActivityACLAwareRepository implements ActivityACLAwareRepositoryInterface { - private AuthorizationHelper $authorizationHelper; - - private CenterResolverDispatcherInterface $centerResolverDispatcher; - - private EntityManagerInterface $em; - - private ActivityRepository $repository; - - private Security $security; - - private TokenStorageInterface $tokenStorage; - public function __construct( - AuthorizationHelper $authorizationHelper, - CenterResolverDispatcherInterface $centerResolverDispatcher, - TokenStorageInterface $tokenStorage, - ActivityRepository $repository, - EntityManagerInterface $em, - Security $security + private AuthorizationHelperForCurrentUserInterface $authorizationHelper, + private CenterResolverManagerInterface $centerResolverManager, + private ActivityRepository $repository, + private EntityManagerInterface $em, + private Security $security, + private RequestStack $requestStack, ) { - $this->authorizationHelper = $authorizationHelper; - $this->centerResolverDispatcher = $centerResolverDispatcher; - $this->tokenStorage = $tokenStorage; - $this->repository = $repository; - $this->em = $em; - $this->security = $security; } - public function findByAccompanyingPeriod(AccompanyingPeriod $period, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = []): array + public function findByAccompanyingPeriod(AccompanyingPeriod $period, string $role, ?int $start = 0, ?int $limit = 1000, array $orderBy = ['date' => 'DESC'], array $filters = []): array { - $user = $this->security->getUser(); - $center = $this->centerResolverDispatcher->resolveCenter($period); + $qb = $this->buildBaseQuery($filters); - if (0 === count($orderBy)) { - $orderBy = ['date' => 'DESC']; + $qb->andWhere('a.accompanyingPeriod = :period')->setParameter('period', $period); + + foreach ($orderBy as $field => $order) { + $qb->addOrderBy('a.' . $field, $order); } - $scopes = $this->authorizationHelper - ->getReachableCircles($user, $role, $center); + $qb->setFirstResult(0)->setMaxResults(1000); - return $this->em->getRepository(Activity::class) - ->findByAccompanyingPeriod($period, $scopes, true, $limit, $start, $orderBy); + return $qb->getQuery()->getResult(); } + public function buildBaseQuery(array $filters): QueryBuilder + { + $qb = $this->repository + ->createQueryBuilder('a') + ; + + if (($filters['my_activities'] ?? false) and ($user = $this->security->getUser()) instanceof User) { + $qb->andWhere( + $qb->expr()->orX( + 'a.createdBy = :user', + 'a.user = :user', + ':user MEMBER OF a.users' + ) + )->setParameter('user', $user); + } + + if ([] !== ($types = $filters['types'] ?? [])) { + $qb->andWhere('a.activityType IN (:types)')->setParameter('types', $types); + } + + if ([] !== ($jobs = $filters['jobs'] ?? [])) { + $qb + ->leftJoin('a.createdBy', 'creator') + ->leftJoin('a.user', 'activity_u') + ->andWhere( + $qb->expr()->orX( + 'creator.userJob IN (:jobs)', + 'activity_u.userJob IN (:jobs)', + 'EXISTS (SELECT 1 FROM ' . User::class . ' activity_user WHERE activity_user MEMBER OF a.users AND activity_user.userJob IN (:jobs))' + ) + ) + ->setParameter('jobs', $jobs); + } + + if (null !== ($after = $filters['after'] ?? null)) { + $qb->andWhere('a.date >= :after')->setParameter('after', $after); + } + + if (null !== ($before = $filters['before'] ?? null)) { + $qb->andWhere('a.date <= :before')->setParameter('before', $before); + } + + return $qb; + } + + /** + * @param AccompanyingPeriod|Person $associated + * @return array + */ + public function findActivityTypeByAssociated(AccompanyingPeriod|Person $associated): array + { + $in = $this->em->createQueryBuilder(); + $in + ->select('1') + ->from(Activity::class, 'a'); + + if ($associated instanceof Person) { + $in = $this->filterBaseQueryByPerson($in, $associated, ActivityVoter::SEE); + } else { + $in->andWhere('a.accompanyingPeriod = :period')->setParameter('period', $associated); + } + + // join between the embedded exist query and the main query + $in->andWhere('a.activityType = t'); + + $qb = $this->em->createQueryBuilder()->setParameters($in->getParameters()); + $qb + ->select('t') + ->from(ActivityType::class, 't') + ->where( + $qb->expr()->exists($in->getDQL()) + ); + + return $qb->getQuery()->getResult(); + } + + public function findUserJobByAssociated(Person|AccompanyingPeriod $associated): array + { + $in = $this->em->createQueryBuilder(); + $in->select('IDENTITY(u.userJob)') + ->from(User::class, 'u') + ->join( + Activity::class, + 'a', + Join::WITH, + 'a.createdBy = u OR a.user = u OR u MEMBER OF a.users' + ); + + if ($associated instanceof Person) { + $in = $this->filterBaseQueryByPerson($in, $associated, ActivityVoter::SEE); + } else { + $in->andWhere('a.accompanyingPeriod = :associated'); + $in->setParameter('associated', $associated); + } + + $qb = $this->em->createQueryBuilder()->setParameters($in->getParameters()); + + $qb->select('ub', 'JSON_EXTRACT(ub.label, :lang) AS HIDDEN lang') + ->from(UserJob::class, 'ub') + ->where($qb->expr()->in('ub.id', $in->getDQL())) + ->setParameter('lang', $this->requestStack->getCurrentRequest()->getLocale()) + ->orderBy('lang') + ; + + return $qb->getQuery()->getResult(); + } + + public function findByAccompanyingPeriodSimplified(AccompanyingPeriod $period, ?int $limit = 1000): array { $rsm = new ResultSetMappingBuilder($this->em); @@ -159,25 +251,66 @@ final class ActivityACLAwareRepository implements ActivityACLAwareRepositoryInte return $nq->getResult(AbstractQuery::HYDRATE_ARRAY); } - /** - * @param array $orderBy - * - * @return Activity[]|array - */ - public function findByPerson(Person $person, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = []): array + public function findByPerson(Person $person, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = [], array $filters = []): array { - $user = $this->security->getUser(); - $center = $this->centerResolverDispatcher->resolveCenter($person); + $qb = $this->buildBaseQuery($filters); - if (0 === count($orderBy)) { - $orderBy = ['date' => 'DESC']; + $qb = $this->filterBaseQueryByPerson($qb, $person, $role); + + foreach ($orderBy as $field => $direction) { + $qb->addOrderBy('a.' . $field, $direction); } - $reachableScopes = $this->authorizationHelper - ->getReachableCircles($user, $role, $center); + return $qb->getQuery()->getResult(); + } - return $this->em->getRepository(Activity::class) - ->findByPersonImplied($person, $reachableScopes, $orderBy, $limit, $start); + private function filterBaseQueryByPerson(QueryBuilder $qb, Person $person, string $role): QueryBuilder + { + $orX = $qb->expr()->orX(); + $counter = 0; + foreach ($this->centerResolverManager->resolveCenters($person) as $center) { + $scopes = $this->authorizationHelper->getReachableScopes($role, $center); + + if ([] === $scopes) { + continue; + } + + $orX->add(sprintf('a.person = :person AND a.scope IN (:scopes_%d)', $counter)); + $qb->setParameter(sprintf('scopes_%d', $counter), $scopes); + $qb->setParameter('person', $person); + $counter++; + } + + foreach ($person->getAccompanyingPeriodParticipations() as $participation) { + if (!$this->security->isGranted(ActivityVoter::SEE, $participation->getAccompanyingPeriod())) { + continue; + } + + $and = $qb->expr()->andX( + sprintf('a.accompanyingPeriod = :period_%d', $counter), + sprintf('a.date >= :participation_start_%d', $counter) + ); + + $qb + ->setParameter(sprintf('period_%d', $counter), $participation->getAccompanyingPeriod()) + ->setParameter(sprintf('participation_start_%d', $counter), $participation->getStartDate()); + + if (null !== $participation->getEndDate()) { + $and->add(sprintf('a.date < :participation_end_%d', $counter)); + $qb + ->setParameter(sprintf('participation_end_%d', $counter), $participation->getEndDate()); + } + $orX->add($and); + $counter++; + } + + if (0 === $orX->count()) { + $qb->andWhere('FALSE = TRUE'); + } else { + $qb->andWhere($orX); + } + + return $qb; } public function queryTimelineIndexer(string $context, array $args = []): array @@ -226,7 +359,6 @@ final class ActivityACLAwareRepository implements ActivityACLAwareRepositoryInte // acls: $reachableCenters = $this->authorizationHelper->getReachableCenters( - $this->tokenStorage->getToken()->getUser(), ActivityVoter::SEE ); @@ -251,7 +383,7 @@ final class ActivityACLAwareRepository implements ActivityACLAwareRepositoryInte continue; } // we get all the reachable scopes for this center - $reachableScopes = $this->authorizationHelper->getReachableScopes($this->tokenStorage->getToken()->getUser(), ActivityVoter::SEE, $center); + $reachableScopes = $this->authorizationHelper->getReachableScopes(ActivityVoter::SEE, $center); // we get the ids for those scopes $reachablesScopesId = array_map( static fn (Scope $scope) => $scope->getId(), diff --git a/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepositoryInterface.php b/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepositoryInterface.php index 8cdb83524..a046ad218 100644 --- a/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepositoryInterface.php +++ b/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepositoryInterface.php @@ -11,15 +11,22 @@ declare(strict_types=1); namespace Chill\ActivityBundle\Repository; +use Chill\ActivityBundle\Entity\Activity; +use Chill\ActivityBundle\Entity\ActivityType; +use Chill\MainBundle\Entity\UserJob; use Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\Person; interface ActivityACLAwareRepositoryInterface { /** - * @return Activity[]|array + * Return all the activities associated to an accompanying period and that the user is allowed to apply the given role. + * + * + * @param array{my_activities?: bool, types?: array, jobs?: array, after?: \DateTimeImmutable|null, before?: \DateTimeImmutable|null} $filters + * @return array */ - public function findByAccompanyingPeriod(AccompanyingPeriod $period, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = []): array; + public function findByAccompanyingPeriod(AccompanyingPeriod $period, string $role, ?int $start = 0, ?int $limit = 1000, array $orderBy = ['date' => 'DESC'], array $filters = []): array; /** * Return a list of activities, simplified as array (not object). @@ -31,7 +38,28 @@ interface ActivityACLAwareRepositoryInterface public function findByAccompanyingPeriodSimplified(AccompanyingPeriod $period, ?int $limit = 1000): array; /** - * @return Activity[]|array + * @param array{my_activities?: bool, types?: array, jobs?: array, after?: \DateTimeImmutable|null, before?: \DateTimeImmutable|null} $filters + * @return array */ - public function findByPerson(Person $person, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = []): array; + public function findByPerson(Person $person, string $role, ?int $start = 0, ?int $limit = 1000, array $orderBy = ['date' => 'DESC'], array $filters = []): array; + + + /** + * Return a list of the type for the activities associated to person or accompanying period + * + * @return array + */ + public function findActivityTypeByAssociated(AccompanyingPeriod|Person $associated): array; + + /** + * Return a list of the user job for the activities associated to person or accompanying period + * + * Associated mean the job: + * - of the creator; + * - of the user (activity.user) + * - of all the users + * + * @return array + */ + public function findUserJobByAssociated(AccompanyingPeriod|Person $associated): array; } diff --git a/src/Bundle/ChillActivityBundle/Repository/ActivityTypeRepository.php b/src/Bundle/ChillActivityBundle/Repository/ActivityTypeRepository.php index fd5d52fce..10bd1e651 100644 --- a/src/Bundle/ChillActivityBundle/Repository/ActivityTypeRepository.php +++ b/src/Bundle/ChillActivityBundle/Repository/ActivityTypeRepository.php @@ -11,9 +11,13 @@ declare(strict_types=1); namespace Chill\ActivityBundle\Repository; +use Chill\ActivityBundle\Entity\Activity; use Chill\ActivityBundle\Entity\ActivityType; +use Chill\PersonBundle\Entity\AccompanyingPeriod; +use Chill\PersonBundle\Entity\Person; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\Query\Expr\Join; final class ActivityTypeRepository implements ActivityTypeRepositoryInterface { diff --git a/src/Bundle/ChillActivityBundle/Repository/ActivityTypeRepositoryInterface.php b/src/Bundle/ChillActivityBundle/Repository/ActivityTypeRepositoryInterface.php index 2148a02f5..574faea22 100644 --- a/src/Bundle/ChillActivityBundle/Repository/ActivityTypeRepositoryInterface.php +++ b/src/Bundle/ChillActivityBundle/Repository/ActivityTypeRepositoryInterface.php @@ -12,12 +12,14 @@ declare(strict_types=1); namespace Chill\ActivityBundle\Repository; use Chill\ActivityBundle\Entity\ActivityType; +use Chill\PersonBundle\Entity\AccompanyingPeriod; +use Chill\PersonBundle\Entity\Person; use Doctrine\Persistence\ObjectRepository; interface ActivityTypeRepositoryInterface extends ObjectRepository { /** - * @return array|ActivityType[] + * @return array */ public function findAllActive(): array; } diff --git a/src/Bundle/ChillActivityBundle/Resources/views/Activity/list.html.twig b/src/Bundle/ChillActivityBundle/Resources/views/Activity/list.html.twig index 79c946f17..17ae0598b 100644 --- a/src/Bundle/ChillActivityBundle/Resources/views/Activity/list.html.twig +++ b/src/Bundle/ChillActivityBundle/Resources/views/Activity/list.html.twig @@ -86,6 +86,9 @@

{% else %} + + {{ filter|chill_render_filter_order_helper }} +
{% for activity in activities %} {% include 'ChillActivityBundle:Activity:_list_item.html.twig' with { diff --git a/src/Bundle/ChillActivityBundle/Tests/Repository/ActivityACLAwareRepositoryTest.php b/src/Bundle/ChillActivityBundle/Tests/Repository/ActivityACLAwareRepositoryTest.php new file mode 100644 index 000000000..e7b2117d4 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/Tests/Repository/ActivityACLAwareRepositoryTest.php @@ -0,0 +1,325 @@ +authorizationHelperForCurrentUser = self::$container->get(AuthorizationHelperForCurrentUserInterface::class); + $this->centerResolverManager = self::$container->get(CenterResolverManagerInterface::class); + $this->activityRepository = self::$container->get(ActivityRepository::class); + $this->entityManager = self::$container->get(EntityManagerInterface::class); + $this->security = self::$container->get(Security::class); + + $this->requestStack = $requestStack = new RequestStack(); + $request = $this->prophesize(Request::class); + $request->getLocale()->willReturn('fr'); + $request->getDefaultLocale()->willReturn('fr'); + $requestStack->push($request->reveal()); + } + + /** + * @dataProvider provideDataFindByAccompanyingPeriod + */ + public function testFindByAccompanyingPeriod(AccompanyingPeriod $period, User $user, string $role, ?int $start = 0, ?int $limit = 1000, array $orderBy = ['date' => 'DESC'], array $filters = []): void + { + $security = $this->prophesize(Security::class); + $security->isGranted($role, $period)->willReturn(true); + $security->getUser()->willReturn($user); + + $repository = new ActivityACLAwareRepository( + $this->authorizationHelperForCurrentUser, + $this->centerResolverManager, + $this->activityRepository, + $this->entityManager, + $security->reveal(), + $this->requestStack + ); + + $actual = $repository->findByAccompanyingPeriod($period, $role, $start, $limit, $orderBy, $filters); + + self::assertIsArray($actual); + } + + /** + * @dataProvider provideDataFindByAccompanyingPeriod + */ + public function testFindActivityTypeByAccompanyingPeriod(AccompanyingPeriod $period, User $user, string $role, ?int $start = 0, ?int $limit = 1000, array $orderBy = ['date' => 'DESC'], array $filters = []): void + { + $security = $this->prophesize(Security::class); + $security->isGranted($role, $period)->willReturn(true); + $security->getUser()->willReturn($user); + + $repository = new ActivityACLAwareRepository( + $this->authorizationHelperForCurrentUser, + $this->centerResolverManager, + $this->activityRepository, + $this->entityManager, + $security->reveal(), + $this->requestStack + ); + + $actual = $repository->findActivityTypeByAssociated($period); + + self::assertIsArray($actual); + } + + /** + * @dataProvider provideDataFindByPerson + */ + public function testFindActivityTypeByPerson(Person $person, User $user, array $centers, array $scopes, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = [], array $filters = []): void + { + $role = ActivityVoter::SEE; + $centerResolver = $this->prophesize(CenterResolverManagerInterface::class); + $centerResolver->resolveCenters($person)->willReturn($centers); + + $authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class); + $authorizationHelper->getReachableScopes($role, Argument::type(Center::class)) + ->willReturn($scopes); + + $security = $this->prophesize(Security::class); + $security->isGranted($role, Argument::type(AccompanyingPeriod::class))->willReturn(true); + $security->getUser()->willReturn($user); + + $repository = new ActivityACLAwareRepository( + $authorizationHelper->reveal(), + $centerResolver->reveal(), + $this->activityRepository, + $this->entityManager, + $security->reveal(), + $this->requestStack + ); + + $actual = $repository->findByPerson($person, $role, $start, $limit, $orderBy, $filters); + + self::assertIsArray($actual); + } + + /** + * @dataProvider provideDataFindByPerson + */ + public function testFindByPerson(Person $person, User $user, array $centers, array $scopes, string $role, ?int $start = 0, ?int $limit = 1000, ?array $orderBy = [], array $filters = []): void + { + $centerResolver = $this->prophesize(CenterResolverManagerInterface::class); + $centerResolver->resolveCenters($person)->willReturn($centers); + + $authorizationHelper = $this->prophesize(AuthorizationHelperForCurrentUserInterface::class); + $authorizationHelper->getReachableScopes($role, Argument::type(Center::class)) + ->willReturn($scopes); + + $security = $this->prophesize(Security::class); + $security->isGranted($role, Argument::type(AccompanyingPeriod::class))->willReturn(true); + $security->getUser()->willReturn($user); + + $repository = new ActivityACLAwareRepository( + $authorizationHelper->reveal(), + $centerResolver->reveal(), + $this->activityRepository, + $this->entityManager, + $security->reveal(), + $this->requestStack + ); + + $actual = $repository->findByPerson($person, $role, $start, $limit, $orderBy, $filters); + + self::assertIsArray($actual); + } + + public function provideDataFindByPerson(): iterable + { + $this->setUp(); + + /** @var Person $person */ + if (null === $person = $this->entityManager->createQueryBuilder() + ->select('p')->from(Person::class, 'p')->setMaxResults(1) + ->getQuery()->getSingleResult()) { + throw new \RuntimeException("person not found"); + } + + /** @var AccompanyingPeriod $period1 */ + if (null === $period1 = $this->entityManager + ->createQueryBuilder() + ->select('a') + ->from(AccompanyingPeriod::class, 'a') + ->setMaxResults(1) + ->getQuery() + ->getSingleResult()) { + throw new \RuntimeException("no period found"); + } + + /** @var AccompanyingPeriod $period2 */ + if (null === $period2 = $this->entityManager + ->createQueryBuilder() + ->select('a') + ->from(AccompanyingPeriod::class, 'a') + ->where('a.id > :pid') + ->setParameter('pid', $period1->getId()) + ->setMaxResults(1) + ->getQuery() + ->getSingleResult()) { + throw new \RuntimeException("no second period found"); + } + // add a period + $period1->addPerson($person); + $period2->addPerson($person); + $period1->getParticipationsContainsPerson($person)->first()->setEndDate( + (new \DateTime('now'))->add(new \DateInterval('P1M')) + ); + + if ([] === $types = $this->entityManager + ->createQueryBuilder() + ->select('t') + ->from(ActivityType::class, 't') + ->setMaxResults(2) + ->getQuery() + ->getResult()) { + throw new \RuntimeException("no types"); + } + + if ([] === $jobs = $this->entityManager + ->createQueryBuilder() + ->select('j') + ->from(UserJob::class, 'j') + ->setMaxResults(2) + ->getQuery() + ->getResult() + ) { + throw new \RuntimeException("no jobs found"); + } + + if (null === $user = $this->entityManager + ->createQueryBuilder() + ->select('u') + ->from(User::class, 'u') + ->setMaxResults(1) + ->getQuery() + ->getSingleResult() + ) { + throw new \RuntimeException("no user found"); + } + + if ([] === $centers = $this->entityManager->createQueryBuilder() + ->select('c')->from(Center::class, 'c')->setMaxResults(2)->getQuery() + ->getResult()) { + throw new \RuntimeException("no centers found"); + } + + if ([] === $scopes = $this->entityManager->createQueryBuilder() + ->select('s')->from(Scope::class, 's')->setMaxResults(2)->getQuery() + ->getResult()) { + throw new \RuntimeException("no scopes found"); + } + + yield [$person, $user, $centers, $scopes, ActivityVoter::SEE, 0, 5, ['date' => 'DESC'], []]; + yield [$person, $user, $centers, $scopes, ActivityVoter::SEE, 0, 5, ['date' => 'DESC'], ['my_activities' => true]]; + yield [$person, $user, $centers, $scopes, ActivityVoter::SEE, 0, 5, ['date' => 'DESC'], ['types' => $types]]; + yield [$person, $user, $centers, $scopes, ActivityVoter::SEE, 0, 5, ['date' => 'DESC'], ['jobs' => $jobs]]; + yield [$person, $user, $centers, $scopes, ActivityVoter::SEE, 0, 5, ['date' => 'DESC'], ['after' => new \DateTimeImmutable('1 year ago')]]; + yield [$person, $user, $centers, $scopes, ActivityVoter::SEE, 0, 5, ['date' => 'DESC'], ['before' => new \DateTimeImmutable('1 year ago')]]; + yield [$person, $user, $centers, $scopes, ActivityVoter::SEE, 0, 5, ['date' => 'DESC'], ['after' => new \DateTimeImmutable('1 year ago'), 'before' => new \DateTimeImmutable('1 month ago')]]; + } + + public function provideDataFindByAccompanyingPeriod(): iterable + { + $this->setUp(); + + if (null === $period = $this->entityManager + ->createQueryBuilder() + ->select('a') + ->from(AccompanyingPeriod::class, 'a') + ->setMaxResults(1) + ->getQuery() + ->getSingleResult()) { + throw new \RuntimeException("no period found"); + } + + if ([] === $types = $this->entityManager + ->createQueryBuilder() + ->select('t') + ->from(ActivityType::class, 't') + ->setMaxResults(2) + ->getQuery() + ->getResult()) { + throw new \RuntimeException("no types"); + } + + if ([] === $jobs = $this->entityManager + ->createQueryBuilder() + ->select('j') + ->from(UserJob::class, 'j') + ->setMaxResults(2) + ->getQuery() + ->getResult() + ) { + throw new \RuntimeException("no jobs found"); + } + + if (null === $user = $this->entityManager + ->createQueryBuilder() + ->select('u') + ->from(User::class, 'u') + ->setMaxResults(1) + ->getQuery() + ->getSingleResult() + ) { + throw new \RuntimeException("no user found"); + } + + yield [$period, $user, ActivityVoter::SEE, 0, 10, ['date' => 'DESC'], []]; + yield [$period, $user, ActivityVoter::SEE, 0, 10, ['date' => 'DESC'], ['my_activities' => true]]; + yield [$period, $user, ActivityVoter::SEE, 0, 10, ['date' => 'DESC'], ['types' => $types]]; + yield [$period, $user, ActivityVoter::SEE, 0, 10, ['date' => 'DESC'], ['jobs' => $jobs]]; + yield [$period, $user, ActivityVoter::SEE, 0, 10, ['date' => 'DESC'], ['after' => new \DateTimeImmutable('1 year ago')]]; + yield [$period, $user, ActivityVoter::SEE, 0, 10, ['date' => 'DESC'], ['before' => new \DateTimeImmutable('1 year ago')]]; + yield [$period, $user, ActivityVoter::SEE, 0, 10, ['date' => 'DESC'], ['after' => new \DateTimeImmutable('1 year ago'), 'before' => new \DateTimeImmutable('1 month ago')]]; + } +} diff --git a/src/Bundle/ChillActivityBundle/translations/messages.fr.yml b/src/Bundle/ChillActivityBundle/translations/messages.fr.yml index 042fccc69..1229494bb 100644 --- a/src/Bundle/ChillActivityBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillActivityBundle/translations/messages.fr.yml @@ -83,12 +83,20 @@ Third persons: Tiers non-pro. Others persons: Usagers Third parties: Tiers professionnels Users concerned: T(M)S + activity: + date: Date de l'échange Insert a document: Insérer un document Remove a document: Supprimer le document comment: Commentaire No documents: Aucun document +# activity filter in list page +activity_filter: + My activities: Mes échanges (où j'interviens) + Types: Par type d'échange + Jobs: Par métier impliqué + #timeline '%user% has done an %activity_type%': '%user% a effectué un échange de type "%activity_type%"' From 0e5f1b4ab9c7d8e693332d42709841249e68a0eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 23 Jun 2023 12:44:54 +0200 Subject: [PATCH 4/6] Feature: [activity list] add pagination --- .../unreleased/Feature-20230623-124438.yaml | 5 +++ .../Controller/ActivityController.php | 18 +++++--- .../Repository/ActivityACLAwareRepository.php | 43 ++++++++++++++++++- .../ActivityACLAwareRepositoryInterface.php | 10 +++++ .../Resources/views/Activity/list.html.twig | 6 ++- 5 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 .changes/unreleased/Feature-20230623-124438.yaml diff --git a/.changes/unreleased/Feature-20230623-124438.yaml b/.changes/unreleased/Feature-20230623-124438.yaml new file mode 100644 index 000000000..bc199d3bb --- /dev/null +++ b/.changes/unreleased/Feature-20230623-124438.yaml @@ -0,0 +1,5 @@ +kind: Feature +body: '[activity list] add pagination to the list of activities' +time: 2023-06-23T12:44:38.879098862+02:00 +custom: + Issue: "" diff --git a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php index 07e4fcf62..63639c149 100644 --- a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php +++ b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php @@ -22,6 +22,7 @@ use Chill\ActivityBundle\Repository\ActivityUserJobRepository; use Chill\ActivityBundle\Security\Authorization\ActivityVoter; use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable; use Chill\MainBundle\Entity\UserJob; +use Chill\MainBundle\Pagination\PaginatorFactory; use Chill\MainBundle\Repository\LocationRepository; use Chill\MainBundle\Repository\UserRepositoryInterface; use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; @@ -70,6 +71,7 @@ final class ActivityController extends AbstractController private readonly TranslatorInterface $translator, private readonly FilterOrderHelperFactoryInterface $filterOrderHelperFactory, private readonly TranslatableStringHelperInterface $translatableStringHelper, + private readonly PaginatorFactory $paginatorFactory, ) { } @@ -251,7 +253,6 @@ final class ActivityController extends AbstractController { $view = null; $activities = []; - // TODO: add pagination [$person, $accompanyingPeriod] = $this->getEntity($request); $filter = $this->buildFilterOrder($person ?? $accompanyingPeriod); @@ -266,12 +267,14 @@ final class ActivityController extends AbstractController if ($person instanceof Person) { $this->denyAccessUnlessGranted(ActivityVoter::SEE, $person); + $count = $this->activityACLAwareRepository->countByPerson($person, ActivityVoter::SEE, $filterArgs); + $paginator = $this->paginatorFactory->create($count); $activities = $this->activityACLAwareRepository ->findByPerson( $person, ActivityVoter::SEE, - 0, - null, + $paginator->getCurrentPageFirstItemNumber(), + $paginator->getItemsPerPage(), ['date' => 'DESC', 'id' => 'DESC'], $filterArgs ); @@ -286,17 +289,21 @@ final class ActivityController extends AbstractController } elseif ($accompanyingPeriod instanceof AccompanyingPeriod) { $this->denyAccessUnlessGranted(ActivityVoter::SEE, $accompanyingPeriod); + $count = $this->activityACLAwareRepository->countByAccompanyingPeriod($accompanyingPeriod, ActivityVoter::SEE, $filterArgs); + $paginator = $this->paginatorFactory->create($count); $activities = $this->activityACLAwareRepository ->findByAccompanyingPeriod( $accompanyingPeriod, ActivityVoter::SEE, - 0, - null, + $paginator->getCurrentPageFirstItemNumber(), + $paginator->getItemsPerPage(), ['date' => 'DESC', 'id' => 'DESC'], $filterArgs ); $view = 'ChillActivityBundle:Activity:listAccompanyingCourse.html.twig'; + } else { + throw new \LogicException("Unsupported"); } return $this->render( @@ -306,6 +313,7 @@ final class ActivityController extends AbstractController 'person' => $person, 'accompanyingCourse' => $accompanyingPeriod, 'filter' => $filter, + 'paginator' => $paginator, ] ); } diff --git a/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepository.php b/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepository.php index 1fd0a57d6..1544dd764 100644 --- a/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepository.php +++ b/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepository.php @@ -27,6 +27,8 @@ use Chill\PersonBundle\Entity\Person; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\AbstractQuery; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\NonUniqueResultException; +use Doctrine\ORM\NoResultException; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\QueryBuilder; @@ -48,6 +50,33 @@ final readonly class ActivityACLAwareRepository implements ActivityACLAwareRepos ) { } + /** + * @throws NonUniqueResultException + * @throws NoResultException + */ + public function countByAccompanyingPeriod(AccompanyingPeriod $period, string $role, array $filters = []): int + { + $qb = $this->buildBaseQuery($filters); + + $qb + ->select('COUNT(a)') + ->andWhere('a.accompanyingPeriod = :period')->setParameter('period', $period); + + return $qb->getQuery()->getSingleScalarResult(); + } + + public function countByPerson(Person $person, string $role, array $filters = []): int + { + $qb = $this->buildBaseQuery($filters); + + $qb = $this->filterBaseQueryByPerson($qb, $person, $role); + + $qb->select('COUNT(a)'); + + return $qb->getQuery()->getSingleScalarResult(); + } + + public function findByAccompanyingPeriod(AccompanyingPeriod $period, string $role, ?int $start = 0, ?int $limit = 1000, array $orderBy = ['date' => 'DESC'], array $filters = []): array { $qb = $this->buildBaseQuery($filters); @@ -58,7 +87,12 @@ final readonly class ActivityACLAwareRepository implements ActivityACLAwareRepos $qb->addOrderBy('a.' . $field, $order); } - $qb->setFirstResult(0)->setMaxResults(1000); + if (null !== $start) { + $qb->setFirstResult($start); + } + if (null !== $limit) { + $qb->setMaxResults($limit); + } return $qb->getQuery()->getResult(); } @@ -261,6 +295,13 @@ final readonly class ActivityACLAwareRepository implements ActivityACLAwareRepos $qb->addOrderBy('a.' . $field, $direction); } + if (null !== $start) { + $qb->setFirstResult($start); + } + if (null !== $limit) { + $qb->setMaxResults($limit); + } + return $qb->getQuery()->getResult(); } diff --git a/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepositoryInterface.php b/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepositoryInterface.php index a046ad218..474d8ad16 100644 --- a/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepositoryInterface.php +++ b/src/Bundle/ChillActivityBundle/Repository/ActivityACLAwareRepositoryInterface.php @@ -28,6 +28,16 @@ interface ActivityACLAwareRepositoryInterface */ public function findByAccompanyingPeriod(AccompanyingPeriod $period, string $role, ?int $start = 0, ?int $limit = 1000, array $orderBy = ['date' => 'DESC'], array $filters = []): array; + /** + * @param array{my_activities?: bool, types?: array, jobs?: array, after?: \DateTimeImmutable|null, before?: \DateTimeImmutable|null} $filters + */ + public function countByAccompanyingPeriod(AccompanyingPeriod $period, string $role, array $filters = []): int; + + /** + * @param array{my_activities?: bool, types?: array, jobs?: array, after?: \DateTimeImmutable|null, before?: \DateTimeImmutable|null} $filters + */ + public function countByPerson(Person $person, string $role, array $filters = []): int; + /** * Return a list of activities, simplified as array (not object). * diff --git a/src/Bundle/ChillActivityBundle/Resources/views/Activity/list.html.twig b/src/Bundle/ChillActivityBundle/Resources/views/Activity/list.html.twig index 17ae0598b..dd78c9396 100644 --- a/src/Bundle/ChillActivityBundle/Resources/views/Activity/list.html.twig +++ b/src/Bundle/ChillActivityBundle/Resources/views/Activity/list.html.twig @@ -80,6 +80,8 @@
+ {{ filter|chill_render_filter_order_helper }} + {% if activities|length == 0 %}

{{ "There isn't any activities."|trans }} @@ -87,8 +89,6 @@ {% else %} - {{ filter|chill_render_filter_order_helper }} -

{% for activity in activities %} {% include 'ChillActivityBundle:Activity:_list_item.html.twig' with { @@ -99,4 +99,6 @@
{% endif %} + {{ chill_pagination(paginator) }} +
From c04fd66163f2014ac54362f56bafebca9f38e1ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Wed, 5 Jul 2023 22:20:27 +0200 Subject: [PATCH 5/6] do not show filter on job or activity type if less than 2 possibilities --- .../Controller/ActivityController.php | 39 +++++++++++-------- .../Templating/Listing/FilterOrderHelper.php | 20 ++++++++++ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php index 63639c149..9b6e69bf0 100644 --- a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php +++ b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php @@ -259,8 +259,8 @@ final class ActivityController extends AbstractController $filterArgs = [ 'my_activities' => $filter->getSingleCheckboxData('my_activities'), - 'types' => $filter->getEntityChoiceData('activity_types'), - 'jobs' => $filter->getEntityChoiceData('jobs'), + 'types' => $filter->hasEntityChoice('activity_type') ? $filter->getEntityChoiceData('activity_types') : [], + 'jobs' => $filter->hasEntityChoice('jobs') ? $filter->getEntityChoiceData('jobs') : [], 'before' => $filter->getDateRangeData('activity_date')['to'], 'after' => $filter->getDateRangeData('activity_date')['from'], ]; @@ -327,21 +327,28 @@ final class ActivityController extends AbstractController $filterBuilder ->addDateRange('activity_date', 'activity.date') - ->addSingleCheckbox('my_activities', 'activity_filter.My activities') - ->addEntityChoice('activity_types', 'activity_filter.Types', \Chill\ActivityBundle\Entity\ActivityType::class, $types, [ - 'choice_label' => function (\Chill\ActivityBundle\Entity\ActivityType $activityType) { - $text = match ($activityType->hasCategory()) { - true => $this->translatableStringHelper->localize($activityType->getCategory()->getName()) . ' > ', - false => '', - }; + ->addSingleCheckbox('my_activities', 'activity_filter.My activities'); - return $text . $this->translatableStringHelper->localize($activityType->getName()); - } - ]) - ->addEntityChoice('jobs', 'activity_filter.Jobs', UserJob::class, $jobs, [ - 'choice_label' => fn (UserJob $u) => $this->translatableStringHelper->localize($u->getLabel()) - ]) - ; + if (1 < count($types)) { + $filterBuilder + ->addEntityChoice('activity_types', 'activity_filter.Types', \Chill\ActivityBundle\Entity\ActivityType::class, $types, [ + 'choice_label' => function (\Chill\ActivityBundle\Entity\ActivityType $activityType) { + $text = match ($activityType->hasCategory()) { + true => $this->translatableStringHelper->localize($activityType->getCategory()->getName()) . ' > ', + false => '', + }; + + return $text . $this->translatableStringHelper->localize($activityType->getName()); + } + ]); + } + + if (1 < count($jobs)) { + $filterBuilder + ->addEntityChoice('jobs', 'activity_filter.Jobs', UserJob::class, $jobs, [ + 'choice_label' => fn (UserJob $u) => $this->translatableStringHelper->localize($u->getLabel()) + ]); + } return $filterBuilder->build(); } diff --git a/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelper.php b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelper.php index 28cc8e331..5c9971890 100644 --- a/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelper.php +++ b/src/Bundle/ChillMainBundle/Templating/Listing/FilterOrderHelper.php @@ -116,16 +116,31 @@ class FilterOrderHelper ->handleRequest($this->requestStack->getCurrentRequest()); } + public function hasCheckboxData(string $name): bool + { + return array_key_exists($name, $this->checkboxes); + } + public function getCheckboxData(string $name): array { return $this->getFormData()['checkboxes'][$name]; } + public function hasSingleCheckboxData(string $name): bool + { + return array_key_exists($name, $this->singleCheckbox); + } + public function getSingleCheckboxData(string $name): ?bool { return $this->getFormData()['single_checkboxes'][$name]; } + public function hasEntityChoice(string $name): bool + { + return array_key_exists($name, $this->entityChoices); + } + public function getEntityChoiceData($name): mixed { return $this->getFormData()['entity_choices'][$name]; @@ -144,6 +159,11 @@ class FilterOrderHelper return $this->singleCheckbox; } + public function hasDateRangeData(string $name): bool + { + return array_key_exists($name, $this->dateRanges); + } + /** * @return array{to: ?DateTimeImmutable, from: ?DateTimeImmutable} */ From 6bdb3e969538bc3cf0ad3458d5015d3ab180626e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 7 Jul 2023 21:49:36 +0200 Subject: [PATCH 6/6] fix typo which prevent to apply a filter on activity types --- .../ChillActivityBundle/Controller/ActivityController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php index 9b6e69bf0..1e911ff08 100644 --- a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php +++ b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php @@ -259,7 +259,7 @@ final class ActivityController extends AbstractController $filterArgs = [ 'my_activities' => $filter->getSingleCheckboxData('my_activities'), - 'types' => $filter->hasEntityChoice('activity_type') ? $filter->getEntityChoiceData('activity_types') : [], + 'types' => $filter->hasEntityChoice('activity_types') ? $filter->getEntityChoiceData('activity_types') : [], 'jobs' => $filter->hasEntityChoice('jobs') ? $filter->getEntityChoiceData('jobs') : [], 'before' => $filter->getDateRangeData('activity_date')['to'], 'after' => $filter->getDateRangeData('activity_date')['from'],