Skip to content

Saved views

The current search’s querystring, named and kept server-side. Saving posts the filter form’s pairs plus a name (data-hx-include); applying a view is a plain GET link — bookmarkable, shareable, zero client state; deleting answers the re-rendered strip. When a view is applied, the server re-renders the filter form with the controls filled — the querystring a view expands to is always visible, so a view is never opaque. Zero new JavaScript. Covered by the versioning policy.

Also known as: saved filters, saved searches.

Filter, name the result, Save view — the new chip lands in the strip marked current. Click a chip to apply it (watch the filter controls fill), × to delete it, and save the same name twice for the 422 branch. This demo stores nothing: each chip’s link carries the view’s full querystring — a real app stores views per user.

Who can see it

No saved views yet — filter, then save the result under a name.

<form id="filters" action="/items" method="get"
data-hx-get="/items"
data-hx-target="#results">
<!-- …the filter fields: q, status, …, and an Apply button… -->
</form>
<form method="post" action="/views"
data-hx-post="/views"
data-hx-include="#filters"
data-hx-target="#views"
data-hx-disabled-elt="find button[type=submit]">
<div class="hc-field">
<label class="hc-field__label" for="view-name">View name</label>
<input class="hc-input" id="view-name" name="name" required>
</div>
<button class="hc-button" data-variant="primary" type="submit">Save view</button>
</form>
<div id="views">
<ul class="hc-chips">
<li class="hc-chip">
<a href="/items?view=quarterly"
data-hx-get="/items?view=quarterly"
data-hx-target="#results">quarterly</a>
<button class="hc-button" data-size="sm" type="button"
aria-label="Delete view quarterly"
data-hx-delete="/views/quarterly"
data-hx-target="#views">×</button>
</li>
</ul>
</div>
<div id="results" aria-live="polite"></div>

The save form pulls the filter pairs in with data-hx-include="#filters" and targets the strip region — the response is the #views contents, never a client-side splice. Each chip is a plain apply link plus a delete button whose accessible name names the view.

CaseResponse
POST /views (name + filter pairs)200 + the strip fragment, the new chip marked current (aria-current="true")
duplicate name422 + the strip with an inline field error (the field-errors shape, data-field="name") — swaps via the standard 422 allowance
GET /items?view=<name>the list fragment for #results with that view’s filters applied, plus an OOB outerHTML re-render of the filter form with the controls filled — a view is never opaque
PUT /views/<name> (the current filter pairs)200 + the strip — update in place, so a corrected view keeps its name and every shared link
POST /views/<name>/default200 + the strip with that view marked default
DELETE /views/<name>200 + the strip
bare list URL with a default set303 to the default view’s URL

The view’s name is its key (URL-encoded in the path). Storage, limits and scope — personal, shared, default — are the server’s business; the wire contract is the same either way. Because the applied querystring lands back in the controls, a view stays editable: apply, tweak a field, save under a new name.

Applying a view and then changing one condition is the commonest thing users do with saved views, and the least served: nothing said whether what you are looking at is still the view. The user either loses the tweak or trusts a saved version that is not on screen.

The apply link names the view it came from (&from-view=<name>), so the server can compare the incoming conditions with the stored ones and mark the chip when they differ:

<li class="hc-chip" data-modified>
<a href="" aria-current="true">Overdue shipments</a>
<span class="hc-badge" data-variant="warning">Modified</span>
<button type="button" data-hx-put="/views/Overdue%20shipments"
data-hx-include="#filters" data-hx-target="#views">Update</button>
<a href="/items?view=Overdue%20shipments">Reset</a>
</li>

Compare normalized querystrings — same params, sorted — or the same question will look different depending on whether it arrived from the form or from a link.

PUT /views/<name> updates in place, so correcting a view keeps its name and every link already shared. 422 duplicate stays, but only for a genuinely new name that collides.

Reset is a link, never <button type="reset">. A native reset restores the values the server rendered into the controls — which, after an apply plus a tweak, are the modified state. Point it at the view’s own URL and let the apply response fill the form.

Recall belongs on the screen, not inside the filter editor. A view is a named URL, so applying one is navigation; behind a dialog it costs four interactions for the screen’s most frequent act, and it mixes a “go there” verb into a “build a condition” surface. Naming what you built is the terminal step of composing, so saving stays with the editor while recall sits beside the screen title.

The chips strip above is the small-set shape. Past a handful of views, render the same links as an hc-menu whose button label is the applied view’s name:

