Merge branch 'user_filter_tasks' into 'master'

Filter on user within task list

See merge request Chill-Projet/chill-bundles!569
This commit is contained in:
Julien Fastré 2023-07-13 16:07:54 +00:00
commit aea6796ba1
14 changed files with 227 additions and 20 deletions

View File

@ -0,0 +1,5 @@
kind: Feature
body: Allow filtering on the basis of a user within general tasks list
time: 2023-07-05T14:03:36.664880092+02:00
custom:
Issue: ""

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form\Type\Listing; namespace Chill\MainBundle\Form\Type\Listing;
use Chill\MainBundle\Form\Type\ChillDateType; use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\PickUserDynamicType;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper; use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@ -114,6 +115,28 @@ final class FilterOrderType extends \Symfony\Component\Form\AbstractType
$builder->add($singleCheckBoxBuilder); $builder->add($singleCheckBoxBuilder);
} }
if ([] !== $helper->getUserPickers()) {
$userPickersBuilder = $builder->create('user_pickers', null, ['compound' => true]);
foreach ($helper->getUserPickers() as $name => [
'label' => $label, 'options' => $opts
]) {
$userPickersBuilder->add(
$name,
PickUserDynamicType::class,
[
'multiple' => true,
'label' => $label,
...$opts,
]
);
}
$builder->add($userPickersBuilder);
}
} }
public static function buildCheckboxChoices(array $choices, array $trans = []): array public static function buildCheckboxChoices(array $choices, array $trans = []): array

View File

@ -18,6 +18,7 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if form.dateRanges is defined %} {% if form.dateRanges is defined %}
{% set btnSubmit = 1 %} {% set btnSubmit = 1 %}
{% if form.dateRanges|length > 0 %} {% if form.dateRanges|length > 0 %}
@ -40,6 +41,7 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if form.checkboxes is defined %} {% if form.checkboxes is defined %}
{% set btnSubmit = 1 %} {% set btnSubmit = 1 %}
{% if form.checkboxes|length > 0 %} {% if form.checkboxes|length > 0 %}
@ -56,6 +58,7 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if form.entity_choices is defined %} {% if form.entity_choices is defined %}
{% set btnSubmit = 1 %} {% set btnSubmit = 1 %}
{% if form.entity_choices |length > 0 %} {% if form.entity_choices |length > 0 %}
@ -74,6 +77,25 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if form.user_pickers is defined %}
{% set btnSubmit = 1 %}
{% if form.user_pickers.children|length > 0 %}
{% for name, options in form.user_pickers %}
<div class="row my-2">
{% if form.user_pickers[name].vars.label is not same as(false) %}
{{ form_label(form.user_pickers[name]) }}
{% else %}
{{ form_label(form.user_pickers[name].vars.label) }}
{% endif %}
<div class="col-sm-8 pt-2">
{{ form_widget(form.user_pickers[name]) }}
</div>
</div>
{% endfor %}
{% endif %}
{% endif %}
{% if form.single_checkboxes is defined %} {% if form.single_checkboxes is defined %}
{% set btnSubmit = 1 %} {% set btnSubmit = 1 %}
{% for name, _o in form.single_checkboxes %} {% for name, _o in form.single_checkboxes %}
@ -91,8 +113,10 @@
<button type="submit" class="btn btn-sm btn-misc"><i class="fa fa-fw fa-filter"></i>{{ 'Filter'|trans }}</button> <button type="submit" class="btn btn-sm btn-misc"><i class="fa fa-fw fa-filter"></i>{{ 'Filter'|trans }}</button>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if active|length > 0 %} {% if active|length > 0 %}
<div class="activeFilters mt-3"> <div class="activeFilters mt-3">
{% for f in active %} {% for f in active %}

View File

