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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions assets/controllers/live_search_controller.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
11 changes: 0 additions & 11 deletions src/Form/InitiativeFilterType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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],
]);
}

Expand Down
4 changes: 0 additions & 4 deletions src/Model/InitiativeFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
94 changes: 77 additions & 17 deletions src/Repository/InitiativeRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Initiative>
Expand All @@ -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);
}
Expand All @@ -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) {
Expand All @@ -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<string>
*/
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
Expand Down Expand Up @@ -145,7 +201,11 @@ public function findRecent(int $limit = 5): array
}

/**
* @return array<int, array<string, mixed>>
* 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<array<string, mixed>>
*/
public function dashboardRows(): array
{
Expand Down
95 changes: 95 additions & 0 deletions templates/initiative/_results.html.twig
Original file line number Diff line number Diff line change
@@ -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' %}
<a class="table__sort {{ isActive ? 'is-active' }}" href="{{ path('app_initiative_index', app.request.query.all|merge({sort: field, direction: newDir, page: 1})) }}">
{{ label|trans }}{% if isActive %} {{ direction == 'ASC' ? '▲' : '▼' }}{% endif %}
</a>
{% endmacro %}

{% macro icon(name) %}
{% if name == 'eye' %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{% elseif name == 'edit' %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
{% elseif name == 'trash' %}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
{% endif %}
{% endmacro %}

{% if pagination.items is empty %}
<div class="empty-state">
<div class="empty-state__title">{{ 'initiative.empty.title'|trans }}</div>
<p>{{ 'initiative.empty.hint'|trans }}</p>
</div>
{% else %}
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>{{ h.sortlink('title', 'initiative.title', sort, direction) }}</th>
<th>{{ 'initiative.status'|trans }}</th>
<th>{{ 'initiative.initiative_type'|trans }}</th>
<th>{{ 'initiative.organizational_anchoring'|trans }}</th>
<th>{{ h.sortlink('timePeriodStart', 'initiative.time_period', sort, direction) }}</th>
<th>{{ 'initiative.author'|trans }}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for initiative in pagination.items %}
<tr>
<td>
<a href="{{ path('app_initiative_show', {id: initiative.id}) }}" class="cell-title" data-turbo-frame="_top">{{ initiative.title }}</a>
</td>
<td>
{% if initiative.status %}
<span class="badge badge--status-{{ initiative.status.value }}">{{ initiative.status.labelKey|trans }}</span>
{% else %}
<span class="muted">—</span>
{% endif %}
</td>
<td>{{ initiative.initiativeType ? initiative.initiativeType.labelKey|trans : '—' }}</td>
<td>{{ initiative.organizationalAnchoring ? initiative.organizationalAnchoring.labelKey|trans : '—' }}</td>
<td>
{% if initiative.timePeriodStart %}
{{ initiative.timePeriodStart|date('d.m.Y') }}{% if initiative.timePeriodEnd %} – {{ initiative.timePeriodEnd|date('d.m.Y') }}{% endif %}
{% else %}—{% endif %}
</td>
<td>{{ initiative.createdBy ? initiative.createdBy.name : '—' }}</td>
<td>
<div class="cell-actions">
<a href="{{ path('app_initiative_show', {id: initiative.id}) }}" class="btn-icon" data-turbo-frame="_top" title="{{ 'action.view'|trans }}" aria-label="{{ 'action.view'|trans }}">{{ h.icon('eye') }}</a>
<a href="{{ path('app_initiative_edit', {id: initiative.id}) }}" class="btn-icon" data-turbo-frame="_top" title="{{ 'action.edit'|trans }}" aria-label="{{ 'action.edit'|trans }}">{{ h.icon('edit') }}</a>
<form method="post" action="{{ path('app_initiative_delete', {id: initiative.id}) }}" data-turbo-frame="_top" data-turbo-confirm="{{ 'action.confirm_delete'|trans }}">
<input type="hidden" name="_token" value="{{ csrf_token('delete-initiative-' ~ initiative.id) }}">
<button type="submit" class="btn-icon btn-icon--danger" title="{{ 'action.delete'|trans }}" aria-label="{{ 'action.delete'|trans }}">{{ h.icon('trash') }}</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

<div class="pagination">
<span class="pagination__info">{{ pagination.firstResult }}–{{ pagination.lastResult }} / {{ pagination.total }}</span>
{% if pagination.pages > 1 %}
<div class="pagination__pages">
{% if pagination.hasPrevious %}
<a class="pagination__link" href="{{ path('app_initiative_index', app.request.query.all|merge({page: pagination.page - 1})) }}">‹</a>
{% endif %}
{% for p in 1..pagination.pages %}
{% if pagination.pages <= 12 or (p <= 2) or (p >= pagination.pages - 1) or (p >= pagination.page - 2 and p <= pagination.page + 2) %}
<a class="pagination__link {{ p == pagination.page ? 'is-active' }}" href="{{ path('app_initiative_index', app.request.query.all|merge({page: p})) }}">{{ p }}</a>
{% endif %}
{% endfor %}
{% if pagination.hasNext %}
<a class="pagination__link" href="{{ path('app_initiative_index', app.request.query.all|merge({page: pagination.page + 1})) }}">›</a>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
Loading
Loading