diff --git a/CHANGELOG.md b/CHANGELOG.md index beb964d..f4189ac 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-8](https://github.com/itk-dev/itk-project-database/pull/8) Adopt itk-dev/entity-bundle: ULID identifiers + shared blamable/timestampable * [PR-10](https://github.com/itk-dev/itk-project-database/pull/10) 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 f03df09..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,16 +65,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'placeholder' => 'filter.all', 'choices' => ['filter.yes' => true, 'filter.no' => 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 238c2cf..c933a78 100644 --- a/src/Model/InitiativeFilter.php +++ b/src/Model/InitiativeFilter.php @@ -27,10 +27,6 @@ class InitiativeFilter public ?bool $endorsement = null; - public ?int $budgetMin = null; - - public ?int $budgetMax = null; - public string $sort = 'createdAt'; public string $direction = 'DESC'; diff --git a/src/Repository/InitiativeRepository.php b/src/Repository/InitiativeRepository.php index 0ca45e6..d566511 100644 --- a/src/Repository/InitiativeRepository.php +++ b/src/Repository/InitiativeRepository.php @@ -5,10 +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 @@ -17,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); } @@ -27,13 +35,50 @@ public function search(InitiativeFilter $filter): QueryBuilder $qb = $this->createQueryBuilder('i'); if (null !== $filter->q && '' !== trim($filter->q)) { - // Escape LIKE wildcards so a user-typed % or _ is matched literally - // instead of acting as a wildcard. Backslash is MariaDB's default - // LIKE escape character. - $term = addcslashes(mb_strtolower(trim($filter->q)), '%_\\'); - $qb->leftJoin('i.createdBy', 'createdBy') - ->andWhere('LOWER(i.title) LIKE :q OR LOWER(i.description) LIKE :q OR LOWER(createdBy.name) LIKE :q OR LOWER(i.statusAdditional) LIKE :q') - ->setParameter('q', '%'.$term.'%'); + // Lower-cased for case-insensitive matching. The raw term drives the + // enum-label lookups below; the LIKE parameter is wildcard-escaped so a + // user-typed % or _ matches literally (backslash is MariaDB's escape). + $term = mb_strtolower(trim($filter->q)); + $qb->setParameter('q', '%'.addcslashes($term, '%_\\').'%'); + + $ors = [ + 'LOWER(i.title) LIKE :q', + 'LOWER(i.description) 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 icrt.id FROM %s icrt JOIN icrt.createdBy cb WHERE LOWER(cb.name) LIKE :q)', Initiative::class), + 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) { @@ -56,20 +101,31 @@ public function search(InitiativeFilter $filter): QueryBuilder $qb->andWhere('i.endorsement = :endorsement')->setParameter('endorsement', $filter->endorsement); } - 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; + } + /** * Returns the filtered initiatives with every to-many collection primed, so * a CSV export can read them without firing a query per row (N+1). Each @@ -145,7 +201,11 @@ public function findRecent(int $limit = 5): array } /** - * @return array> + * 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 { diff --git a/templates/initiative/_results.html.twig b/templates/initiative/_results.html.twig new file mode 100644 index 0000000..6dbde54 --- /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.createdBy ? initiative.createdBy.name : '—' }} +
+ {{ h.icon('eye') }} + {{ h.icon('edit') }} +
+ + +
+
+
+
+ + +{% endif %} diff --git a/templates/initiative/index.html.twig b/templates/initiative/index.html.twig index 44d7a47..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,94 +32,13 @@ {{ form_row(form.initiativeType) }} {{ form_row(form.organizationalAnchoring) }} {{ form_row(form.endorsement) }} - {{ 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 7991266..c417115 100644 --- a/tests/Controller/SmokeTest.php +++ b/tests/Controller/SmokeTest.php @@ -89,6 +89,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']; diff --git a/tests/Repository/InitiativeRepositoryTest.php b/tests/Repository/InitiativeRepositoryTest.php index 1c8f6e4..d4d13ec 100644 --- a/tests/Repository/InitiativeRepositoryTest.php +++ b/tests/Repository/InitiativeRepositoryTest.php @@ -35,14 +35,22 @@ public function testSearchAppliesEveryFilterBranch(): void $filter->initiativeType = InitiativeType::Project; $filter->organizationalAnchoring = OrganizationalAnchoring::HealthAndCare; $filter->endorsement = true; - $filter->budgetMin = 0; - $filter->budgetMax = 1_000_000_000; $filter->sort = 'title'; $filter->direction = 'ASC'; self::assertIsArray($this->repository->search($filter)->getQuery()->getResult()); } + public function testSearchMatchesTranslatedFundingLabel(): void + { + $filter = new InitiativeFilter(); + // "midler" is a substring of the Danish "EU-midler" funding label, so the + // search maps it to the eu_funds slug and matches it inside the funding JSON. + $filter->q = 'midler'; + + self::assertIsArray($this->repository->search($filter)->getQuery()->getResult()); + } + public function testSearchFallsBackForUnknownSortAndDirection(): void { $filter = new InitiativeFilter(); diff --git a/tests/Unit/Entity/InitiativeTest.php b/tests/Unit/Entity/InitiativeTest.php index 6e82ca4..b2a2a14 100644 --- a/tests/Unit/Entity/InitiativeTest.php +++ b/tests/Unit/Entity/InitiativeTest.php @@ -73,6 +73,26 @@ public function testScalarAccessors(): void self::assertSame('Grøn omstilling', (string) $initiative); } + public function testCompletionPercentage(): void + { + self::assertSame(0, (new Initiative())->getCompletionPercentage()); + + $full = (new Initiative()) + ->setTitle('T') + ->setCategory(Category::Climate) + ->setDescription('D') + ->setInitiativeType(InitiativeType::Project) + ->setStatus(Status::Active) + ->setOrganizationalAnchoring(OrganizationalAnchoring::TechnicalAndEnvironment) + ->setEndorsementAuthor(EndorsementAuthor::CityCouncil) + ->setBudget(1000) + ->setFunding([Funding::EuFunds]) + ->setTimePeriodStart(new \DateTimeImmutable()) + ->setTimePeriodEnd(new \DateTimeImmutable()); + + self::assertSame(100, $full->getCompletionPercentage()); + } + public function testFundingRoundTrip(): void { $initiative = (new Initiative())->setFunding([Funding::MunicipalBudget, Funding::EuFunds]); diff --git a/tests/Unit/Model/InitiativeFilterTest.php b/tests/Unit/Model/InitiativeFilterTest.php index 670cd3f..8f35719 100644 --- a/tests/Unit/Model/InitiativeFilterTest.php +++ b/tests/Unit/Model/InitiativeFilterTest.php @@ -23,8 +23,6 @@ public function testDefaults(): void self::assertNull($filter->initiativeType); self::assertNull($filter->organizationalAnchoring); self::assertNull($filter->endorsement); - self::assertNull($filter->budgetMin); - self::assertNull($filter->budgetMax); self::assertSame('createdAt', $filter->sort); self::assertSame('DESC', $filter->direction); } @@ -38,15 +36,11 @@ public function testIsMutable(): void $filter->initiativeType = InitiativeType::Project; $filter->organizationalAnchoring = OrganizationalAnchoring::HealthAndCare; $filter->endorsement = true; - $filter->budgetMin = 1000; - $filter->budgetMax = 5000; $filter->sort = 'title'; $filter->direction = 'ASC'; self::assertSame('klima', $filter->q); self::assertSame(Status::Active, $filter->status); self::assertTrue($filter->endorsement); - self::assertSame(1000, $filter->budgetMin); - self::assertSame(5000, $filter->budgetMax); } }