<button class="hc-button" id="view-trigger" popovertarget="views" type="button">
<span>Overdue shipments</span>
<span class="hc-badge" data-variant="warning">Modified</span>
</button>
<div class="hc-menu" id="views" popover role="menu" aria-labelledby="view-trigger">
<a class="hc-menu__item" role="menuitemradio" aria-checked="true"
href="/items?view=overdue">Overdue shipments</a>
<a class="hc-menu__item" role="menuitemradio" aria-checked="false"
href="/items">Show everything</a>
</div>
  • menuitemradio, because exactly one view is applied at a time, with Show everything as the none-of-them option — the way back when a default view redirected the bare list URL.
  • Still real <a href>s: bookmarkable, middle-clickable, no-JS.
  • The server renders the menu, so it owns pinned / recent order and which item carries aria-checked="true" — the call it already makes for the strip.

The data grid page template shows it in place.

A dialog that asks only for a name pushes the other two decisions onto whoever notices later:

FieldWhy it is asked here
namethe view’s key — URL-encoded in view= and in the DELETE path
scope — personal / shareda department standard is the normal case, and silently forking a colleague’s view is an accident
defaulta screen that opens on the wrong question wastes a step every day; the bare list URL then 303s to it, so the address bar still shows the real conditions
<fieldset class="hc-field">
<legend class="hc-field__label">Who can see it</legend>
<label class="hc-radio-label">
<input class="hc-radio" type="radio" name="scope" value="personal" checked>
Only me
</label>
<label class="hc-radio-label">
<input class="hc-radio" type="radio" name="scope" value="shared">
My team
</label>
</fieldset>
<label class="hc-checkbox-label">
<input class="hc-checkbox" type="checkbox" name="default" value="1">
Open this screen on it
</label>
  • At most one default — a screen that opens on two questions has none; saving a new default clears the old.
  • Scope and default are not conditions. PUT /views/<name> corrects what the view asks, and must never silently re-home it or move the default.
  • Shared views are labelled in the strip, and editing one is a distinct, visible act — offer “copy to my views” instead of forking.

Copy link sits beside Save, because a view is a URL: data-hc-copy-text puts the apply URL on the clipboard, no shared object involved. Most “can you send me that list?” moments are a link, not a new object; reserve shared views for standards that outlive a conversation.

A view is a question you want to ask again, not a place you were standing:

InOut
filter conditionspage number
sort (ordered, multi-column)row selection
column set / order, if the view pins themscroll position
page size, groupingexpanded rows

Page number is the dangerous one — “page 7 of yesterday’s data” means nothing, and a shared link lands the recipient somewhere else.

Columns are a preference first. A user’s column choice follows them between screens, so it is stored per user rather than pushed into every URL. A view may pin a column set — “Shipping check” usually means the filters and the columns for that job — and when it does, applying it visibly changes the columns and offers a way back. Resolution order is always URL → user preference → app default, which is what makes a shared link beat a stored layout.

  • Scope. Views are not necessarily personal; a department standard is the normal case. The server owns scope, the strip labels shared views, and editing one is a distinct, visible action — offer “copy to my views” rather than silently forking a colleague’s.
  • Default. A bare list URL may 303 to the default view’s URL, so the address bar always shows the real conditions. A default must never be a hidden filter.
  • Re-authorisation. A stored view can hold conditions the user has since lost the right to run. Re-check on every apply and fail closed (403, or re-ask) — quietly dropping the condition would widen the result set, the one failure a business screen cannot afford.
  • Ordering. Comfortable at five views, unusable at thirty: pinned and recently-used first, the rest behind a menu, and say when the strip was capped.

Apply links are real hrefs — bookmarkable, shareable, full-page navigations without JavaScript. The save form keeps method="post" + action="/views", so a native submit works (answer a full page or a classic 303 post/redirect/get). The delete button is htmx-only; wrap it in a tiny POST form if deletion must work without JavaScript.

  • The strip is a real list — ul.hc-chips announces as “list, N items” — and aria-current="true" marks the current view.
  • Every × has an accessible name naming its view (“Delete view quarterly”), not a bare glyph.
  • The results region is aria-live="polite", so applying a view announces the new list without stealing focus; the duplicate-name error is a role="alert" field-errors fragment.
  • The OOB filter re-render keeps ids stable, so <label for> associations survive.
  • filter-popover — where the filter form itself often lives.
  • live-search — the as-you-type sibling for the same q input.
  • field-errors — the 422 shape the duplicate-name branch answers with.
  • data-region — keeping the result list fresh once views select it.