From 09c57b9980baea0404c44a5735856cfa212044f1 Mon Sep 17 00:00:00 2001 From: jekuaitk Date: Thu, 1 Oct 2026 08:31:44 +0200 Subject: [PATCH] 8385: Update department filter to allow multiple --- CHANGELOG.md | 3 ++ assets/app.js | 42 ++++++++++++++++++-- assets/controllers/live_search_controller.js | 13 +++++- assets/styles/app.css | 19 +++++++++ src/Form/ProjectFilterType.php | 22 +++++++++- src/Model/ProjectFilter.php | 16 +++++++- src/Repository/ProjectRepository.php | 13 ++++-- tests/Controller/ProjectControllerTest.php | 32 +++++++++++++-- tests/Repository/ProjectRepositoryTest.php | 34 +++++++++++++++- tests/Unit/Model/ProjectFilterTest.php | 6 ++- 10 files changed, 182 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d397b7c..825790f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-44](https://github.com/itk-dev/itk-projects/pull/44) + Let the project list be filtered by several departments at once, picked with + the same searchable multiselect as the project form. * [PR-33](https://github.com/itk-dev/itk-projects/pull/33) Let a project be anchored in several departments, picked with a searchable multiselect. diff --git a/assets/app.js b/assets/app.js index 4b9e80e..1888f5c 100644 --- a/assets/app.js +++ b/assets/app.js @@ -180,10 +180,11 @@ function initPartnerSelect() { ); } -// Departments are a fixed, admin-managed pool, so the project form's -// — the +// project form's anchoring field and the project list's department filter — +// becomes a searchable chip multiselect without `create`. Tom Select reads the +// options and the selection from the select itself and fires input/change on +// it, which is what autosave, the progress bar and the live filter listen for. function initDepartmentSelect() { document.querySelectorAll("[data-department-select]").forEach((select) => { if (select.dataset.bound) { @@ -194,11 +195,43 @@ function initDepartmentSelect() { new TomSelect(select, { plugins: ["remove_button"], hideSelected: true, + // Tom Select keeps a multiselect's placeholder ("All", "Choose …") + // visible next to the chips; drop it once something is picked. + hidePlaceholder: true, maxOptions: null, + render: { + // The name gets its own element so a long one can be cut with + // an ellipsis (see .item__label) while the × stays visible. + item: (data, escape) => { + const name = escape(data.text); + return `
${name}
`; + }, + }, }); }); } +// Turbo restores a visited page from its cached snapshot without running any +// init again, so a Tom Select left in the snapshot would come back as dead +// markup. Put the plain . + if (select.tomselect) { + select.tomselect.clear(true); + } else if (select.multiple) { + for (const option of select.options) { + option.selected = false; + } + } else { + select.selectedIndex = 0; + } } window.clearTimeout(this.timer); this.formTarget.requestSubmit(); diff --git a/assets/styles/app.css b/assets/styles/app.css index 7103242..304b2b4 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -947,6 +947,14 @@ a.recent-item:hover .recent-item__cta { line-height: 1.55; } +/* The department filter is a chip multiselect among plain selects. Its caret + input normally claims 8rem, which in a filter cell means a line of its own: + let it squeeze in after the chips so the control stays one row high — the + same height as its neighbours — until the chips truly need a second. */ +.filters .ts-wrapper .ts-control > input { + min-width: 2rem; +} + .form { display: flex; flex-direction: column; @@ -2093,6 +2101,8 @@ textarea { .ts-wrapper.multi .ts-control .item { display: inline-flex; align-items: center; + max-width: 100%; + white-space: nowrap; padding: 2px var(--itk-space-2); font-size: var(--itk-text-sm); font-weight: 400; @@ -2106,6 +2116,15 @@ textarea { text-shadow: none; } +/* A name too long for the control ("Sociale Forhold og Beskæftigelse") is cut + with an ellipsis — the chip's title carries the full name — so the chip never + outgrows its line and the remove button stays reachable. */ +.ts-wrapper.multi .ts-control .item .item__label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + .ts-wrapper.multi .ts-control .item.active { color: var(--itk-ink); background: var(--itk-slate-200); diff --git a/src/Form/ProjectFilterType.php b/src/Form/ProjectFilterType.php index 1b7be1a..0fd07eb 100644 --- a/src/Form/ProjectFilterType.php +++ b/src/Form/ProjectFilterType.php @@ -11,10 +11,12 @@ use App\Model\ProjectFilter; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Event\PreSubmitEvent; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\SearchType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvents; use Symfony\Component\OptionsResolver\OptionsResolver; /** @@ -57,8 +59,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'project.organizational_anchoring', 'class' => Department::class, 'choice_label' => 'name', + 'multiple' => true, 'required' => false, - 'placeholder' => 'filter.all', + // Same searchable chip multiselect as the project form (Tom Select, + // see app.js); a multiple select has no placeholder option, so + // "All" goes on the input instead. + 'attr' => ['data-department-select' => true, 'placeholder' => 'filter.all'], ]) ->add('endorsement', ChoiceType::class, [ 'label' => 'project.endorsement', @@ -67,6 +73,20 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'choices' => ['filter.yes' => true, 'filter.no' => false], 'choice_value' => $boolChoiceValue, ]); + + // Links saved while the department filter was a single select carry one + // id (?organizationalAnchoring=…) rather than a list; a multiple choice + // field rejects a scalar, so wrap it before the field's own listener runs. + $builder->get('organizationalAnchoring')->addEventListener( + FormEvents::PRE_SUBMIT, + static function (PreSubmitEvent $event): void { + $data = $event->getData(); + if (\is_string($data)) { + $event->setData('' === $data ? [] : [$data]); + } + }, + 10, + ); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Model/ProjectFilter.php b/src/Model/ProjectFilter.php index 875a593..ddecd80 100644 --- a/src/Model/ProjectFilter.php +++ b/src/Model/ProjectFilter.php @@ -8,6 +8,8 @@ use App\Entity\Department; use App\Enum\ProjectType; use App\Enum\Status; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; /** * Bound to the project list filter form (GET) and consumed by @@ -23,11 +25,23 @@ class ProjectFilter public ?ProjectType $projectType = null; - public ?Department $organizationalAnchoring = null; + /** + * Several departments may be chosen; a project matches when it is anchored + * in any of them. A Collection rather than an array because a multiple + * EntityType writes an ArrayCollection back into the model. + * + * @var Collection + */ + public Collection $organizationalAnchoring; public ?bool $endorsement = null; public string $sort = 'createdAt'; public string $direction = 'DESC'; + + public function __construct() + { + $this->organizationalAnchoring = new ArrayCollection(); + } } diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 1181091..fbee942 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -96,9 +96,16 @@ public function search(ProjectFilter $filter): QueryBuilder $qb->andWhere('i.projectType = :projectType')->setParameter('projectType', $filter->projectType->value); } - if (null !== $filter->organizationalAnchoring) { - $qb->andWhere(':anchoring MEMBER OF i.organizationalAnchoring') - ->setParameter('anchoring', $filter->organizationalAnchoring->getId(), 'ulid'); + if (!$filter->organizationalAnchoring->isEmpty()) { + // Any of the chosen departments matches. One MEMBER OF per department, + // each bound with the ulid type, because the ORM cannot expand an IN () + // list of binary ULIDs from a single typed parameter. + $ors = []; + foreach ($filter->organizationalAnchoring->getValues() as $i => $department) { + $ors[] = sprintf(':anchoring%d MEMBER OF i.organizationalAnchoring', $i); + $qb->setParameter('anchoring'.$i, $department->getId(), 'ulid'); + } + $qb->andWhere('('.implode(' OR ', $ors).')'); } if (null !== $filter->endorsement) { diff --git a/tests/Controller/ProjectControllerTest.php b/tests/Controller/ProjectControllerTest.php index 1a2556e..81d57bc 100644 --- a/tests/Controller/ProjectControllerTest.php +++ b/tests/Controller/ProjectControllerTest.php @@ -194,6 +194,9 @@ public function testDepartmentsAreSavedShownFilteredAndExported(): void $em->persist($second); $em->flush(); $unanchored = $this->createProject('Unanchored project '.uniqid()); + $onlySecond = (new Project())->setTitle('Second-only project '.uniqid())->addOrganizationalAnchoring($second); + $em->persist($onlySecond); + $em->flush(); $title = 'Anchored project '.uniqid(); $crawler = $this->client->request('GET', '/projects/new'); @@ -219,20 +222,41 @@ public function testDepartmentsAreSavedShownFilteredAndExported(): void self::assertStringContainsString((string) $first->getName(), $details); self::assertStringContainsString((string) $second->getName(), $details); - // … the department filter narrows the list to projects anchored there … - $crawler = $this->client->request('GET', '/projects?organizationalAnchoring='.$first->getId()); + // … the department filter is a multiselect that narrows the list to + // projects anchored in the chosen department … + $crawler = $this->client->request('GET', '/projects?organizationalAnchoring[]='.$first->getId()); + self::assertCount(1, $crawler->filter('#project-filters select[name="organizationalAnchoring[]"][multiple][data-department-select]')); $results = $crawler->filter('#project-results')->text(); self::assertStringContainsString($title, $results); self::assertStringContainsString($first->getName().', '.$second->getName(), $results); + self::assertStringNotContainsString((string) $onlySecond->getTitle(), $results); + self::assertStringNotContainsString((string) $unanchored->getTitle(), $results); + + // … or in any of several departments, which the form shows as selected … + $crawler = $this->client->request('GET', sprintf('/projects?organizationalAnchoring[]=%s&organizationalAnchoring[]=%s', $first->getId(), $second->getId())); + self::assertCount(2, $crawler->filter('#project-filters select[name="organizationalAnchoring[]"] option[selected]')); + $results = $crawler->filter('#project-results')->text(); + self::assertStringContainsString($title, $results); + self::assertStringContainsString((string) $onlySecond->getTitle(), $results); + self::assertStringNotContainsString((string) $unanchored->getTitle(), $results); + + // … a link saved while the filter was a single select still narrows … + $results = $this->client->request('GET', '/projects?organizationalAnchoring='.$second->getId())->filter('#project-results')->text(); + self::assertStringContainsString((string) $onlySecond->getTitle(), $results); self::assertStringNotContainsString((string) $unanchored->getTitle(), $results); + $results = $this->client->request('GET', '/projects?organizationalAnchoring=')->filter('#project-results')->text(); + self::assertStringContainsString((string) $unanchored->getTitle(), $results); - // … and the export lists every department of the project. - $this->client->request('GET', '/projects/export?organizationalAnchoring='.$first->getId()); + // … and the export applies the same filter and lists every department + // of the project. + $this->client->request('GET', '/projects/export?organizationalAnchoring[]='.$first->getId()); $csv = (string) $this->client->getInternalResponse()->getContent(); self::assertStringContainsString($first->getName().', '.$second->getName(), $csv); + self::assertStringNotContainsString((string) $onlySecond->getTitle(), $csv); $this->removeProject($id); $this->removeProject((string) $unanchored->getId()); + $this->removeProject((string) $onlySecond->getId()); $em = $this->entityManager(); foreach ([$first->getId(), $second->getId()] as $departmentId) { $department = $this->departments()->find($departmentId); diff --git a/tests/Repository/ProjectRepositoryTest.php b/tests/Repository/ProjectRepositoryTest.php index a5f24f0..85bfab5 100644 --- a/tests/Repository/ProjectRepositoryTest.php +++ b/tests/Repository/ProjectRepositoryTest.php @@ -40,7 +40,7 @@ public function testSearchAppliesEveryFilterBranch(): void $filter->status = Status::Granted; $filter->area = $areas->findAllOrdered()[0]; $filter->projectType = ProjectType::Project; - $filter->organizationalAnchoring = $departments->findAllOrdered()[0]; + $filter->organizationalAnchoring->add($departments->findAllOrdered()[0]); $filter->endorsement = true; $filter->sort = 'title'; $filter->direction = 'ASC'; @@ -66,7 +66,7 @@ public function testSearchByDepartmentOrAreaMatchesOnlyProjectsWithThem(): void $em->flush(); $byDepartment = new ProjectFilter(); - $byDepartment->organizationalAnchoring = $department; + $byDepartment->organizationalAnchoring->add($department); $byArea = new ProjectFilter(); $byArea->area = $area; @@ -83,6 +83,36 @@ public function testSearchByDepartmentOrAreaMatchesOnlyProjectsWithThem(): void $em->flush(); } + public function testSearchBySeveralDepartmentsMatchesProjectsAnchoredInAnyOfThem(): void + { + $em = static::getContainer()->get(EntityManagerInterface::class); + \assert($em instanceof EntityManagerInterface); + + $first = (new Department())->setName('Filter dept A '.uniqid()); + $second = (new Department())->setName('Filter dept B '.uniqid()); + $inFirst = (new Project())->setTitle('A anchored '.uniqid())->addOrganizationalAnchoring($first); + $inSecond = (new Project())->setTitle('B anchored '.uniqid())->addOrganizationalAnchoring($second); + $loose = (new Project())->setTitle('Loose '.uniqid()); + foreach ([$first, $second, $inFirst, $inSecond, $loose] as $entity) { + $em->persist($entity); + } + $em->flush(); + + $filter = new ProjectFilter(); + $filter->organizationalAnchoring->add($first); + $filter->organizationalAnchoring->add($second); + $filter->sort = 'title'; + $filter->direction = 'ASC'; + + // The chosen departments combine with OR: anchored in either is enough. + self::assertSame([$inFirst->getTitle(), $inSecond->getTitle()], $this->titles($filter)); + + foreach ([$inFirst, $inSecond, $loose, $first, $second] as $entity) { + $em->remove($entity); + } + $em->flush(); + } + /** @return list */ private function titles(ProjectFilter $filter): array { diff --git a/tests/Unit/Model/ProjectFilterTest.php b/tests/Unit/Model/ProjectFilterTest.php index ad76ac1..988400a 100644 --- a/tests/Unit/Model/ProjectFilterTest.php +++ b/tests/Unit/Model/ProjectFilterTest.php @@ -9,6 +9,7 @@ use App\Enum\ProjectType; use App\Enum\Status; use App\Model\ProjectFilter; +use Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\TestCase; final class ProjectFilterTest extends TestCase @@ -21,7 +22,7 @@ public function testDefaults(): void self::assertNull($filter->status); self::assertNull($filter->area); self::assertNull($filter->projectType); - self::assertNull($filter->organizationalAnchoring); + self::assertTrue($filter->organizationalAnchoring->isEmpty()); self::assertNull($filter->endorsement); self::assertSame('createdAt', $filter->sort); self::assertSame('DESC', $filter->direction); @@ -34,13 +35,14 @@ public function testIsMutable(): void $filter->status = Status::Granted; $filter->area = (new Area())->setName('Klima og miljø'); $filter->projectType = ProjectType::Project; - $filter->organizationalAnchoring = (new Department())->setName('Sundhed og Omsorg'); + $filter->organizationalAnchoring = new ArrayCollection([(new Department())->setName('Sundhed og Omsorg')]); $filter->endorsement = true; $filter->sort = 'title'; $filter->direction = 'ASC'; self::assertSame('klima', $filter->q); self::assertSame(Status::Granted, $filter->status); + self::assertCount(1, $filter->organizationalAnchoring); self::assertTrue($filter->endorsement); } }