diff --git a/docs/research/htmx-filterable-admin-series-list.md b/docs/research/htmx-filterable-admin-series-list.md new file mode 100644 index 0000000..70590ae --- /dev/null +++ b/docs/research/htmx-filterable-admin-series-list.md @@ -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: + +- `` (`backend/internal/web/templates/app.html:45`) +- cards as `
` + (`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 `` 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:` — "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`): + ``; + 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 "` 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`.