feat(content): filter and sort a listing by what the model actually declares - #195
Merged
Conversation
…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
force-pushed
the
feat/content-filter-sort
branch
from
August 13, 2026 23:07
1f3cbb5 to
0d82bbd
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
selectfields 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.field.optionstitle_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-featuresproduces 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
categorymeans 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_atarrived 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 lint0 errors ·pnpm typecheckclean ·pnpm test147 files / 1278 testsNot verified — needs a browser