Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

* [PR-26](https://github.com/itk-dev/itk-projects/pull/26)
Reflect the initiative list's filters in the address bar so they can be
deeplinked.

## [0.2.0] - 2026-06-30

* [PR-23](https://github.com/itk-dev/itk-projects/pull/23)
Expand Down
61 changes: 56 additions & 5 deletions assets/controllers/live_search_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@ import { Controller } from "@hotwired/stimulus";
* (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.
*
* The frame carries data-turbo-action="advance", so the query it was fetched
* with becomes the address bar URL — the deeplink trimQuery() keeps clean.
*/
export default class extends Controller {
static values = { debounce: { type: Number, default: 300 } };
static targets = ["form"];

static values = {
debounce: { type: Number, default: 300 },
exportUrl: String,
};

connect() {
this.timer = null;
Expand All @@ -20,21 +28,64 @@ export default class extends Controller {
submit() {
window.clearTimeout(this.timer);
this.timer = window.setTimeout(
() => this.element.requestSubmit(),
() => this.formTarget.requestSubmit(),
this.debounceValue,
);
}

// Filtering is live, so Enter has nothing left to run — and without a
// submit button the browser would submit the form itself on every press.
ignoreEnter(event) {
event.preventDefault();
}

clear() {
for (const input of this.element.querySelectorAll("input")) {
for (const input of this.formTarget.querySelectorAll("input")) {
if (!["submit", "button", "reset"].includes(input.type)) {
input.value = "";
}
}
for (const select of this.element.querySelectorAll("select")) {
for (const select of this.formTarget.querySelectorAll("select")) {
select.selectedIndex = 0;
}
window.clearTimeout(this.timer);
this.element.requestSubmit();
this.formTarget.requestSubmit();
}

// Turbo re-reads detail.url after this event, and the frame adopts the
// response URL — so dropping empty filters here is what shortens the link.
trimQuery(event) {
const url = new URL(event.detail.url);
url.search = this.query();
event.detail.url = url;
}

// Downloads a file, so it leaves Turbo behind.
exportCsv() {
const query = this.query();
window.location.assign(
query ? `${this.exportUrlValue}?${query}` : this.exportUrlValue,
);
}

// Sorting is driven by links inside the frame rather than by a form field,
// so it has to come off the URL or a filter change would reset it.
query() {
const params = new URLSearchParams();
for (const [name, value] of new FormData(this.formTarget)) {
if ("" !== value) {
params.append(name, value);
}
}

const current = new URLSearchParams(window.location.search);
for (const name of ["sort", "direction"]) {
const value = current.get(name);
if (value) {
params.set(name, value);
}
}

return params.toString();
}
}
15 changes: 9 additions & 6 deletions templates/initiative/index.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,22 @@
{% block title %}{{ 'initiative.index.title'|trans }} · {{ 'app.name'|trans }}{% endblock %}

{% block body %}
<div class="page">
<div class="page" data-controller="live-search" data-live-search-export-url-value="{{ path('app_initiative_export') }}">
<twig:Page:Header :title="'initiative.index.title'|trans" :subtitle="'initiative.index.subtitle'|trans">
<button type="submit" form="initiative-filters" formaction="{{ path('app_initiative_export') }}" formmethod="get" formnovalidate class="btn btn--ghost" data-turbo="false">{{ 'action.export'|trans }}</button>
<button type="button" class="btn btn--ghost" data-action="live-search#exportCsv">{{ 'action.export'|trans }}</button>
<a href="{{ path('app_initiative_new') }}" class="btn btn--primary">{{ 'action.new'|trans }}</a>
</twig:Page:Header>

{{ form_start(form, {attr: {
class: 'filters',
id: 'initiative-filters',
'data-controller': 'live-search',
'data-action': 'input->live-search#submit change->live-search#submit',
'data-live-search-target': 'form',
'data-action': 'input->live-search#submit change->live-search#submit turbo:before-fetch-request->live-search#trimQuery',
'data-turbo-frame': 'initiative-results',
}}) }}
<div class="filters__search">
{{ form_row(form.q) }}
{% set search_attr = form.q.vars.attr|merge({'data-action': 'keydown.enter->live-search#ignoreEnter'}) %}
{{ form_row(form.q, {attr: search_attr}) }}
<button type="button" class="btn btn--ghost" data-action="live-search#clear">{{ 'action.reset'|trans }}</button>
</div>
<div class="filters__grid">
Expand All @@ -30,7 +31,9 @@
{{ form_end(form) }}

<div class="card">
<turbo-frame id="initiative-results">
{# advance puts the frame's query in the address bar, so filtering,
sorting and paging all produce a shareable URL. #}
<turbo-frame id="initiative-results" data-turbo-action="advance">
{{ include('initiative/_results.html.twig') }}
</turbo-frame>
</div>
Expand Down
44 changes: 42 additions & 2 deletions tests/Controller/InitiativeControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,51 @@
use App\Entity\Initiative;
use App\Entity\InitiativeAttachment;
use App\Entity\InitiativeImage;
use App\Enum\Status;
use App\Tests\FunctionalTestCase;
use Symfony\Component\HttpFoundation\Response;

final class InitiativeControllerTest extends FunctionalTestCase
{
public function testIndexAppliesFiltersFromTheQueryString(): void
{
$this->loginAsAdmin();
$match = $this->createInitiative('Deeplinked initiative', Status::Active);
$other = $this->createInitiative('Deeplinked other initiative', Status::Cancelled);

// Opening a shared link must narrow the list, not just fill the form.
$crawler = $this->client->request('GET', '/initiatives?q=Deeplinked&status=active&sort=title&direction=ASC');

$this->assertResponseIsSuccessful();
self::assertSame('active', $crawler->filter('#initiative-filters select[name="status"] option[selected]')->attr('value'));
self::assertSame('Deeplinked', $crawler->filter('#initiative-filters input[name="q"]')->attr('value'));

$titles = $crawler->filter('#initiative-results .cell-title')->each(static fn ($node): string => $node->text());
self::assertContains('Deeplinked initiative', $titles);
self::assertNotContains('Deeplinked other initiative', $titles);

$this->removeInitiative((string) $match->getId());
$this->removeInitiative((string) $other->getId());
}

public function testIndexSearchFieldDoesNotSubmitOnEnter(): void
{
$this->loginAsAdmin();
$crawler = $this->client->request('GET', '/initiatives');

$this->assertResponseIsSuccessful();

// Enter clicks the form's default button, so an owned submit button
// anywhere would turn it into a CSV download.
self::assertCount(0, $crawler->filter('#initiative-filters button[type="submit"], #initiative-filters input[type="submit"]'));
self::assertCount(0, $crawler->filter('button[form="initiative-filters"], input[form="initiative-filters"]'));

$search = $crawler->filter('#initiative-filters input[name="q"]');
self::assertStringContainsString('keydown.enter->live-search#ignoreEnter', (string) $search->attr('data-action'));
// Merged onto the field's attributes, not swapped in.
self::assertNotEmpty($search->attr('placeholder'));
}

public function testNewPersistsInitiativeWithInlineContactAndDropsEmptyMedia(): void
{
$this->loginAsAdmin();
Expand Down Expand Up @@ -222,9 +262,9 @@ public function testEditAutosaveReturnsUnprocessableWhenInvalid(): void
$this->removeInitiative($id);
}

private function createInitiative(string $title): Initiative
private function createInitiative(string $title, ?Status $status = null): Initiative
{
$initiative = (new Initiative())->setTitle($title);
$initiative = (new Initiative())->setTitle($title)->setStatus($status);
$em = $this->entityManager();
$em->persist($initiative);
$em->flush();
Expand Down
Loading