Skip to content

feat(content): filter and sort a listing by what the model actually declares - #195

Merged
ABB65 merged 1 commit into
mainfrom
feat/content-filter-sort
Aug 13, 2026
Merged

feat(content): filter and sort a listing by what the model actually declares#195
ABB65 merged 1 commit into
mainfrom
feat/content-filter-sort

Conversation

@ABB65

@ABB65 ABB65 commented Aug 13, 2026

Copy link
Copy Markdown
Member

Closes T06 — the last item in the backlog. Stacked on #194#193.

Entries were rendered in file order with no way to narrow them, which is what made a thousand-article model hard to work in.

The axes are derived, not configured

A model with two select fields gets two axes; a model with none gets only status. Nothing has to be kept in step by hand, and a model that gains a field gains a filter.

axis source labels
status entry meta always available — needs no schema
select field.options straight from the schema
boolean the field three-state
relation target entries titled via the target's title_field (why T07b came first)

An axis with fewer than two options is dropped. Filtering a column where every row holds the same value is a control that can only ever do nothing, and a project that never archives anything should not be handed a status filter.

Verified against the model the brief names: Studio's own plan-features produces status + category (8 options) + type (2) + requires_ee, and the select options come from the schema, not from the data — only three categories appear in the content, and all eight are offered.

One button, not a row of dropdowns

The axis count varies per model and the panel is 280–640px. A fixed row would overflow on some models and look empty on others; a button costs the same for every model. Active filters show as removable chips, so "what is being filtered" stays visible without needing the room.

Composition

Search narrows through the index, filters narrow through the in-memory payload, and both run over the whole model — neither is limited to the rows already rendered. They intersect, the result is sorted, and only then is the page limit applied. The counter reports the intersection (Showing 1 of 1 matches in 3 entries).

Model or locale change resets filters, sort and paging: one model's category means nothing in another.

Sorting

Title A–Z / Z–A reads the field the model declares, so it orders by title rather than by whatever field happened to be first. Numeric and date fields get both directions. Status puts published before draft.

"Recently updated" appears only when the data can answer it. updated_at arrived with types 1.0.0 and is deliberately not backfilled, so on a project whose entries all predate it the criterion would sort nothing — worse than not offering it.

A bug my own test caught

A missing value has to sort last in both directions — reversing an order must not promote "unknown" to the top. My first implementation reversed "most recently updated" by swapping the comparator's arguments, which flipped the missing-last rule along with everything else and put never-written entries first. Written out explicitly now.

Deliberately out of scope

Filter state stays in component state, not the URL. The content panel is not a route; putting the filter in the query string would bind /w/:slug/projects/:id — a route shared with the chat panel — to a listing preference. The brief flags this as something to leave out on purpose rather than add because it is easy.

Verified

  • pnpm lint 0 errors · pnpm typecheck clean · pnpm test 147 files / 1278 tests
  • 25 unit tests on axis derivation, filter composition (OR within an axis, AND across them) and every sort criterion
  • 4 component tests: axes appearing from the model, the counter, search ∩ filter, and empty-with-a-way-out vs empty-because-empty

Not verified — needs a browser

  • the popover and chips at a 280px panel width without horizontal overflow
  • a relation axis against real target content (the labels path is exercised by unit tests with supplied labels; loading them from the brain is not)
  • dark mode on the chips and popover

…eclares

The last item in the backlog. Entries were rendered in file order with no
way to narrow them, which is what made a thousand-article model hard to
work in.

The axes are derived from the model at runtime, not configured. A model
with two `select` fields gets two axes; a model with none gets only
status. Nothing has to be kept in step by hand, and a model that gains a
field gains a filter.

    status      entry meta        always — needs no schema
    select      field.options     straight from the schema
    boolean     the field         three-state
    relation    target entries    titled via the target's title_field

An axis with fewer than two options is dropped. Filtering a column where
every row holds the same value is a control that can only ever do
nothing, and a project that never archives anything should not be handed
a status filter.

## One button, not a row of dropdowns

The axis count varies per model and the panel is 280–640px. A fixed row
would overflow on some models and look empty on others; a button costs
the same for every model. Active filters show as removable chips, so
"what is being filtered" stays visible without needing the room.

## Composition

Search narrows through the index, filters narrow through the in-memory
payload, and both run over the whole model — neither is limited to the
rows already rendered. They intersect, the result is sorted, and only
then is the page limit applied. The counter reports the intersection.

Model or locale change resets filters, sort and paging: one model's
`category` means nothing in another.

## Sorting

Title A–Z / Z–A reads the field the model declares, so it orders by
title rather than by whatever field happened to be first. Numeric and
date fields get both directions. Status orders published before draft.

"Recently updated" appears only when the data can answer it. `updated_at`
arrived with types 1.0.0 and is deliberately not backfilled, so on a
project whose entries all predate it the criterion would sort nothing —
worse than not offering it.

A missing value sorts last in BOTH directions. Reversing an order must
not promote "unknown" to the top; the first implementation did exactly
that for "most recently updated", because it reversed by swapping the
comparator's arguments and that flipped the missing-last rule with
everything else. The test caught it.

Filter state stays in component state, deliberately: the content panel
is not a route, and putting it in the URL would bind a route shared with
the chat panel to a listing preference.
@ABB65
ABB65 force-pushed the feat/content-filter-sort branch from 1f3cbb5 to 0d82bbd Compare August 13, 2026 23:07
@ABB65
ABB65 merged commit 7c81000 into main Aug 13, 2026
2 checks passed
@ABB65
ABB65 deleted the feat/content-filter-sort branch August 13, 2026 23:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant