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 %} -