@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Templating\Listing; namespace Chill\MainBundle\Templating\Listing;
use Chill\MainBundle\Templating\Entity\UserRender;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyAccess\PropertyPathInterface; use Symfony\Component\PropertyAccess\PropertyPathInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
@ -20,6 +21,7 @@ final readonly class FilterOrderGetActiveFilterHelper
public function __construct( public function __construct(
private TranslatorInterface $translator, private TranslatorInterface $translator,
private PropertyAccessorInterface $propertyAccessor, private PropertyAccessorInterface $propertyAccessor,
private UserRender $userRender,
) { ) {
} }
@ -73,6 +75,12 @@ final readonly class FilterOrderGetActiveFilterHelper
} }
} }
foreach ($filterOrderHelper->getUserPickers() as $name => ['label' => $label, 'options' => $options]) {
foreach ($filterOrderHelper->getUserPickerData($name) as $user) {
$result[] = ['value' => $this->userRender->renderString($user, []), 'label' => (string) $label, 'position' => FilterOrderPositionEnum::UserPicker->value, 'name' => $name];
}
}
foreach ($filterOrderHelper->getSingleCheckbox() as $name => ['label' => $label]) { foreach ($filterOrderHelper->getSingleCheckbox() as $name => ['label' => $label]) {
if (true === $filterOrderHelper->getSingleCheckboxData($name)) { if (true === $filterOrderHelper->getSingleCheckboxData($name)) {
$result[] = ['label' => '', 'value' => $this->translator->trans($label), 'position' => FilterOrderPositionEnum::SingleCheckbox->value, 'name' => $name]; $result[] = ['label' => '', 'value' => $this->translator->trans($label), 'position' => FilterOrderPositionEnum::SingleCheckbox->value, 'name' => $name];

View File

@ -11,8 +11,11 @@ declare(strict_types=1);
namespace Chill\MainBundle\Templating\Listing; namespace Chill\MainBundle\Templating\Listing;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\Listing\FilterOrderType; use Chill\MainBundle\Form\Type\Listing\FilterOrderType;
use DateTimeImmutable; 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\FormFactoryInterface;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
@ -52,6 +55,11 @@ final class FilterOrderHelper
private array $entityChoices = []; private array $entityChoices = [];
/**
* @var array<string, array{label: string, options: array}>
*/
private array $userPickers = [];
public function __construct( public function __construct(
private readonly FormFactoryInterface $formFactory, private readonly FormFactoryInterface $formFactory,
private readonly RequestStack $requestStack, private readonly RequestStack $requestStack,
@ -80,6 +88,14 @@ final class FilterOrderHelper
return $this->entityChoices; return $this->entityChoices;
} }
public function addUserPicker(string $name, ?string $label = null, array $options = []): self
{
$this->userPickers[$name] = ['label' => $label, 'options' => $options];
return $this;
}
public function addCheckbox(string $name, array $choices, ?array $default = [], ?array $trans = [], array $options = []): self public function addCheckbox(string $name, array $choices, ?array $default = [], ?array $trans = [], array $options = []): self
{ {
if ([] === $trans) { if ([] === $trans) {
@ -114,6 +130,19 @@ final class FilterOrderHelper
->handleRequest($this->requestStack->getCurrentRequest()); ->handleRequest($this->requestStack->getCurrentRequest());
} }
public function getUserPickers(): array
{
return $this->userPickers;
}
/**
* @return list<User>
*/
public function getUserPickerData(string $name): array
{
return $this->getFormData()['user_pickers'][$name];
}
public function hasCheckboxData(string $name): bool public function hasCheckboxData(string $name): bool
{ {
return array_key_exists($name, $this->checkboxes); return array_key_exists($name, $this->checkboxes);
@ -203,7 +232,8 @@ final class FilterOrderHelper
'checkboxes' => [], 'checkboxes' => [],
'dateRanges' => [], 'dateRanges' => [],
'single_checkboxes' => [], 'single_checkboxes' => [],
'entity_choices' => [] 'entity_choices' => [],
'user_pickers' => []
]; ];
if ($this->hasSearchBox()) { if ($this->hasSearchBox()) {
@ -227,6 +257,10 @@ final class FilterOrderHelper
$r['entity_choices'][$name] = ($c['options']['multiple'] ?? true) ? [] : null; $r['entity_choices'][$name] = ($c['options']['multiple'] ?? true) ? [] : null;
} }
foreach ($this->userPickers as $name => $u) {
$r['user_pickers'][$name] = ($u['options']['multiple'] ?? true) ? [] : null;
}
return $r; return $r;
} }

View File

@ -39,6 +39,11 @@ class FilterOrderHelperBuilder
*/ */
private array $entityChoices = []; private array $entityChoices = [];
/**
* @var array<string, array{label: string, options: array}>
*/
private array $userPickers = [];
public function __construct( public function __construct(
FormFactoryInterface $formFactory, FormFactoryInterface $formFactory,
RequestStack $requestStack, RequestStack $requestStack,
@ -85,6 +90,13 @@ class FilterOrderHelperBuilder
return $this; return $this;
} }
public function addUserPicker(string $name, ?string $label = null, ?array $options = []): self
{
$this->userPickers[$name] = ['label' => $label, 'options' => $options];
return $this;
}
public function build(): FilterOrderHelper public function build(): FilterOrderHelper
{ {
$helper = new FilterOrderHelper( $helper = new FilterOrderHelper(
@ -126,6 +138,17 @@ class FilterOrderHelperBuilder
$helper->addDateRange($name, $label, $from, $to); $helper->addDateRange($name, $label, $from, $to);
} }
foreach (
$this->userPickers as $name => [
'label' => $label,
'options' => $options
]
) {
$helper->addUserPicker($name, $label, $options);
}
return $helper; return $helper;
} }
} }

View File

@ -18,4 +18,5 @@ enum FilterOrderPositionEnum: string
case DateRange = 'date_range'; case DateRange = 'date_range';
case EntityChoice = 'entity_choice'; case EntityChoice = 'entity_choice';
case SingleCheckbox = 'single_checkbox'; case SingleCheckbox = 'single_checkbox';
case UserPicker = 'user_picker';
} }

