From f614798ff2a99c0232ceb936f21d85b5a1477cfc Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 22 Jun 2026 14:41:39 +0200 Subject: [PATCH 1/2] Improvements to initiative list, search and filters --- assets/controllers/live_search_controller.js | 40 +++++++ src/Form/InitiativeFilterType.php | 18 ---- src/Model/InitiativeFilter.php | 6 -- src/Repository/InitiativeRepository.php | 107 +++++++++++++++--- templates/initiative/_results.html.twig | 95 ++++++++++++++++ templates/initiative/index.html.twig | 108 +++---------------- tests/Controller/SmokeTest.php | 1 + 7 files changed, 240 insertions(+), 135 deletions(-) create mode 100644 assets/controllers/live_search_controller.js create mode 100644 templates/initiative/_results.html.twig diff --git a/assets/controllers/live_search_controller.js b/assets/controllers/live_search_controller.js new file mode 100644 index 0000000..e764124 --- /dev/null +++ b/assets/controllers/live_search_controller.js @@ -0,0 +1,40 @@ +import { Controller } from "@hotwired/stimulus"; + +/* + * Live filtering for the initiative list. The form targets a Turbo Frame + * (data-turbo-frame), so submitting it swaps only the results — no full page + * load. Typing is debounced; selects submit on change. The submit button is + * gone: this controller drives the submit, and clear() resets the fields. + */ +export default class extends Controller { + static values = { debounce: { type: Number, default: 300 } }; + + connect() { + this.timer = null; + } + + disconnect() { + window.clearTimeout(this.timer); + } + + submit() { + window.clearTimeout(this.timer); + this.timer = window.setTimeout( + () => this.element.requestSubmit(), + this.debounceValue, + ); + } + + clear() { + for (const input of this.element.querySelectorAll("input")) { + if (!["submit", "button", "reset"].includes(input.type)) { + input.value = ""; + } + } + for (const select of this.element.querySelectorAll("select")) { + select.selectedIndex = 0; + } + window.clearTimeout(this.timer); + this.element.requestSubmit(); + } +} diff --git a/src/Form/InitiativeFilterType.php b/src/Form/InitiativeFilterType.php index 7f0f974..cbdbaa2 100644 --- a/src/Form/InitiativeFilterType.php +++ b/src/Form/InitiativeFilterType.php @@ -12,7 +12,6 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\EnumType; -use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\Extension\Core\Type\SearchType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -66,23 +65,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'placeholder' => 'filter.all', 'choices' => ['filter.yes' => true, 'filter.no' => false], 'choice_value' => $boolChoiceValue, - ]) - ->add('published', ChoiceType::class, [ - 'label' => 'initiative.published', - 'required' => false, - 'placeholder' => 'filter.all', - 'choices' => ['filter.published' => true, 'filter.draft' => false], - 'choice_value' => $boolChoiceValue, - ]) - ->add('budgetMin', IntegerType::class, [ - 'label' => 'filter.budget_min', - 'required' => false, - 'attr' => ['min' => 0], - ]) - ->add('budgetMax', IntegerType::class, [ - 'label' => 'filter.budget_max', - 'required' => false, - 'attr' => ['min' => 0], ]); } diff --git a/src/Model/InitiativeFilter.php b/src/Model/InitiativeFilter.php index b46e91c..c933a78 100644 --- a/src/Model/InitiativeFilter.php +++ b/src/Model/InitiativeFilter.php @@ -27,12 +27,6 @@ class InitiativeFilter public ?bool $endorsement = null; - public ?int $budgetMin = null; - - public ?int $budgetMax = null; - - public ?bool $published = null; - public string $sort = 'createdAt'; public string $direction = 'DESC'; diff --git a/src/Repository/InitiativeRepository.php b/src/Repository/InitiativeRepository.php index 80563d1..9fe8745 100644 --- a/src/Repository/InitiativeRepository.php +++ b/src/Repository/InitiativeRepository.php @@ -5,11 +5,18 @@ namespace App\Repository; use App\Entity\Initiative; +use App\Enum\Category; +use App\Enum\EndorsementAuthor; +use App\Enum\Funding; +use App\Enum\InitiativeType; +use App\Enum\OrganizationalAnchoring; use App\Enum\Status; +use App\Enum\TranslatableEnum; use App\Model\InitiativeFilter; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; +use Symfony\Contracts\Translation\TranslatorInterface; /** * @extends ServiceEntityRepository @@ -18,7 +25,7 @@ class InitiativeRepository extends ServiceEntityRepository { public const SORTABLE = ['title', 'budget', 'createdAt', 'timePeriodStart']; - public function __construct(ManagerRegistry $registry) + public function __construct(ManagerRegistry $registry, private readonly TranslatorInterface $translator) { parent::__construct($registry, Initiative::class); } @@ -28,8 +35,47 @@ public function search(InitiativeFilter $filter): QueryBuilder $qb = $this->createQueryBuilder('i'); if (null !== $filter->q && '' !== trim($filter->q)) { - $qb->andWhere('LOWER(i.title) LIKE :q OR LOWER(i.description) LIKE :q OR LOWER(i.author) LIKE :q OR LOWER(i.statusAdditional) LIKE :q') - ->setParameter('q', '%'.mb_strtolower(trim($filter->q)).'%'); + $term = mb_strtolower(trim($filter->q)); + $qb->setParameter('q', '%'.$term.'%'); + + $ors = [ + 'LOWER(i.title) LIKE :q', + 'LOWER(i.description) LIKE :q', + 'LOWER(i.author) LIKE :q', + 'LOWER(i.statusAdditional) LIKE :q', + // Related names, matched without joining the root query so the + // paginator's count stays correct. + sprintf('i.id IN (SELECT itag.id FROM %s itag JOIN itag.tags tg WHERE LOWER(tg.name) LIKE :q)', Initiative::class), + sprintf('i.id IN (SELECT istr.id FROM %s istr JOIN istr.strategies st WHERE LOWER(st.name) LIKE :q)', Initiative::class), + sprintf('i.id IN (SELECT isth.id FROM %s isth JOIN isth.stakeholders sh WHERE LOWER(sh.name) LIKE :q)', Initiative::class), + sprintf('i.id IN (SELECT icon.id FROM %s icon JOIN icon.contacts co WHERE LOWER(co.name) LIKE :q)', Initiative::class), + ]; + + // Enum columns store slugs, but the user searches their translated + // labels ("nik" should find "Teknik og Miljø"); map labels to values. + $enumFields = [ + 'status' => Status::cases(), + 'category' => Category::cases(), + 'initiativeType' => InitiativeType::cases(), + 'organizationalAnchoring' => OrganizationalAnchoring::cases(), + 'endorsementAuthor' => EndorsementAuthor::cases(), + ]; + foreach ($enumFields as $field => $cases) { + $values = $this->matchEnumLabels($cases, $term); + if ([] !== $values) { + $ors[] = sprintf('i.%s IN (:q_%s)', $field, $field); + $qb->setParameter('q_'.$field, $values); + } + } + + // Funding is a JSON list of slugs; match the label, then look for the + // slug inside the stored JSON array. + foreach ($this->matchEnumLabels(Funding::cases(), $term) as $i => $value) { + $ors[] = sprintf('i.funding LIKE :q_funding%d', $i); + $qb->setParameter('q_funding'.$i, '%"'.$value.'"%'); + } + + $qb->andWhere('('.implode(' OR ', $ors).')'); } if (null !== $filter->status) { @@ -52,24 +98,31 @@ public function search(InitiativeFilter $filter): QueryBuilder $qb->andWhere('i.endorsement = :endorsement')->setParameter('endorsement', $filter->endorsement); } - if (null !== $filter->published) { - $qb->andWhere('i.published = :published')->setParameter('published', $filter->published); - } - - if (null !== $filter->budgetMin) { - $qb->andWhere('i.budget >= :budgetMin')->setParameter('budgetMin', $filter->budgetMin); - } - - if (null !== $filter->budgetMax) { - $qb->andWhere('i.budget <= :budgetMax')->setParameter('budgetMax', $filter->budgetMax); - } - $sort = \in_array($filter->sort, self::SORTABLE, true) ? $filter->sort : 'createdAt'; $direction = 'ASC' === strtoupper($filter->direction) ? 'ASC' : 'DESC'; return $qb->orderBy('i.'.$sort, $direction); } + /** + * Backing values of the enum cases whose translated label contains the term. + * + * @param array<\BackedEnum&TranslatableEnum> $cases + * + * @return list + */ + private function matchEnumLabels(array $cases, string $lowerTerm): array + { + $values = []; + foreach ($cases as $case) { + if (str_contains(mb_strtolower($this->translator->trans($case->labelKey())), $lowerTerm)) { + $values[] = (string) $case->value; + } + } + + return $values; + } + public function countAll(): int { return (int) $this->createQueryBuilder('i') @@ -123,4 +176,28 @@ public function findRecent(int $limit = 5): array ->getQuery() ->getResult(); } + + /** + * Lightweight per-initiative rows for the dashboard visualisations: just the + * columns the aggregates need, no relations, so it stays cheap to recompute + * on every live broadcast. + * + * @return list> + */ + public function dashboardRows(): array + { + return $this->createQueryBuilder('i') + ->select( + 'i.title', + 'i.category', + 'i.status', + 'i.organizationalAnchoring', + 'i.budget', + 'i.funding', + 'i.timePeriodStart', + 'i.timePeriodEnd', + ) + ->getQuery() + ->getArrayResult(); + } } diff --git a/templates/initiative/_results.html.twig b/templates/initiative/_results.html.twig new file mode 100644 index 0000000..d7714f7 --- /dev/null +++ b/templates/initiative/_results.html.twig @@ -0,0 +1,95 @@ +{% import _self as h %} + +{% macro sortlink(field, label, sort, direction) %} + {% set isActive = sort == field %} + {% set newDir = (isActive and direction == 'ASC') ? 'DESC' : 'ASC' %} + + {{ label|trans }}{% if isActive %} {{ direction == 'ASC' ? '▲' : '▼' }}{% endif %} + +{% endmacro %} + +{% macro icon(name) %} + {% if name == 'eye' %} + + {% elseif name == 'edit' %} + + {% elseif name == 'trash' %} + + {% endif %} +{% endmacro %} + +{% if pagination.items is empty %} +
+
{{ 'initiative.empty.title'|trans }}
+

