diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b75412..a49a6a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/assets/controllers/live_search_controller.js b/assets/controllers/live_search_controller.js index e764124..aa0cd15 100644 --- a/assets/controllers/live_search_controller.js +++ b/assets/controllers/live_search_controller.js @@ -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; @@ -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(); } } diff --git a/templates/initiative/index.html.twig b/templates/initiative/index.html.twig index edf52f8..2e91ff4 100644 --- a/templates/initiative/index.html.twig +++ b/templates/initiative/index.html.twig @@ -3,21 +3,22 @@ {% block title %}{{ 'initiative.index.title'|trans }} · {{ 'app.name'|trans }}{% endblock %} {% block body %} -
+
- + {{ 'action.new'|trans }} {{ 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', }}) }}
@@ -30,7 +31,9 @@ {{ form_end(form) }}
- + {# advance puts the frame's query in the address bar, so filtering, + sorting and paging all produce a shareable URL. #} + {{ include('initiative/_results.html.twig') }}
diff --git a/tests/Controller/InitiativeControllerTest.php b/tests/Controller/InitiativeControllerTest.php index f3b1f4f..192b460 100644 --- a/tests/Controller/InitiativeControllerTest.php +++ b/tests/Controller/InitiativeControllerTest.php @@ -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(); @@ -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();