Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-45](https://github.com/itk-dev/itk-projects/pull/45)
Remove the "Strategies and plans" field. Update Summary help text to now ask
for strategy and plans.
Expand Down
46 changes: 41 additions & 5 deletions assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,12 @@ function initPartnerSelect() {
);
}

// Departments and project types are fixed, admin-managed pools, so the
// project form's <select multiple> 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 and the progress bar listen for.
// Departments and project types are fixed, admin-managed pools, so a
// <select multiple> — the project form's anchoring and type fields 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 initPoolSelect() {
document
.querySelectorAll("[data-department-select], [data-type-select]")
Expand All @@ -197,11 +198,45 @@ function initPoolSelect() {
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 `<div title="${name}"><span class="item__label">${name}</span></div>`;
},
},
});
});
}

// 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 <select> back — destroy() resets it to the selection it
// was built with, so the current one is reapplied — and drop the bound marker
// so turbo:load rebuilds the widget on restore.
function teardownPoolSelect() {
document
.querySelectorAll("[data-department-select], [data-type-select]")
.forEach((select) => {
const tomSelect = select.tomselect;
if (!tomSelect) {
return;
}
const selected = tomSelect.items.slice();
tomSelect.destroy();
for (const option of select.options) {
option.selected = selected.includes(option.value);
option.toggleAttribute("selected", option.selected);
}
delete select.dataset.bound;
});
}

// One delegated handler on the document (which survives Turbo navigations and
// cache restores) both opens the menu — when the click lands on the toggle —
// and closes it on any outside click. Delegation avoids per-page binding, which
Expand Down Expand Up @@ -238,4 +273,5 @@ document.addEventListener("turbo:before-cache", () => {
document.querySelectorAll("dialog[open]").forEach((dialog) => {
dialog.close();
});
teardownPoolSelect();
});
13 changes: 12 additions & 1 deletion assets/controllers/live_search_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,18 @@ export default class extends Controller {
}
}
for (const select of this.formTarget.querySelectorAll("select")) {
select.selectedIndex = 0;
// A Tom Select multiselect owns its chips: clear it silently so the
// single requestSubmit() below is the only request. selectedIndex = 0
// would pick the first option of a plain <select multiple>.
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();
Expand Down
19 changes: 19 additions & 0 deletions assets/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2099,6 +2107,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;
Expand All @@ -2112,6 +2122,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);
Expand Down
22 changes: 21 additions & 1 deletion src/Form/ProjectFilterType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand Down
16 changes: 15 additions & 1 deletion src/Model/ProjectFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use App\Entity\Department;
use App\Entity\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
Expand All @@ -23,11 +25,23 @@ class ProjectFilter

public ?ProjectType $type = 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<int, Department>
*/
public Collection $organizationalAnchoring;

public ?bool $endorsement = null;

public string $sort = 'createdAt';

public string $direction = 'DESC';

public function __construct()
{
$this->organizationalAnchoring = new ArrayCollection();
}
}
13 changes: 10 additions & 3 deletions src/Repository/ProjectRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,16 @@ public function search(ProjectFilter $filter): QueryBuilder
->setParameter('type', $filter->type->getId(), 'ulid');
}

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) {
Expand Down
32 changes: 28 additions & 4 deletions tests/Controller/ProjectControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,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');
Expand All @@ -220,20 +223,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);
Expand Down
34 changes: 32 additions & 2 deletions tests/Repository/ProjectRepositoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public function testSearchAppliesEveryFilterBranch(): void
$filter->status = Status::Granted;
$filter->area = $areas->findAllOrdered()[0];
$filter->type = $types->findAllOrdered()[0];
$filter->organizationalAnchoring = $departments->findAllOrdered()[0];
$filter->organizationalAnchoring->add($departments->findAllOrdered()[0]);
$filter->endorsement = true;
$filter->sort = 'title';
$filter->direction = 'ASC';
Expand Down Expand Up @@ -71,7 +71,7 @@ public function testSearchByDepartmentAreaOrTypeMatchesOnlyProjectsWithThem(): v
$em->flush();

$byDepartment = new ProjectFilter();
$byDepartment->organizationalAnchoring = $department;
$byDepartment->organizationalAnchoring->add($department);
$byArea = new ProjectFilter();
$byArea->area = $area;
$byType = new ProjectFilter();
Expand All @@ -93,6 +93,36 @@ public function testSearchByDepartmentAreaOrTypeMatchesOnlyProjectsWithThem(): v
$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<string> */
private function titles(ProjectFilter $filter): array
{
Expand Down
6 changes: 4 additions & 2 deletions tests/Unit/Model/ProjectFilterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use App\Entity\ProjectType;
use App\Enum\Status;
use App\Model\ProjectFilter;
use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\TestCase;

final class ProjectFilterTest extends TestCase
Expand All @@ -21,7 +22,7 @@ public function testDefaults(): void
self::assertNull($filter->status);
self::assertNull($filter->area);
self::assertNull($filter->type);
self::assertNull($filter->organizationalAnchoring);
self::assertTrue($filter->organizationalAnchoring->isEmpty());
self::assertNull($filter->endorsement);
self::assertSame('createdAt', $filter->sort);
self::assertSame('DESC', $filter->direction);
Expand All @@ -34,13 +35,14 @@ public function testIsMutable(): void
$filter->status = Status::Granted;
$filter->area = (new Area())->setName('Klima og miljø');
$filter->type = (new ProjectType())->setName('Drift');
$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);
}
}
Loading