{{ 'initiative.empty.hint'|trans }}

+
+{% else %} +
+ + + + + + + + + + + + + + {% for initiative in pagination.items %} + + + + + + + + + + {% endfor %} + +
{{ h.sortlink('title', 'initiative.title', sort, direction) }}{{ 'initiative.status'|trans }}{{ 'initiative.initiative_type'|trans }}{{ 'initiative.organizational_anchoring'|trans }}{{ h.sortlink('timePeriodStart', 'initiative.time_period', sort, direction) }}{{ 'initiative.author'|trans }}
+ {{ initiative.title }} + + {% if initiative.status %} + {{ initiative.status.labelKey|trans }} + {% else %} + + {% endif %} + {{ initiative.initiativeType ? initiative.initiativeType.labelKey|trans : '—' }}{{ initiative.organizationalAnchoring ? initiative.organizationalAnchoring.labelKey|trans : '—' }} + {% if initiative.timePeriodStart %} + {{ initiative.timePeriodStart|date('d.m.Y') }}{% if initiative.timePeriodEnd %} – {{ initiative.timePeriodEnd|date('d.m.Y') }}{% endif %} + {% else %}—{% endif %} + {{ initiative.author ?: '—' }} +
+ {{ h.icon('eye') }} + {{ h.icon('edit') }} +
+ + +
+
+
+
+ + +{% endif %} diff --git a/templates/initiative/index.html.twig b/templates/initiative/index.html.twig index 3314c02..599a002 100644 --- a/templates/initiative/index.html.twig +++ b/templates/initiative/index.html.twig @@ -2,16 +2,7 @@ {% block title %}{{ 'initiative.index.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} -{% macro sortlink(field, label, sort, direction) %} - {% set isActive = sort == field %} - {% set newDir = (isActive and direction == 'ASC') ? 'DESC' : 'ASC' %} - - {{ label|trans }}{% if isActive %} {{ direction == 'ASC' ? '▲' : '▼' }}{% endif %} - -{% endmacro %} - {% block body %} - {% import _self as h %}
- {{ form_start(form, {attr: {class: 'filters'}}) }} + {{ form_start(form, {attr: { + class: 'filters', + id: 'initiative-filters', + 'data-controller': 'live-search', + 'data-action': 'input->live-search#submit change->live-search#submit', + 'data-turbo-frame': 'initiative-results', + }}) }}
{{ form_row(form.status) }} @@ -34,95 +32,13 @@ {{ form_row(form.initiativeType) }} {{ form_row(form.organizationalAnchoring) }} {{ form_row(form.endorsement) }} - {{ form_row(form.published) }} - {{ form_row(form.budgetMin) }} - {{ form_row(form.budgetMax) }} -
-
- - {{ 'action.reset'|trans }}
{{ form_end(form) }}
- {% if pagination.items is empty %} -
-
{{ 'initiative.empty.title'|trans }}
-

{{ 'initiative.empty.hint'|trans }}

-
- {% else %} -
- - - - - - - - - - - - - - - {% for initiative in pagination.items %} - - - - - - - - - - - {% endfor %} - -
{{ h.sortlink('title', 'initiative.title', sort, direction) }}{{ 'initiative.status'|trans }}{{ 'initiative.initiative_type'|trans }}{{ 'initiative.organizational_anchoring'|trans }}{{ h.sortlink('budget', 'initiative.budget', sort, direction) }}{{ h.sortlink('timePeriodStart', 'initiative.time_period', sort, direction) }}{{ 'autosave.completion'|trans }}
- {{ initiative.title }} - - {% if initiative.status %} - {{ initiative.status.labelKey|trans }} - {% else %} - - {% endif %} - {{ initiative.initiativeType ? initiative.initiativeType.labelKey|trans : '—' }}{{ initiative.organizationalAnchoring ? initiative.organizationalAnchoring.labelKey|trans : '—' }}{{ initiative.budget is not null ? initiative.budget|number_format(0, ',', '.') : '—' }} - {% if initiative.timePeriodStart %} - {{ initiative.timePeriodStart|date('d.m.Y') }}{% if initiative.timePeriodEnd %} – {{ initiative.timePeriodEnd|date('d.m.Y') }}{% endif %} - {% else %}—{% endif %} - - {% set pct = initiative.completionPercentage %} -
- - {{ pct }}% -
-
- -
-
- - - {% endif %} + + {{ include('initiative/_results.html.twig') }} +
{% endblock %} diff --git a/tests/Controller/SmokeTest.php b/tests/Controller/SmokeTest.php index be83de0..7853075 100644 --- a/tests/Controller/SmokeTest.php +++ b/tests/Controller/SmokeTest.php @@ -98,6 +98,7 @@ public static function authenticatedPages(): iterable yield 'dashboard' => ['/']; yield 'initiatives' => ['/initiatives']; yield 'initiatives filtered' => ['/initiatives?status=active&endorsement=1&sort=title&direction=ASC']; + yield 'initiatives freetext search' => ['/initiatives?q=teknik']; yield 'initiative new' => ['/initiatives/new']; yield 'csv export' => ['/initiatives/export']; yield 'contacts' => ['/contacts']; From 80c0376e43c4197702a065ab3d68d8faab72d7df Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 22 Jun 2026 14:48:37 +0200 Subject: [PATCH 2/2] Updated changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fcdeb2..b518e6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-5](https://github.com/itk-dev/itk-project-database/pull/5) + Improve initiative list, search and filters using partial page rendering * [PR-4](https://github.com/itk-dev/itk-project-database/pull/4) Add Chart.js graphs to the dashboard * [PR-3](https://github.com/itk-dev/itk-project-database/pull/3)