From 0d82bbded70f2178cd66096dd3845013d5134239 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Fri, 14 Aug 2026 01:39:32 +0300 Subject: [PATCH] feat(content): filter and sort a listing by what the model actually declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../content/system/ui-strings/en.json | 12 + app/components/molecules/ContentFilterBar.vue | 157 ++++++++ .../organisms/ContentCollectionView.vue | 140 ++++++- app/utils/content-filters.ts | 343 ++++++++++++++++++ .../content-collection-view.nuxt.test.ts | 97 ++++- tests/unit/content-filters.test.ts | 242 ++++++++++++ 6 files changed, 982 insertions(+), 9 deletions(-) create mode 100644 app/components/molecules/ContentFilterBar.vue create mode 100644 app/utils/content-filters.ts create mode 100644 tests/unit/content-filters.test.ts diff --git a/.contentrain/content/system/ui-strings/en.json b/.contentrain/content/system/ui-strings/en.json index ea6e255..c780ca3 100644 --- a/.contentrain/content/system/ui-strings/en.json +++ b/.contentrain/content/system/ui-strings/en.json @@ -276,7 +276,12 @@ "content.entry_count_filtered": "Showing {shown} of {matched} matches in {total} entries", "content.entry_count_partial": "Showing {shown} of {total}", "content.field_required": "This field is required", + "content.filter": "Filter", + "content.filter_clear": "Clear filters", "content.filter_keys": "Filter keys or values...", + "content.filter_none": "This model has no fields that can be filtered.", + "content.filter_remove": "Remove filter {value}", + "content.filter_status": "Status", "content.keep_editing": "Keep editing", "content.key_column": "Key", "content.keys_count": "keys", @@ -312,6 +317,13 @@ "content.select_entry": "Select an entry", "content.show_more": "Show more", "content.slug_hint": "Lowercase letters, numbers, and hyphens only", + "content.sort": "Sort", + "content.sort_default": "Default order", + "content.sort_field_asc": "{field} ascending", + "content.sort_field_desc": "{field} descending", + "content.sort_title_asc": "Title A–Z", + "content.sort_title_desc": "Title Z–A", + "content.sort_updated": "Recently updated", "content.stat_entries": "Entries", "content.stat_locales": "Locales", "content.stat_models": "Models", diff --git a/app/components/molecules/ContentFilterBar.vue b/app/components/molecules/ContentFilterBar.vue new file mode 100644 index 0000000..36a66a1 --- /dev/null +++ b/app/components/molecules/ContentFilterBar.vue @@ -0,0 +1,157 @@ + + + diff --git a/app/components/organisms/ContentCollectionView.vue b/app/components/organisms/ContentCollectionView.vue index ab865ce..c1189cb 100644 --- a/app/components/organisms/ContentCollectionView.vue +++ b/app/components/organisms/ContentCollectionView.vue @@ -58,13 +58,81 @@ const visibleCount = ref(PAGE_SIZE) const allIds = computed(() => Object.keys(props.content)) +// ── Filter + sort ────────────────────────────────────────── +// The axes come from the model, so they differ per model and cannot be +// hardcoded. The full definition comes from the brain rather than the injected +// meta, which carries only id/name/kind — `title_field` and `fields` are needed +// here to title a sort and to derive the axes. +const modelDefinition = computed(() => + brain.models.value.find(m => m.id === props.modelId) ?? null, +) + +const filterSelection = ref({}) +const sortBy = ref(SORT_DEFAULT) + +/** + * Human labels for relation targets, so a relation axis reads as titles rather + * than as `f3a81c09d24e`. Loaded when the model changes, not per render. + */ +const relationLabels = ref>>({}) + +async function loadRelationLabels() { + const model = modelDefinition.value + const fields = (model?.fields ?? {}) as Record + const next: Record> = {} + + for (const [fieldId, def] of Object.entries(fields)) { + if (def?.type !== 'relation' && def?.type !== 'relations') continue + const targets = Array.isArray(def.model) ? def.model : def.model ? [def.model] : [] + const labels: Record = {} + + for (const targetId of targets) { + const targetModel = brain.models.value.find(m => m.id === targetId) ?? null + const result = await brain.queryContent(targetId, props.locale ?? 'en') + const data = result?.data as Record> | Array> | null + if (Array.isArray(data)) { + for (const doc of data) { + const slug = doc.slug as string + if (slug) labels[slug] = resolveEntryTitle(doc, targetModel, slug) + } + } + else if (data) { + for (const [ref, entry] of Object.entries(data)) labels[ref] = resolveEntryTitle(entry, targetModel, ref) + } + } + + if (Object.keys(labels).length > 0) next[fieldId] = labels + } + + relationLabels.value = next +} + +const filterAxes = computed(() => deriveFilterAxes({ + model: modelDefinition.value, + content: props.content, + meta: props.meta as Record | null, + relationLabels: relationLabels.value, + t, +})) + +const sortOptions = computed(() => deriveSortOptions({ + model: modelDefinition.value, + meta: props.meta as Record | null, + hasStatusAxis: filterAxes.value.some(a => a.id === STATUS_AXIS_ID), + t, +})) + +const activeFilterCount = computed(() => + Object.values(filterSelection.value).reduce((sum, values) => sum + values.length, 0), +) + /** * Search runs against the brain's index, not the rendered rows — otherwise it * would only ever find what is already on screen, which is the opposite of what * it is for. The index holds every entry, so a match on page twenty is found * without paging there. */ -const matchedIds = computed(() => { +const searchedIds = computed(() => { if (!searchQuery.value.trim()) return allIds.value if (!searchIds.value) return [] // Intersect rather than trust the index: it is rebuilt per sync, so it can @@ -73,6 +141,30 @@ const matchedIds = computed(() => { return searchIds.value.filter(id => present.has(id)) }) +/** + * Search and filter compose as an intersection, then the result is ordered. + * + * Both work over the whole model — search through the index, filters through + * the in-memory payload — so neither is limited to the rows already rendered. + * The page limit is applied last, to whatever survived. + */ +const matchedIds = computed(() => { + const filtered = applyFilters( + searchedIds.value, + props.content, + props.meta as Record | null, + filterAxes.value, + filterSelection.value, + ) + return sortIds( + filtered, + props.content, + props.meta as Record | null, + sortBy.value, + modelDefinition.value, + ) +}) + /** * An entry named by `?entry=` — the palette's search result — is pulled to the * front and opened. Otherwise "go to this entry" would drop someone at the top @@ -139,13 +231,23 @@ onBeforeUnmount(() => { if (searchTimer) clearTimeout(searchTimer) }) -// A different model or locale is a different list; the old query and page -// position mean nothing there. +// A different model or locale is a different list; the old query, filters and +// page position mean nothing there — one model's `category` is not another's. watch(() => [props.modelId, props.locale], () => { searchQuery.value = '' searchIds.value = null visibleCount.value = PAGE_SIZE -}) + filterSelection.value = {} + sortBy.value = SORT_DEFAULT + relationLabels.value = {} + void loadRelationLabels() +}, { immediate: true }) + +// Paging restarts whenever the surviving set changes, so page two of the old +// filter is never page two of the new one. +watch([filterSelection, sortBy], () => { + visibleCount.value = PAGE_SIZE +}, { deep: true }) const getFieldType = inject(getFieldTypeKey, () => 'string') const getEntryTitle = inject(getEntryTitleKey, (_e: Record, f: string) => f) @@ -254,7 +356,7 @@ function onFieldDragStart(e: DragEvent, entryId: string, fieldId: string, value:
-
+
+ +
@@ -385,17 +497,31 @@ function onFieldDragStart(e: DragEvent, entryId: string, fieldId: string, value: :title="t('common.loading')" :description="t('content.searching_description')" /> + + + +
-