View File

@ -26,6 +26,7 @@ use Chill\TaskBundle\Event\TaskEvent;
use Chill\TaskBundle\Event\UI\UIEvent; use Chill\TaskBundle\Event\UI\UIEvent;
use Chill\TaskBundle\Form\SingleTaskType; use Chill\TaskBundle\Form\SingleTaskType;
use Chill\TaskBundle\Repository\SingleTaskAclAwareRepositoryInterface; use Chill\TaskBundle\Repository\SingleTaskAclAwareRepositoryInterface;
use Chill\TaskBundle\Repository\SingleTaskStateRepository;
use Chill\TaskBundle\Security\Authorization\TaskVoter; use Chill\TaskBundle\Security\Authorization\TaskVoter;
use LogicException; use LogicException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@ -71,7 +72,8 @@ final class SingleTaskController extends AbstractController
EventDispatcherInterface $eventDispatcher, EventDispatcherInterface $eventDispatcher,
TimelineBuilder $timelineBuilder, TimelineBuilder $timelineBuilder,
LoggerInterface $logger, LoggerInterface $logger,
FilterOrderHelperFactoryInterface $filterOrderHelperFactory FilterOrderHelperFactoryInterface $filterOrderHelperFactory,
private SingleTaskStateRepository $singleTaskStateRepository
) { ) {
$this->eventDispatcher = $eventDispatcher; $this->eventDispatcher = $eventDispatcher;
$this->timelineBuilder = $timelineBuilder; $this->timelineBuilder = $timelineBuilder;
@ -299,13 +301,17 @@ final class SingleTaskController extends AbstractController
$this->denyAccessUnlessGranted(TaskVoter::SHOW, null); $this->denyAccessUnlessGranted(TaskVoter::SHOW, null);
$filterOrder = $this->buildFilterOrder(); $filterOrder = $this->buildFilterOrder();
$filteredUsers = $filterOrder->getUserPickerData('userPicker');
$flags = array_merge( $flags = array_merge(
$filterOrder->getCheckboxData('status'), $filterOrder->getCheckboxData('status'),
array_map(static fn ($i) => 'state_' . $i, $filterOrder->getCheckboxData('states')) array_map(static fn ($i) => 'state_' . $i, $filterOrder->getCheckboxData('states'))
); );
$nb = $this->singleTaskAclAwareRepository->countByAllViewable( $nb = $this->singleTaskAclAwareRepository->countByAllViewable(
$filterOrder->getQueryString(), $filterOrder->getQueryString(),
$flags $flags,
$filteredUsers
); );
$paginator = $this->paginatorFactory->create($nb); $paginator = $this->paginatorFactory->create($nb);
@ -313,6 +319,7 @@ final class SingleTaskController extends AbstractController
$tasks = $this->singleTaskAclAwareRepository->findByAllViewable( $tasks = $this->singleTaskAclAwareRepository->findByAllViewable(
$filterOrder->getQueryString(), $filterOrder->getQueryString(),
$flags, $flags,
$filteredUsers,
$paginator->getCurrentPageFirstItemNumber(), $paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage(), $paginator->getItemsPerPage(),
[ [
@ -447,7 +454,7 @@ final class SingleTaskController extends AbstractController
{ {
$this->denyAccessUnlessGranted('ROLE_USER'); $this->denyAccessUnlessGranted('ROLE_USER');
$filterOrder = $this->buildFilterOrder(); $filterOrder = $this->buildFilterOrder(false);
$flags = array_merge( $flags = array_merge(
$filterOrder->getCheckboxData('status'), $filterOrder->getCheckboxData('status'),
array_map(static fn ($i) => 'state_' . $i, $filterOrder->getCheckboxData('states')) array_map(static fn ($i) => 'state_' . $i, $filterOrder->getCheckboxData('states'))
@ -662,7 +669,7 @@ final class SingleTaskController extends AbstractController
return $form; return $form;
} }
private function buildFilterOrder(): FilterOrderHelper private function buildFilterOrder($includeFilterByUser = true): FilterOrderHelper
{ {
$statuses = ['no-alert', 'warning', 'alert']; $statuses = ['no-alert', 'warning', 'alert'];
$statusTrans = [ $statusTrans = [
@ -670,17 +677,22 @@ final class SingleTaskController extends AbstractController
'Tasks near deadline', 'Tasks near deadline',
'Tasks over deadline', 'Tasks over deadline',
]; ];
$states = [ $states = $this->singleTaskStateRepository->findAllExistingStates();
// todo: get a list of possible states dynamically $checked = array_values(array_filter($states, fn (string $state) => !in_array($state, ['closed', 'canceled', 'validated'], true)));
'new', 'in_progress', 'closed', 'canceled',
];
return $this->filterOrderHelperFactory $filterBuilder = $this->filterOrderHelperFactory
->create(self::class) ->create(self::class)
->addSearchBox() ->addSearchBox()
->addCheckbox('status', $statuses, $statuses, $statusTrans) ->addCheckbox('status', $statuses, $statuses, $statusTrans)
->addCheckbox('states', $states, ['new', 'in_progress']) ->addCheckbox('states', $states, $checked)
->build(); ;
if ($includeFilterByUser) {
$filterBuilder
->addUserPicker('userPicker', 'Filter by user', ['multiple' => true, 'required' => false]);
}
return $filterBuilder->build();
} }
/** /**

View File

@ -51,7 +51,8 @@ final class SingleTaskAclAwareRepository implements SingleTaskAclAwareRepository
public function buildBaseQuery( public function buildBaseQuery(
?string $pattern = null, ?string $pattern = null,
?array $flags = [] ?array $flags = [],
?array $users = []
): QueryBuilder { ): QueryBuilder {
$qb = $this->em->createQueryBuilder(); $qb = $this->em->createQueryBuilder();
$qb $qb
@ -62,6 +63,24 @@ final class SingleTaskAclAwareRepository implements SingleTaskAclAwareRepository
->setParameter('pattern', '%' . $pattern . '%'); ->setParameter('pattern', '%' . $pattern . '%');
} }
if (count($users) > 0) {
$orXUser = $qb->expr()->orX();
foreach ($users as $key => $user) {
$orXUser->add(
$qb->expr()->eq('t.assignee', ':user_' . $key)
);
$qb->setParameter('user_' . $key, $user);
}
if ($orXUser->count() > 0) {
$qb->andWhere($orXUser);
}
return $qb;
}
if (count($flags) > 0) { if (count($flags) > 0) {
$orXDate = $qb->expr()->orX(); $orXDate = $qb->expr()->orX();
$orXState = $qb->expr()->orX(); $orXState = $qb->expr()->orX();
@ -183,9 +202,10 @@ final class SingleTaskAclAwareRepository implements SingleTaskAclAwareRepository
public function countByAllViewable( public function countByAllViewable(
?string $pattern = null, ?string $pattern = null,
?array $flags = [] ?array $flags = [],
?array $users = []
): int { ): int {
$qb = $this->buildBaseQuery($pattern, $flags); $qb = $this->buildBaseQuery($pattern, $flags, $users);
return $this return $this
->addACLGlobal($qb) ->addACLGlobal($qb)
@ -231,11 +251,12 @@ final class SingleTaskAclAwareRepository implements SingleTaskAclAwareRepository
public function findByAllViewable( public function findByAllViewable(
?string $pattern = null, ?string $pattern = null,
?array $flags = [], ?array $flags = [],
?array $users = [],
?int $start = 0, ?int $start = 0,
?int $limit = 50, ?int $limit = 50,
?array $orderBy = [] ?array $orderBy = []
): array { ): array {
$qb = $this->buildBaseQuery($pattern, $flags); $qb = $this->buildBaseQuery($pattern, $flags, $users);
$qb = $this->addACLGlobal($qb); $qb = $this->addACLGlobal($qb);
return $this->getResult($qb, $start, $limit, $orderBy); return $this->getResult($qb, $start, $limit, $orderBy);

View File

@ -18,7 +18,8 @@ interface SingleTaskAclAwareRepositoryInterface
{ {
public function countByAllViewable( public function countByAllViewable(
?string $pattern = null, ?string $pattern = null,
?array $flags = [] ?array $flags = [],
?array $users = []
): int; ): int;
public function countByCourse( public function countByCourse(
@ -38,6 +39,7 @@ interface SingleTaskAclAwareRepositoryInterface
public function findByAllViewable( public function findByAllViewable(
?string $pattern = null, ?string $pattern = null,
?array $flags = [], ?array $flags = [],
?array $users = [],
?int $start = 0, ?int $start = 0,
?int $limit = 50, ?int $limit = 50,
?array $orderBy = [] ?array $orderBy = []

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TaskBundle\Repository;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
class SingleTaskStateRepository
{
private const FIND_ALL_STATES = <<<'SQL'
SELECT DISTINCT jsonb_array_elements_text(current_states) FROM chill_task.single_task
SQL;
public function __construct(
private Connection $connection
) {
}
/**
* Return a list of all states associated to at least one single task in the database
*
* @return list<string>
* @throws Exception
*/
public function findAllExistingStates(): array
{
$states = [];
foreach ($this->connection->fetchAllNumeric(self::FIND_ALL_STATES) as $row) {
if ('' !== $row[0] && null !== $row[0]) {
$states[] = $row[0];
}
}
return $states;
}
}

View File

@ -27,8 +27,10 @@
{% block css %} {% block css %}
{{ parent() }} {{ parent() }}
{{ encore_entry_link_tags('page_task_list') }} {{ encore_entry_link_tags('page_task_list') }}
{{ encore_entry_link_tags('mod_pickentity_type') }}
{% endblock %} {% endblock %}
{% block js %} {% block js %}
{{ parent() }} {{ parent() }}
{{ encore_entry_script_tags('page_task_list') }} {{ encore_entry_script_tags('page_task_list') }}
{{ encore_entry_script_tags('mod_pickentity_type') }}
{% endblock %} {% endblock %}

View File

@ -1,4 +1,8 @@
services: services:
_defaults:
autowire: true
autoconfigure: true
chill_task.single_task_repository: chill_task.single_task_repository:
class: Chill\TaskBundle\Repository\SingleTaskRepository class: Chill\TaskBundle\Repository\SingleTaskRepository
factory: ['@doctrine.orm.entity_manager', getRepository] factory: ['@doctrine.orm.entity_manager', getRepository]
@ -10,8 +14,8 @@ services:
- "@chill.main.security.authorization.helper" - "@chill.main.security.authorization.helper"
Chill\TaskBundle\Repository\SingleTaskRepository: '@chill_task.single_task_repository' Chill\TaskBundle\Repository\SingleTaskRepository: '@chill_task.single_task_repository'
Chill\TaskBundle\Repository\SingleTaskAclAwareRepository: Chill\TaskBundle\Repository\SingleTaskAclAwareRepository: ~
autowire: true
autoconfigure: true
Chill\TaskBundle\Repository\SingleTaskAclAwareRepositoryInterface: '@Chill\TaskBundle\Repository\SingleTaskAclAwareRepository' Chill\TaskBundle\Repository\SingleTaskAclAwareRepositoryInterface: '@Chill\TaskBundle\Repository\SingleTaskAclAwareRepository'
Chill\TaskBundle\Repository\SingleTaskStateRepository: ~

View File

@ -65,6 +65,7 @@ Not assigned: Aucun utilisateur assigné
For person: Pour For person: Pour
By: Par By: Par
Any tasks: Aucune tâche Any tasks: Aucune tâche
Filter by user: Filtrer par utilisateur(s)
# transitions - default task definition # transitions - default task definition
"new": "nouvelle" "new": "nouvelle"