Research: htmx + filter.js coverage for filterable admin Series list (#123)
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
# htmx + filter.js — how far they carry a filterable, sortable, pageable owner-only Series list
|
||||
|
||||
Research note for Gitea issue #123 (part of #114, admin dashboard spec): does the
|
||||
Series list's filtering/sorting/paging need anything new, or does the frontend
|
||||
already carry it?
|
||||
|
||||
All facts fetched live on **2026-08-17**. Sources are the in-tree code
|
||||
(`backend/internal/web/…`, commit on `main` before this branch) and the official
|
||||
htmx 2.x documentation at `htmx.org` (docs, per-attribute pages, examples,
|
||||
migration guide, GitHub releases). Every claim carries a `file:line` or a URL.
|
||||
Interpretation rather than observation is marked `[INFERENCE]`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary answer table
|
||||
|
||||
| Question | Answer | Evidence |
|
||||
|---|---|---|
|
||||
| Vendored htmx version | **2.0.4** — the only version string in the file | §2.1 |
|
||||
| What `filter.js` does | **Pure client-side** title filter over a server-rendered list already in the DOM. It issues **no requests** and never touches the URL; the server never sees the query. | §2.2 |
|
||||
| Markup contract it depends on | `#search` input, `.card` articles with `data-title`, `#list` swap target, optional `.recent` strip and `#no-match` empty state | §2.2 |
|
||||
| URL-addressable filter/sort state | **Idiomatic htmx**: `hx-get` with query params + `hx-push-url` (or `hx-replace-url`). The swap alone never changes the address bar. Already proven in-tree by the tab links. | §3.1 |
|
||||
| Debounced search-as-you-type | **Idiomatic htmx**: `hx-trigger="keyup changed delay:500ms"` (or `input changed delay:…`). Zero JS. Server-driven, unlike the current client filter. | §3.2 |
|
||||
| Paging | **Idiomatic htmx**, no paging attribute exists. "Load more" = `hx-get` + `hx-swap="beforeend"` or `hx-trigger="revealed"`; numbered pages = `hx-get` + `hx-push-url` per page, exactly the tab pattern. They differ in swap style and URL semantics, not in features. | §3.3 |
|
||||
| Per-row action re-rendering one row | **Idiomatic htmx**: `hx-target` + `hx-swap="outerHTML"` (in-tree since the first card action); out-of-band chrome via `hx-swap-oob` (in-tree). | §3.4 |
|
||||
| Self-refresh beside a filtered list | **Idiomatic htmx**: `hx-trigger="every 30s"` scoped to its own block (the in-tree Lanes pattern), or list itself re-applies the client filter on `htmx:afterSwap` (already in filter.js). | §3.5 |
|
||||
| Confirm-gated row action | Both options already in-tree: `hx-confirm` (zero JS, `window.confirm`) and the hand-rolled inline confirm row. | §3.6 |
|
||||
| Does vendored 2.0.4 support every attribute cited? | **Yes** — verified in the vendored file itself, not assumed from latest docs | §4 |
|
||||
| New dependency implied | **None.** Everything needed is vendored; extensions are only relevant for drag-reorder (SortableJS), which column sort does not need. | §5 |
|
||||
|
||||
---
|
||||
|
||||
## 2. What the in-tree frontend actually does
|
||||
|
||||
### 2.1 The htmx build
|
||||
|
||||
`backend/internal/web/static/htmx.min.js` is **htmx 2.0.4**: the file's only
|
||||
version string is `version:"2.0.4"` (minified config object, first line of the
|
||||
file). GitHub release v2.0.4 was published 2024-12-13
|
||||
(`https://github.com/bigskysoftware/htmx/releases/tag/v2.0.4`); 2.0.0
|
||||
(2024-06-17) is the first 2.x. The current docs describe 2.0.10
|
||||
(`https://htmx.org/docs/#installing`), so version-specific claims are checked
|
||||
against 2.0.4 in §4 rather than assumed from today's docs.
|
||||
|
||||
### 2.2 `filter.js` — a client-side filter over a rendered list, no requests
|
||||
|
||||
The file's own header says it outright
|
||||
(`backend/internal/web/static/filter.js:1-2`):
|
||||
|
||||
> "Title search runs entirely in the browser: the full list is already in the
|
||||
> DOM, so filtering it needs no request."
|
||||
|
||||
Mechanics (`filter.js:4-32`): on any relevant event, `applyFilter()` reads the
|
||||
`#search` value, lowercases it, iterates every `.card`, and toggles each card's
|
||||
`hidden` attribute by substring-matching `card.dataset.title`
|
||||
(`filter.js:9-16`). It also hides the `.recent` strip while a query is active
|
||||
(`filter.js:21-25`, because "the strip is never filtered") and shows a
|
||||
server-rendered `#no-match` empty state when the query emptied the list
|
||||
(`filter.js:28-32`). No element is fetched, no request is issued, the query
|
||||
never leaves the browser, and the address bar never changes.
|
||||
|
||||
Trigger wiring (`filter.js:35-49`):
|
||||
|
||||
- `input` on `#search` → `applyFilter` (`filter.js:35-37`)
|
||||
- click on `.clear-search` → clear + refilter + refocus (`filter.js:39-42`)
|
||||
- `htmx:afterSwap` on `document.body` → re-apply (`filter.js:48`, comment at
|
||||
`filter.js:47`: "htmx replaces the list on a tab switch, so re-apply to the
|
||||
new cards")
|
||||
- custom `bmgr:refilter` event → re-apply (`filter.js:49`), dispatched by
|
||||
`setActiveTab` (`filter.js:52-62`) because "the strip is outside the swapped
|
||||
region"
|
||||
|
||||
The rest of the file is disclosure and error handling, not filtering: the
|
||||
chapter form and archive/finish/remove **confirm rows** are server-rendered
|
||||
markup toggled open/closed by hand (`closeCardPanels` `filter.js:68`,
|
||||
`togglePanel` `filter.js:80`, `toggleConfirmRow` `filter.js:102-118`), and
|
||||
failed requests are surfaced inline via htmx's event API
|
||||
(`htmx:beforeRequest` `filter.js:157`, `htmx:responseError` `filter.js:172`,
|
||||
`htmx:sendError` `filter.js:185`, reason text read from `xhr.responseText`
|
||||
`filter.js:167`). The comment at `filter.js:153-155` notes "htmx does not swap
|
||||
on a non-2xx response" — the 2.x response-handling behaviour, already
|
||||
accounted for in-tree.
|
||||
|
||||
**Markup contract** — what a server-rendered list must provide for filter.js:
|
||||
|
||||
- `<input id="search" …>` (`backend/internal/web/templates/app.html:45`)
|
||||
- cards as `<article class="card" id="card-{{.Key}}" data-title="{{.Title}}">`
|
||||
(`card.html:5`)
|
||||
- a `#list` container the list is swapped into (`app.html:87`, targeted by
|
||||
`hx-target="#list"` at `app.html:55`)
|
||||
- optionally a `.recent` strip (`chrome.html:10`) and `#no-match`
|
||||
(`list.html:8-11`)
|
||||
|
||||
Scope note: filter.js is only loaded on the library page (`app.html:15-16`).
|
||||
The admin page loads htmx but **not** filter.js (`admin.html:16`), so a Series
|
||||
list on /admin that wanted the current client filter would first need filter.js
|
||||
included there, or the filter would go server-side instead.
|
||||
|
||||
### 2.3 The server side of the same surface (read-only facts)
|
||||
|
||||
- Tab list rendering is **already parameterised**: `GET /ui/list` reads `lib`
|
||||
and `tab` query params and renders the `list` partial plus out-of-band chrome
|
||||
(`backend/internal/web/web.go:348-360`; routes at `web.go:141-145`). The
|
||||
tab URLs are built centrally so the param can't drop off one link
|
||||
(`web.go:88-101`).
|
||||
- The only sort today is server-side and fixed: `h.store.List` returns
|
||||
"already ordered updated_at DESC" (`web.go:293`).
|
||||
- Card mutations re-render **one card fragment** (`saveAndRenderCard`), and
|
||||
delete answers an empty body that htmx swaps into the card's place
|
||||
(`web.go:551-560`); chrome regions ride along as OOB partials
|
||||
(`writeChromeOOB` `web.go:371-383`, emitting `hx-swap-oob="true"` at
|
||||
`chrome.html:10,35,78`).
|
||||
- Admin routes are owner-gated in one place (`requireOwner` at
|
||||
`backend/internal/web/admin.go:107-117`; route list `admin.go:86-89`).
|
||||
The Lanes fragment renders only itself for its own poll
|
||||
(`uiLanes` → `lanes` partial, `admin.go:136-138`).
|
||||
|
||||
### 2.4 htmx attribute inventory in the templates
|
||||
|
||||
| Attributes | Where | Purpose |
|
||||
|---|---|---|
|
||||
| `hx-get` + `hx-target="#list"` + `hx-swap="innerHTML"` + `hx-push-url` + `hx-on::after-request` | `app.html:55-56, 62-63, 68-69, 72-73, 76-77` | Tab switches: swap the list, push the tab's URL, re-mark the active tab |
|
||||
| `hx-post` / `hx-delete` + `hx-target="[id='card-{{.Key}}']"` + `hx-swap="outerHTML"` + `hx-indicator` + `hx-disabled-elt` (+ `hx-vals`) | `card.html:49-51, 68-70, 89-91, 112-114, 124-126, 135-137` | Per-card actions that re-render only that card |
|
||||
| `hx-swap-oob="true"` | `chrome.html:10, 35, 78` | Recent strip, action key, Updated badge updated out-of-band with every list response |
|
||||
| `hx-get` + `hx-trigger="every 30s"` + `hx-swap="outerHTML"` | `lanes.html:11` | Self-replacing poll fragment |
|
||||
| `hx-post` + `hx-target="#readers"` + `hx-swap="outerHTML"` + `hx-confirm` | `readers.html:30-31, 38-39` | Whole-roster re-render after confirm-gated actions |
|
||||
| `hx-post` + `hx-target="#setup"` + `hx-swap="outerHTML"` + `hx-confirm` | `setup.html:27-29` | Confirm-gated token rotation |
|
||||
|
||||
---
|
||||
|
||||
## 3. The six needs against the official htmx 2.x docs
|
||||
|
||||
### 3.1 URL-addressable filter/sort state that survives a reload and can be bookmarked
|
||||
|
||||
**Covered idiomatically — and already proven in-tree.**
|
||||
|
||||
- `hx-push-url` "allows you to push a URL into the browser location history.
|
||||
This creates a new history entry" and "snapshots the current DOM and saves it
|
||||
into its history cache" (`https://htmx.org/attributes/hx-push-url/`; values
|
||||
`true`, `false`, or an explicit URL; inherited).
|
||||
- `hx-replace-url` does the same without a new history entry
|
||||
(`https://htmx.org/attributes/hx-replace-url/`).
|
||||
- Interaction with `hx-get` swaps — docs, History Support
|
||||
(`https://htmx.org/docs/#history`): "htmx will snapshot the current DOM and
|
||||
store it before it makes a request… It then does the swap and pushes a new
|
||||
location onto the history stack." Back/forward restores from the cache; on a
|
||||
cache miss htmx re-requests the URL with `HX-History-Restore-Request` and
|
||||
"expects back the HTML needed for the entire page." The docs' **NOTE** is the
|
||||
constraint that matters for the IA ticket: "If you push a URL into the
|
||||
history, you **must** be able to navigate to that URL and get a full page
|
||||
back!"
|
||||
- So the issue's premise is exactly right: *the swap itself never changes the
|
||||
address bar*; only `hx-push-url`/`hx-replace-url` (or the `HX-Push-Url` /
|
||||
`HX-Replace-Url` response headers, which "can override this attribute" —
|
||||
`hx-push-url` docs, Notes) do. Server-rendered filtering keeps URLs
|
||||
bookmarkable **if** the request URL carries the params and **if** the pushed
|
||||
URL renders a full page — which the repo already does for tabs: the tab
|
||||
links are real `<a href>` full-page URLs, and the same URL is used for the
|
||||
swap (`hx-get`) and the push (`hx-push-url`) (`app.html:53-56`), with the
|
||||
server rendering both the page and the fragment from the same params
|
||||
(`web.go:88-101`, `web.go:348-349`). A filter/sort/paging control is the same
|
||||
pattern with more params.
|
||||
|
||||
### 3.2 A search box that queries as you type, without a request per keystroke
|
||||
|
||||
**Covered idiomatically, zero JS — but it is a different mechanism from the
|
||||
current client-side filter.**
|
||||
|
||||
- `hx-trigger` modifiers (`https://htmx.org/attributes/hx-trigger/`):
|
||||
`changed` — "only issue a request if the value of the element has changed"
|
||||
(distinct from the `change` event); `delay:<timing>` — "wait the given
|
||||
amount of time before issuing the request. If the event triggers again, the
|
||||
countdown is reset"; plus `throttle` for the drop-instead-of-reset variant.
|
||||
- The docs' own examples: "Active Search" (`https://htmx.org/docs/#trigger-modifiers`):
|
||||
`<input type="text" name="q" hx-get="/trigger_delay" hx-trigger="keyup changed delay:500ms" hx-target="#search-results">`;
|
||||
the same pattern on the attribute page with `input changed delay:1s`
|
||||
(`https://htmx.org/attributes/hx-trigger/`); and the full example with
|
||||
`hx-trigger="input changed delay:500ms, keyup[key=='Enter'], load"`
|
||||
(`https://htmx.org/examples/active-search/`).
|
||||
- Parameters: "an element that causes a request will include its value if it
|
||||
has one" (`https://htmx.org/docs/#parameters`); `hx-include` covers other
|
||||
elements (same section).
|
||||
- `[INFERENCE]` Trade-off to hand to the consuming ticket: debounced htmx
|
||||
search is **server-driven** — one request per settled query, which is what
|
||||
makes the query URL-addressable — whereas the current `filter.js` filter is
|
||||
instant, offline, and invisible to the URL. The docs' progressive-enhancement
|
||||
section notes the active-search pattern does not degrade without JS unless
|
||||
wrapped in a real form (`https://htmx.org/docs/#progressive_enhancement`).
|
||||
The two designs are alternatives, not complements.
|
||||
|
||||
### 3.3 Paging — and whether "load more" and true pagination differ
|
||||
|
||||
**No pagination attribute exists; both styles are compositions of the same
|
||||
primitives. The difference is swap style and URL semantics, not features.**
|
||||
|
||||
- "Load more": the docs' only paging example is Infinite Scroll
|
||||
(`https://htmx.org/examples/infinite-scroll/`): a sentinel row
|
||||
`hx-get="/contacts/?page=2" hx-trigger="revealed" hx-swap="afterend"`,
|
||||
where `revealed` "fires once when an element first scrolls into the
|
||||
viewport" (`https://htmx.org/docs/#special-events`; `intersect once` is the
|
||||
documented alternative for scrollable containers). A "Load more" button is
|
||||
the same idea with a click trigger and `hx-swap="beforeend"` — "Insert the
|
||||
response after the last child of the target element"
|
||||
(`https://htmx.org/attributes/hx-swap/`).
|
||||
- True pagination (numbered pages): each page link is an `hx-get` on that
|
||||
page's URL with `hx-target` on the list, `hx-swap="innerHTML"`/`"outerHTML"`
|
||||
to *replace* the list, and `hx-push-url` for the address — i.e. exactly the
|
||||
repo's tab pattern (`app.html:55-56`). `[INFERENCE]` no docs page documents
|
||||
numbered pagination as such; it is the same building blocks with a replace
|
||||
swap and a pushed URL.
|
||||
- `[INFERENCE]` practical difference for the consuming ticket: load-more
|
||||
appends (state lives in the DOM, page numbers do not appear in the URL, the
|
||||
filter.js re-apply hook at `filter.js:48` still runs after each append swap);
|
||||
numbered paging replaces and is bookmarkable per page. Both are available in
|
||||
the vendored version (§4).
|
||||
|
||||
### 3.4 A per-row action that re-renders only that row
|
||||
|
||||
**Covered idiomatically — this is the oldest pattern in the repo.**
|
||||
|
||||
- `hx-target` "allows you to target a different element for swapping than the
|
||||
one issuing the AJAX request" — any CSS selector, or `this`/`closest`/`find`/
|
||||
`next`/`previous` (`https://htmx.org/attributes/hx-target/`; inherited).
|
||||
- `hx-swap="outerHTML"` — "Replace the entire target element with the
|
||||
response" (`https://htmx.org/attributes/hx-swap/`).
|
||||
- Out-of-band updates: content in a response carrying `hx-swap-oob="true"` is
|
||||
swapped into the matching id elsewhere — "piggyback updates to other elements
|
||||
on a response"; values `true` (= `outerHTML`), any `hx-swap` value, or
|
||||
`swap:selector` (`https://htmx.org/attributes/hx-swap-oob/`).
|
||||
- In-tree proof: `card.html:49-51` targets the one card and replaces it with
|
||||
the re-rendered card; delete answers empty and the card is removed
|
||||
(`web.go:551-560`); chrome regions update OOB (`chrome.html:10,35,78`,
|
||||
`web.go:371-383`). A Series-list row action on the admin page is the same
|
||||
shape — target the row, `outerHTML` the response, OOB anything outside.
|
||||
|
||||
### 3.5 A self-refreshing block on the same page as a filtered list
|
||||
|
||||
**Covered idiomatically; the in-tree Lanes pattern is already the scoped
|
||||
variant, and the client-filter re-apply hook already exists for the un-scoped
|
||||
one.**
|
||||
|
||||
- Polling: `hx-trigger="every <timing>"` polls the URL on an interval
|
||||
(`https://htmx.org/attributes/hx-trigger/#polling` and
|
||||
`https://htmx.org/docs/#polling`); a server response code **286** cancels the
|
||||
polling (docs, Polling); a filter expression can gate it:
|
||||
`hx-trigger="every 1s [someConditional]"` (hx-trigger page, Polling).
|
||||
- Keeping filter state — two documented/in-tree mechanisms:
|
||||
1. **Scope the poll to its own block.** The refresh replaces only the
|
||||
polling element, so a filtered list in a sibling block is untouched. The
|
||||
admin page already does exactly this: `lanes.html:11` polls and replaces
|
||||
itself (`hx-swap="outerHTML"`), and the endpoint renders only the `lanes`
|
||||
partial (`admin.go:136-138`). The readers roster — and any Series list —
|
||||
live in separate sections.
|
||||
2. **If the filtered list itself is the polled block**, a client-side
|
||||
filter would be discarded by the swap unless re-applied — and the repo
|
||||
already re-applies on `htmx:afterSwap` (`filter.js:47-48`). If the filter
|
||||
state is instead in the URL (server-rendered, §3.1), the polled URL
|
||||
carries it and the server re-renders the same view.
|
||||
- Verdict: no extension, no hand-written JS required for either arrangement.
|
||||
|
||||
### 3.6 Confirm-gating a row action
|
||||
|
||||
**Both options already in-tree; the choice is UX, not capability.**
|
||||
|
||||
- `hx-confirm` "allows you to confirm an action before issuing a request…
|
||||
uses the browser's `window.confirm` by default", is inherited, and is
|
||||
customizable through the event detail (`issueRequest` callback, `question`)
|
||||
(`https://htmx.org/attributes/hx-confirm/`). Repo use: `readers.html:31,39`,
|
||||
`setup.html:29`.
|
||||
- Server-rendered confirm row: the repo's other pattern — inline
|
||||
`confirm-row` disclosures in the card (`card.html:106-140`) opened by
|
||||
`toggleConfirmRow` (`filter.js:102-118`). This is hand-written JS
|
||||
(hidden-attribute disclosure + focus management), not an htmx feature; it
|
||||
exists because it is a richer in-page confirm than `window.confirm`. If a
|
||||
Series-list row wants the same richer pattern, it reuses this code; if a
|
||||
native confirm is enough, `hx-confirm` is zero JS.
|
||||
|
||||
---
|
||||
|
||||
## 4. Version check — what vendored 2.0.4 actually supports
|
||||
|
||||
Not assumed from today's docs; checked three ways, all primary:
|
||||
|
||||
1. **Direct grep of the vendored file** (`backend/internal/web/static/htmx.min.js`):
|
||||
the attribute strings `hx-push-url`, `hx-replace-url`, `hx-trigger`,
|
||||
`hx-target`, `hx-swap`, `hx-swap-oob`, `hx-confirm`, `hx-indicator`,
|
||||
`hx-disabled-elt`, `hx-vals`, `hx-include`, `hx-on` are all present; the
|
||||
swap styles `outerHTML`, `innerHTML`, `beforeend`, `afterbegin`, `delete`,
|
||||
`none`; the trigger modifiers `changed`, `delay`, `every`, `throttle`,
|
||||
`once`, `from`; the verbs `get`, `post`, `put`, `patch`, `delete`. The
|
||||
`hx-on:` prefix matcher in the build also covers the repo's
|
||||
`hx-on::after-request` spelling (`app.html:56`).
|
||||
2. **Release chronology**: v2.0.4 was released 2024-12-13
|
||||
(`https://github.com/bigskysoftware/htmx/releases/tag/v2.0.4`); the release
|
||||
bodies for v2.0.5, v2.0.6, v2.0.7 and v2.0.9 (checked via the GitHub API
|
||||
2026-08-17) contain no attribute additions or changes relevant to anything
|
||||
cited here — docs, examples and bugfixes only. Nothing cited in §3 was
|
||||
added after 2.0.4.
|
||||
3. **1.x → 2.x migration guide** (`https://htmx.org/migration-guide-htmx-1/`):
|
||||
none of the cited attributes was removed in 2.x. The only 2.x syntax change
|
||||
touching this repo is `hx-on` → `hx-on:` kebab-case event syntax, which the
|
||||
repo already uses. The guide also states all **extensions were removed from
|
||||
core** and are distributed separately — relevant only if an extension were
|
||||
ever wanted (§5).
|
||||
|
||||
Every attribute cited in §3 is therefore supported by the vendored 2.0.4.
|
||||
|
||||
---
|
||||
|
||||
## 5. Dependencies the work would imply
|
||||
|
||||
**None.** Every need in §3 is met by the vendored htmx 2.0.4 plus the in-tree
|
||||
filter.js. No new Go module, no new npm package, no htmx extension download.
|
||||
|
||||
- What a server-rendered filter/sort/page would cost is *server work*, not
|
||||
dependencies: the list endpoint would read `q`/`sort`/`page` query params and
|
||||
render filtered partials — the same mechanism `uiList` already uses for
|
||||
`lib`/`tab` (`web.go:348-349`).
|
||||
- The one documented htmx extension that mentions sorting is `sortable`
|
||||
(`https://htmx.org/examples/sortable/`) — drag-and-drop *reordering* via the
|
||||
separate `sortable` extension wrapping SortableJS (a third-party library).
|
||||
It is a different feature from column/field sort, and it is the only place a
|
||||
new dependency could creep in. `[INFERENCE]` column sort needs it not: a sort
|
||||
header is just another `hx-get` + `hx-push-url` control (§3.1).
|
||||
- If the IA ticket instead wants the *client-side* filter kept but made
|
||||
URL-addressable (filter.js + `history.pushState` of the query), that is
|
||||
hand-written JS — htmx pushes the **request** URL only, never arbitrary
|
||||
client DOM state — and no dependency either way. `[INFERENCE]`
|
||||
|
||||
---
|
||||
|
||||
## 6. Sources
|
||||
|
||||
- In-tree: `backend/internal/web/static/htmx.min.js`, `backend/internal/web/static/filter.js`,
|
||||
`backend/internal/web/templates/{app,card,chrome,lanes,list,readers,setup,admin}.html`,
|
||||
`backend/internal/web/web.go`, `backend/internal/web/admin.go` (all as cited above).
|
||||
- `https://htmx.org/docs/` — triggers/modifiers (§Triggering Requests, §Trigger Modifiers),
|
||||
Active Search, Polling, OOB Swaps, History Support, Parameters, Progressive Enhancement.
|
||||
- `https://htmx.org/attributes/hx-push-url/`, `…/hx-replace-url/`, `…/hx-trigger/`,
|
||||
`…/hx-target/`, `…/hx-swap/`, `…/hx-swap-oob/`, `…/hx-confirm/`.
|
||||
- `https://htmx.org/examples/active-search/`, `…/infinite-scroll/`, `…/sortable/`.
|
||||
- `https://htmx.org/migration-guide-htmx-1/`.
|
||||
- `https://github.com/bigskysoftware/htmx/releases/tag/v2.0.4` and the v2.0.5–v2.0.9
|
||||
release bodies via `api.github.com/repos/bigskysoftware/htmx/releases`.
|
||||
Reference in New Issue
Block a user