From 3636fa5afdea83ea030540be9220b3454e33636a Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 7 Jun 2026 17:33:11 -0700 Subject: [PATCH 01/10] =?UTF-8?q?feat(slice3):=20migration=20034=20?= =?UTF-8?q?=E2=80=94=20cached=5Frfcs.meta=5Fjson=20for=20facet=20values=20?= =?UTF-8?q?(=C2=A722.4a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/migrations/034_cached_rfc_meta.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 backend/migrations/034_cached_rfc_meta.sql diff --git a/backend/migrations/034_cached_rfc_meta.sql b/backend/migrations/034_cached_rfc_meta.sql new file mode 100644 index 0000000..0122aab --- /dev/null +++ b/backend/migrations/034_cached_rfc_meta.sql @@ -0,0 +1,12 @@ +-- §22.4a SLICE-3 — configurable collection metadata: cache per-entry values. +-- +-- Faceted filtering (§5.1) needs each entry's metadata values (priority, custom +-- enum/tags fields) to compute facet counts and honour filter params. Today the +-- mirror keeps only `tags_json` + the lifecycle columns and drops `Entry.extra`, +-- so a declared field's values are unrecoverable. This column persists the full +-- per-entry metadata mapping (`metadata.metadata_dict(entry)`, known keys + +-- extra, never the body) as JSON, so `app/facets.py` can read any declared +-- field uniformly. Additive + nullable — no rebuild. NULL = not yet re-ingested +-- (the reconciler/webhook fills it on the next sweep) → that entry contributes +-- no facet values until then. SLICE-4/5 edit panels read the same column. +ALTER TABLE cached_rfcs ADD COLUMN meta_json TEXT; From 644bf35d893b5d712b40e8f9db7e072b14618aa8 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 7 Jun 2026 17:33:47 -0700 Subject: [PATCH 02/10] =?UTF-8?q?feat(slice3):=20persist=20entry=20metadat?= =?UTF-8?q?a=20to=20cached=5Frfcs.meta=5Fjson=20at=20ingest=20(=C2=A722.4a?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/cache.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/app/cache.py b/backend/app/cache.py index 3186005..538689a 100644 --- a/backend/app/cache.py +++ b/backend/app/cache.py @@ -170,6 +170,10 @@ def _upsert_cached_rfc( # §6.7: funder_login mirrors the optional `funder:` frontmatter # field. NULL means absent — operator credentials are used. funder_login = entry.funder or None + # §22.4a SLICE-3: persist the full per-entry metadata mapping (known keys + + # extra, never the body) so facet/filter can read any declared field. Stored + # via metadata_dict so the sidecar's forward-compat keys (INV-7) ride along. + meta_json = json.dumps(metadata_mod.metadata_dict(entry)) db.conn().execute( """ INSERT INTO cached_rfcs @@ -177,8 +181,8 @@ def _upsert_cached_rfc( graduated_at, graduated_by, owners_json, arbiters_json, tags_json, models_json, funder_login, body, body_sha, unreviewed, reviewed_at, reviewed_by, collection_id, - metadata_malformed, last_entry_commit_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + metadata_malformed, meta_json, last_entry_commit_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) ON CONFLICT(collection_id, slug) DO UPDATE SET title = excluded.title, state = excluded.state, @@ -199,6 +203,7 @@ def _upsert_cached_rfc( reviewed_at = excluded.reviewed_at, reviewed_by = excluded.reviewed_by, metadata_malformed = excluded.metadata_malformed, + meta_json = excluded.meta_json, last_entry_commit_at = datetime('now'), updated_at = datetime('now') """, @@ -224,6 +229,7 @@ def _upsert_cached_rfc( entry.reviewed_by, collection_id, 1 if metadata_malformed else 0, + meta_json, ), ) From 14ea3c0cce29017c7cc9f8f57299e870b5c8fc6e Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 7 Jun 2026 17:34:33 -0700 Subject: [PATCH 03/10] =?UTF-8?q?feat(slice3):=20pure=20facet=20field-set?= =?UTF-8?q?=20+=20filter/count=20helper=20(=C2=A722.4a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/facets.py | 110 +++++++++++++++++++++++++++++++++++ backend/tests/test_facets.py | 87 +++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 backend/app/facets.py create mode 100644 backend/tests/test_facets.py diff --git a/backend/app/facets.py b/backend/app/facets.py new file mode 100644 index 0000000..a10b7d3 --- /dev/null +++ b/backend/app/facets.py @@ -0,0 +1,110 @@ +"""§22.4a SLICE-3 — faceted catalog filtering + counts (read). + +Pure functions over already-mirrored entries; no I/O, no DB. An "entry" here is +a plain dict carrying at least: + - "state": the lifecycle state column, + - "metadata_malformed": bool, + - "meta": the per-entry metadata mapping (from cached_rfcs.meta_json). + +Facetable fields (§5.1, plan decision 1): a collection's declared `enum` and +`tags` fields, in declaration order, plus the built-in `state` facet appended +last — but only when the collection declares a schema (INV-5: a no-`fields:` +collection has no facets at all, so the frontend keeps its legacy chips). `text` +fields are not faceted in v1 (they get a detail control in SLICE-4). + +Counts use drill-down semantics (plan decision 2): the count for a value of +field F is taken over entries matching every OTHER field's selection (and the +malformed toggle), not F's own — so within-field values stay switchable (OR +within a field, AND across fields). The returned items list applies ALL +selections. +""" +from __future__ import annotations + +from typing import Any + +# enum + tags are facetable; text is rendered as a detail control (SLICE-4). +FACETABLE_TYPES = {"enum", "tags"} + + +def facet_fields(fields: dict[str, dict] | None) -> list[tuple[str, str]]: + """Ordered `[(name, type), ...]` facetable from the schema, `state` last. + + Empty when the collection declares no schema (INV-5).""" + if not fields: + return [] + out = [ + (name, spec.get("type")) + for name, spec in fields.items() + if spec.get("type") in FACETABLE_TYPES + ] + out.append(("state", "enum")) + return out + + +def allowed_filter_keys(fields: dict[str, dict] | None) -> set[str]: + """Query-param keys the collection-scoped list accepts (plan decision 6).""" + keys = {name for name, _ in facet_fields(fields)} + keys.update({"unreviewed", "malformed"}) + return keys + + +def _values_for(entry: dict[str, Any], name: str, ftype: str) -> list[str]: + """The facet value(s) an entry contributes for field `name` (str-cast).""" + if name == "state": + v = entry.get("state") + return [str(v)] if v else [] + meta = entry.get("meta") or {} + v = meta.get(name) + if v is None: + return [] + if ftype == "tags": + return [str(x) for x in v] if isinstance(v, list) else [] + return [str(v)] + + +def _matches(entry: dict[str, Any], name: str, ftype: str, selected: set[str]) -> bool: + if not selected: + return True + return bool(set(_values_for(entry, name, ftype)) & selected) # OR within field + + +def filter_and_count( + entries: list[dict[str, Any]], + fields: dict[str, dict] | None, + selections: dict[str, set[str]], + only_malformed: bool = False, +) -> tuple[list[dict[str, Any]], dict[str, dict[str, int]]]: + """Filter `entries` by `selections` and compute drill-down facet counts. + + `selections` maps a facet field name → the set of selected values (OR within + the field; AND across fields). `only_malformed` narrows items and counts to + entries flagged malformed (INV-3). Returns `(items, facets)` where + `facets = {field: {value: count}}`. With no schema → `([all passing], {})`. + """ + facetable = facet_fields(fields) + + def passes_malformed(e: dict[str, Any]) -> bool: + return (not only_malformed) or bool(e.get("metadata_malformed")) + + items = [ + e for e in entries + if passes_malformed(e) + and all(_matches(e, n, t, selections.get(n, set())) for n, t in facetable) + ] + + facets: dict[str, dict[str, int]] = {} + for name, ftype in facetable: + counts: dict[str, int] = {} + for e in entries: + if not passes_malformed(e): + continue + if not all( + _matches(e, on, ot, selections.get(on, set())) + for on, ot in facetable + if on != name + ): + continue + for val in _values_for(e, name, ftype): + counts[val] = counts.get(val, 0) + 1 + facets[name] = counts + return items, facets diff --git a/backend/tests/test_facets.py b/backend/tests/test_facets.py new file mode 100644 index 0000000..92d20a8 --- /dev/null +++ b/backend/tests/test_facets.py @@ -0,0 +1,87 @@ +"""§22.4a SLICE-3 — pure facet field-set + filter/count (PUC-3). + +Per docs/design/2026-06-06-configurable-collection-metadata.md §5.1, §6.4. +""" +from app import facets + + +PRIORITY = {"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}} +SCHEMA = { + "priority": {"type": "enum", "values": ["P0", "P1", "P2"]}, + "tags": {"type": "tags"}, + "owner": {"type": "text"}, +} + + +def _e(slug, state="active", malformed=False, **meta): + return {"slug": slug, "state": state, "metadata_malformed": malformed, "meta": meta} + + +def test_facet_fields_orders_declared_then_state_skips_text(): + # enum + tags in declaration order, text skipped, state appended last. + assert facets.facet_fields(SCHEMA) == [ + ("priority", "enum"), ("tags", "tags"), ("state", "enum")] + + +def test_no_schema_yields_no_facets(): + # INV-5: a collection with no fields has no facets (frontend keeps chips). + assert facets.facet_fields(None) == [] + assert facets.facet_fields({}) == [] + + +def test_filter_and_count_basic_counts(): + entries = [ + _e("a", priority="P0", tags=["checkout"]), + _e("b", priority="P0", tags=["cart"]), + _e("c", priority="P1", tags=["checkout", "cart"]), + ] + items, fac = facets.filter_and_count(entries, SCHEMA, {}) + assert {i["slug"] for i in items} == {"a", "b", "c"} + assert fac["priority"] == {"P0": 2, "P1": 1} + assert fac["tags"] == {"checkout": 2, "cart": 2} + assert fac["state"] == {"active": 3} + + +def test_filter_compose_or_within_and_across(): + entries = [ + _e("a", priority="P0", tags=["checkout"]), # P0 + checkout + _e("b", priority="P0", tags=["cart"]), # P0, no checkout + _e("c", priority="P1", tags=["checkout"]), # checkout, not P0 + ] + # priority=P0 AND tags=checkout → only "a". + items, _ = facets.filter_and_count( + entries, SCHEMA, {"priority": {"P0"}, "tags": {"checkout"}}) + assert {i["slug"] for i in items} == {"a"} + + +def test_drilldown_counts_exclude_own_field_selection(): + entries = [ + _e("a", priority="P0"), + _e("b", priority="P1"), + _e("c", priority="P1"), + ] + # With P0 selected, the priority facet still counts P1 over the set that + # ignores priority's own selection — so P1 stays switchable. + _, fac = facets.filter_and_count(entries, SCHEMA, {"priority": {"P0"}}) + assert fac["priority"] == {"P0": 1, "P1": 2} + + +def test_malformed_toggle_narrows_items_and_counts(): + entries = [ + _e("a", state="active", malformed=True, priority="P9"), + _e("b", state="active", malformed=False, priority="P0"), + ] + items, fac = facets.filter_and_count(entries, SCHEMA, {}, only_malformed=True) + assert {i["slug"] for i in items} == {"a"} + assert fac["priority"] == {"P9": 1} + + +def test_missing_value_contributes_no_facet_value(): + entries = [_e("a", priority="P0"), _e("b")] # b has no priority + _, fac = facets.filter_and_count(entries, SCHEMA, {}) + assert fac["priority"] == {"P0": 1} + + +def test_allowed_filter_keys(): + assert facets.allowed_filter_keys(PRIORITY) == {"priority", "state", + "unreviewed", "malformed"} From 27061c30b036a88564d989cf6d920167c08b5f54 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 7 Jun 2026 17:35:26 -0700 Subject: [PATCH 04/10] =?UTF-8?q?feat(slice3):=20collection=20list=20endpo?= =?UTF-8?q?int=20returns=20facets=20+=20honours=20filters=20(=C2=A722.4a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api.py | 59 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/backend/app/api.py b/backend/app/api.py index 5cfe89d..22ddc81 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -41,6 +41,7 @@ from . import ( docs_specs, entry as entry_mod, cache, + facets, funder, health, notify, @@ -737,9 +738,33 @@ def make_router( raise HTTPException(404, "Not found") def _list_rfcs_for_collection( - collection_id: str, viewer, unreviewed: str | None + collection_id: str, viewer, unreviewed: str | None, + query_params=None, ) -> dict[str, Any]: viewer_id = viewer.user_id if viewer else None + # §22.4a SLICE-3: the collection's declared field schema drives the facet + # set (None when undeclared → no facets, INV-5). + col = collections_mod.get_collection(collection_id) + fields_schema = (col or {}).get("fields") or None + + # Parse + validate filter selections from the query string. Unknown + # field → 400 (§6.4). `unreviewed` keeps its existing meaning; an + # empty-valued selection is ignored, not an error (plan decision 6). + selections: dict[str, set[str]] = {} + only_malformed = False + if query_params is not None: + allowed = facets.allowed_filter_keys(fields_schema) + facet_names = {n for n, _ in facets.facet_fields(fields_schema)} + for key in query_params.keys(): + if key not in allowed: + raise HTTPException(400, f"unknown filter field {key!r}") + if (query_params.get("malformed") or "").lower() in ("1", "true", "yes"): + only_malformed = True + for name in facet_names: + vals = {v for v in query_params.getlist(name) if v != ""} + if vals: + selections[name] = vals + unreviewed_clause = "" if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"): unreviewed_clause = " AND unreviewed = 1 AND state = 'active'" @@ -747,6 +772,7 @@ def make_router( f""" SELECT slug, title, state, rfc_id, repo, owners_json, arbiters_json, tags_json, metadata_malformed, + meta_json, last_main_commit_at, last_entry_commit_at, updated_at FROM cached_rfcs WHERE state IN ('super-draft', 'active') @@ -764,8 +790,16 @@ def make_router( (viewer_id, collection_id), ) } - items = [ - { + + # Build entry dicts the facet helper understands (state + malformed + + # parsed meta), preserving SQL order. + entries = [] + for r in rows: + try: + meta = json.loads(r["meta_json"]) if r["meta_json"] else {} + except (TypeError, ValueError): + meta = {} + entries.append({ "slug": r["slug"], "title": r["title"], "state": r["state"], @@ -778,10 +812,13 @@ def make_router( "starred_by_me": r["slug"] in starred, "has_open_prs": False, "metadata_malformed": bool(r["metadata_malformed"]), - } - for r in rows - ] - return {"items": items} + "meta": meta, + }) + + filtered, facet_counts = facets.filter_and_count( + entries, fields_schema, selections, only_malformed=only_malformed + ) + return {"items": filtered, "facets": facet_counts} def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]: row = db.conn().execute( @@ -813,7 +850,9 @@ def make_router( auth.require_project_readable(viewer, project_id) # §22 S1: the project-scoped route serves the default collection. collection_id = collections_mod.default_collection_id(project_id) - return _list_rfcs_for_collection(collection_id, viewer, unreviewed) + return _list_rfcs_for_collection( + collection_id, viewer, unreviewed, query_params=request.query_params + ) @router.get("/api/projects/{project_id}/rfcs/{slug}") async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]: @@ -835,7 +874,9 @@ def make_router( _require_collection_in_project(collection_id, project_id) # §22.5 (S3): a hidden/gated collection 404s to a non-scope-role viewer. auth.require_collection_readable(viewer, collection_id) - return _list_rfcs_for_collection(collection_id, viewer, unreviewed) + return _list_rfcs_for_collection( + collection_id, viewer, unreviewed, query_params=request.query_params + ) @router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}") async def get_collection_rfc( From 7b269e11c42dac39a37cf8257724790857578657 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 7 Jun 2026 17:37:46 -0700 Subject: [PATCH 05/10] =?UTF-8?q?test(slice3):=20integration=20tests=20for?= =?UTF-8?q?=20faceted=20list=20endpoint=20(=C2=A722.4a=20PUC-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_facets_api.py | 134 +++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 backend/tests/test_facets_api.py diff --git a/backend/tests/test_facets_api.py b/backend/tests/test_facets_api.py new file mode 100644 index 0000000..42cde65 --- /dev/null +++ b/backend/tests/test_facets_api.py @@ -0,0 +1,134 @@ +"""§22.4a SLICE-3 integration — faceted list endpoint (PUC-3, §6.4). + +Through the real API: schema-declared facets, filter params (OR within / AND +across), drill-down counts, malformed toggle, unknown-field 400, and meta_json +persistence. Reuses the fake-Gitea harness from test_metadata_cache. +""" +from __future__ import annotations + +import asyncio +import json + +import yaml +from fastapi.testclient import TestClient + +from app import cache, db, gitea as gitea_mod +from app.config import load_config + +from test_propose_vertical import ( # noqa: F401 (fixtures) + app_with_fake_gitea, + tmp_env, +) + +# The fake-Gitea harness seeds a single project whose id is the literal +# 'default' (see test_s1_collection_grain_vertical), served at +# /api/projects/default/rfcs. +PID = "default" + + +def _refresh(): + cfg = load_config() + asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg))) + + +def _set_default_fields_schema(schema): + db.conn().execute( + "UPDATE collections SET config_json = ? WHERE id = 'default'", + (json.dumps({"fields": schema}),)) + + +def _seed(fake, slug, *, state="active", **front): + fm = {"slug": slug, "title": slug.title(), "state": state, **front} + body = yaml.safe_dump(fm, sort_keys=False).strip() + fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = { + "content": f"---\n{body}\n---\n\nBody.\n", "sha": slug} + + +def test_facets_and_counts_returned(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _set_default_fields_schema({ + "priority": {"type": "enum", "values": ["P0", "P1", "P2"]}, + "tags": {"type": "tags"}, + }) + _seed(fake, "a", priority="P0", tags=["checkout"]) + _seed(fake, "b", priority="P0", tags=["cart"]) + _seed(fake, "c", priority="P1", tags=["checkout", "cart"]) + _refresh() + + res = client.get(f"/api/projects/{PID}/rfcs") + assert res.status_code == 200 + body = res.json() + assert {i["slug"] for i in body["items"]} == {"a", "b", "c"} + assert body["facets"]["priority"] == {"P0": 2, "P1": 1} + assert body["facets"]["tags"] == {"checkout": 2, "cart": 2} + assert body["facets"]["state"] == {"active": 3} + + +def test_filter_params_compose(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _set_default_fields_schema({ + "priority": {"type": "enum", "values": ["P0", "P1"]}, + "tags": {"type": "tags"}, + }) + _seed(fake, "a", priority="P0", tags=["checkout"]) + _seed(fake, "b", priority="P0", tags=["cart"]) + _seed(fake, "c", priority="P1", tags=["checkout"]) + _refresh() + + res = client.get( + f"/api/projects/{PID}/rfcs", + params={"priority": "P0", "tags": "checkout"}) + assert {i["slug"] for i in res.json()["items"]} == {"a"} + + +def test_unknown_filter_field_400(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _set_default_fields_schema( + {"priority": {"type": "enum", "values": ["P0"]}}) + _seed(fake, "a", priority="P0") + _refresh() + + res = client.get(f"/api/projects/{PID}/rfcs", + params={"nonsense": "x"}) + assert res.status_code == 400 + + +def test_malformed_toggle(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _set_default_fields_schema( + {"priority": {"type": "enum", "values": ["P0", "P1"]}}) + _seed(fake, "good", priority="P0") + _seed(fake, "bad", priority="P9") # not in values → malformed (INV-3) + _refresh() + + res = client.get(f"/api/projects/{PID}/rfcs", + params={"malformed": "true"}) + slugs = {i["slug"] for i in res.json()["items"]} + assert slugs == {"bad"} + + +def test_no_schema_no_facets(app_with_fake_gitea): + # INV-5: the default document collection (no fields) returns empty facets. + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _seed(fake, "plain", tags=["whatever"]) + _refresh() + body = client.get(f"/api/projects/{PID}/rfcs").json() + assert body["facets"] == {} + + +def test_meta_json_persisted_at_ingest(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app): + _seed(fake, "withmeta", priority="P0", tags=["x"]) + _refresh() + row = db.conn().execute( + "SELECT meta_json FROM cached_rfcs WHERE slug = 'withmeta'" + ).fetchone() + meta = json.loads(row["meta_json"]) + assert meta["priority"] == "P0" + assert meta["tags"] == ["x"] From bcce40d2cbcc37b5c49337d6783f8573ce959931 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 7 Jun 2026 17:38:16 -0700 Subject: [PATCH 06/10] =?UTF-8?q?feat(slice3):=20listRFCs=20accepts=20face?= =?UTF-8?q?t=20selections,=20returns=20{items,facets}=20(=C2=A722.4a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api.js | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/frontend/src/api.js b/frontend/src/api.js index f0535ce..2ea0f05 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -203,11 +203,27 @@ export async function createProject({ projectId, name, type, visibility, content // §22.4 (Plan B) / §22 S2: per-collection serving. With a projectId + a // collectionId, read the collection-scoped routes; with only a projectId, the // project default-collection compat path; with neither, the unscoped path. -export async function listRFCs(projectId, collectionId) { - if (projectId && collectionId) { - return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs`)) +// §22.4a SLICE-3: faceted catalog. `opts.selections` is { field: string[] } +// (each non-empty array becomes repeated query params, OR within the field); +// `opts.malformed` toggles the malformed-only filter. Returns the full payload +// { items, facets } — callers read both. With no selections it behaves as the +// pre-SLICE-3 list (and a no-fields collection returns facets: {}). Facets are +// served only on the project/collection-scoped paths, not the unscoped one. +export async function listRFCs(projectId, collectionId, opts = {}) { + const { selections = {}, malformed = false } = opts + const qs = new URLSearchParams() + for (const [field, values] of Object.entries(selections)) { + for (const v of values) qs.append(field, v) } - const url = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs' + if (malformed) qs.set('malformed', 'true') + const query = qs.toString() + let base + if (projectId && collectionId) { + base = `/api/projects/${projectId}/collections/${collectionId}/rfcs` + } else { + base = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs' + } + const url = query ? `${base}?${query}` : base return jsonOrThrow(await fetch(url)) } From ae083bfcaa46d4d4ea88927553b1f93684aa5a6e Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 7 Jun 2026 17:38:35 -0700 Subject: [PATCH 07/10] =?UTF-8?q?feat(slice3):=20FacetGroups=20faceted-pan?= =?UTF-8?q?e=20component=20(=C2=A722.4a=20=C2=A75.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/FacetGroups.jsx | 97 +++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 frontend/src/components/FacetGroups.jsx diff --git a/frontend/src/components/FacetGroups.jsx b/frontend/src/components/FacetGroups.jsx new file mode 100644 index 0000000..d7fceef --- /dev/null +++ b/frontend/src/components/FacetGroups.jsx @@ -0,0 +1,97 @@ +// FacetGroups.jsx — §22.4a SLICE-3 faceted left-pane filters (§5.1). +// +// Renders one collapsible group per facet field returned by the API, each with +// per-value result counts and multi-select checkboxes. A `tags`-type field gets +// a "filter values…" search box so it stays usable at 30+ values. Selection +// state + the malformed toggle are owned by the parent (Catalog), which +// re-fetches server-side on change. + +import { useState } from 'react' + +// Human label for the built-in state facet; declared fields use their own name +// (or the schema `label` when the API supplies it on `fields`). +function groupLabel(field, fields) { + if (field === 'state') return 'State' + const def = fields?.[field] + return def?.label || field.charAt(0).toUpperCase() + field.slice(1) +} + +function FacetGroup({ field, fields, counts, selected, onToggle, isTags }) { + const [open, setOpen] = useState(true) + const [valueSearch, setValueSearch] = useState('') + const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]) + const needle = valueSearch.trim().toLowerCase() + const shown = isTags && needle + ? entries.filter(([v]) => v.toLowerCase().includes(needle)) + : entries + return ( +
+ + {open && ( +
+ {isTags && entries.length > 8 && ( + setValueSearch(e.target.value)} + /> + )} + {shown.length === 0 && ( +
No values.
+ )} + {shown.map(([value, count]) => ( + + ))} +
+ )} +
+ ) +} + +export default function FacetGroups({ + facets, fields, selections, onToggle, malformedOnly, onToggleMalformed, + onClear, hasActiveFilters, +}) { + // The API returns facets in field order with `state` last; render in that + // order. `selections` is { field: Set }. + const fieldOrder = Object.keys(facets || {}) + // A `tags`-type field renders the value-search box. + const isTags = (field) => fields?.[field]?.type === 'tags' + return ( +
+ {hasActiveFilters && ( + + )} + {fieldOrder.map(field => ( + + ))} + +
+ ) +} From ae3afe2f4815854baf3e041cbc386db22bda5667 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 7 Jun 2026 17:40:29 -0700 Subject: [PATCH 08/10] =?UTF-8?q?feat(slice3):=20faceted=20catalog=20pane?= =?UTF-8?q?=20gated=20on=20collection=20fields=20(=C2=A722.4a=20=C2=A75.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.css | 33 +++++++++ frontend/src/components/Catalog.jsx | 106 +++++++++++++++++++++++----- 2 files changed, 123 insertions(+), 16 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 8201f3f..62f350b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -150,6 +150,39 @@ .row-title { font-size: var(--text-base); margin-top: 2px; } .catalog-row.is-super .row-title { color: var(--c-gray-500); } .row-tags { font-size: var(--text-xs); color: var(--c-gray-500); margin-top: 2px; } +.row-malformed { font-size: var(--text-2xs); color: var(--c-warning-accent); font-weight: 600; } + +/* §22.4a SLICE-3 — faceted left-pane filters (§5.1). */ +.facets { + padding: 8px 14px; + border-bottom: 1px solid var(--c-gray-150); +} +.facet-group { margin-bottom: 8px; } +.facet-header { + background: none; border: 0; cursor: pointer; width: 100%; text-align: left; + font-size: var(--text-xs); font-weight: 700; color: var(--c-gray-600); + text-transform: uppercase; letter-spacing: 0.04em; padding: 4px 0; +} +.facet-values { display: flex; flex-direction: column; gap: 2px; } +.facet-value { + display: flex; align-items: center; gap: 6px; + font-size: var(--text-xs); color: var(--c-gray-600); cursor: pointer; +} +.facet-value-label { flex: 1; } +.facet-count { color: var(--c-gray-500); font-variant-numeric: tabular-nums; } +.facet-value-search { + width: 100%; margin: 4px 0; font-size: var(--text-xs); + padding: 2px 6px; border: 1px solid var(--c-gray-150); border-radius: var(--radius-sm); +} +.facet-clear { + background: none; border: 0; cursor: pointer; padding: 0 0 6px; + font-size: var(--text-xs); color: var(--c-ink); text-decoration: underline; +} +.facet-empty { font-size: var(--text-xs); color: var(--c-gray-500); } +.facet-malformed { + display: flex; align-items: center; gap: 6px; margin-top: 6px; + font-size: var(--text-xs); color: var(--c-warning-accent); cursor: pointer; +} .catalog-pending { border-top: 1px solid var(--c-gray-150); diff --git a/frontend/src/components/Catalog.jsx b/frontend/src/components/Catalog.jsx index 3c0b2ac..31153e2 100644 --- a/frontend/src/components/Catalog.jsx +++ b/frontend/src/components/Catalog.jsx @@ -11,6 +11,7 @@ import { useParams, Link } from 'react-router-dom' import { listRFCs, listProposals, getCollection } from '../api' import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths' import JoinRequestModal from './JoinRequestModal.jsx' +import FacetGroups from './FacetGroups.jsx' const STATE_CHIPS = [ { id: 'super-draft', label: 'Super-draft' }, @@ -43,34 +44,67 @@ export default function Catalog({ viewer, onProposeRFC, version }) { const [sort, setSort] = useState('recent') const [activeChips, setActiveChips] = useState(new Set()) const [pendingOpen, setPendingOpen] = useState(true) + // §22.4a SLICE-3: faceted left pane. When the collection declares a `fields:` + // schema, the catalog renders faceted groups (counts from the server) instead + // of the legacy state chips, and re-fetches server-side as selections change. + const [fields, setFields] = useState(null) // collection field schema + const [facets, setFacets] = useState({}) // { field: { value: count } } + const [selections, setSelections] = useState({}) // { field: Set } + const [malformedOnly, setMalformedOnly] = useState(false) + const [loading, setLoading] = useState(false) const { slug, prNumber } = useParams() const pid = useProjectId() // §22 S2: the catalog is scoped to the active collection (the `/c/:cid/` // route segment, else the project's default collection). const cid = useCollectionId() + // Collection-level data (proposals, caps, field schema) loads once per + // collection — independent of the facet selections, which only re-fetch the + // entry list. Switching collections clears any active facet selections. useEffect(() => { - listRFCs(pid, cid).then(d => setRfcs(d.items)).catch(() => setRfcs([])) listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([])) setCanContribute(null) setCanRequestJoin(false) + setSelections({}) + setMalformedOnly(false) getCollection(pid, cid) .then(c => { setCanContribute(!!c?.viewer?.can_contribute) setCanRequestJoin(!!c?.viewer?.can_request_join) setEntryNoun(c?.entry_noun || 'RFC') + setFields(c?.fields || null) }) - .catch(() => setCanContribute(false)) + .catch(() => { setCanContribute(false); setFields(null) }) }, [version, pid, cid]) + // §22.4a SLICE-3: the entry list re-fetches server-side whenever the facet + // selections or the malformed toggle change (in legacy mode selections stay + // empty, so this fires once per collection like before). + useEffect(() => { + setLoading(true) + const selObj = Object.fromEntries( + Object.entries(selections).map(([f, set]) => [f, [...set]]) + ) + listRFCs(pid, cid, { selections: selObj, malformed: malformedOnly }) + .then(d => { setRfcs(d.items); setFacets(d.facets || {}) }) + .catch(() => { setRfcs([]); setFacets({}) }) + .finally(() => setLoading(false)) + }, [version, pid, cid, selections, malformedOnly]) + // While caps load, fall back to "any authenticated viewer" so the propose // affordance on the common (default-collection) case doesn't flash off. const mayPropose = canContribute === null ? !!viewer : canContribute + // §22.4a SLICE-3: faceted mode when the collection declares a non-empty + // `fields:` schema; otherwise the legacy state-chip catalog (INV-5). + const faceted = !!fields && Object.keys(fields).length > 0 + const filtered = useMemo(() => { const needle = search.trim().toLowerCase() let items = rfcs.filter(r => { - if (activeChips.size > 0 && !activeChips.has(r.state)) return false + // In faceted mode the server already filtered by state; the legacy chips + // narrow client-side only when there's no schema. + if (!faceted && activeChips.size > 0 && !activeChips.has(r.state)) return false if (!needle) return true const hay = [r.title, r.slug, r.id || ''].join(' ').toLowerCase() return hay.includes(needle) @@ -85,7 +119,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) { return (b.last_active_at || '').localeCompare(a.last_active_at || '') }) return items - }, [rfcs, search, sort, activeChips]) + }, [rfcs, search, sort, activeChips, faceted]) function toggleChip(id) { const next = new Set(activeChips) @@ -93,6 +127,20 @@ export default function Catalog({ viewer, onProposeRFC, version }) { setActiveChips(next) } + // §22.4a SLICE-3 facet selection handlers (faceted mode). + function toggleFacet(field, value) { + setSelections(prev => { + const next = { ...prev } + const set = new Set(next[field] || []) + set.has(value) ? set.delete(value) : set.add(value) + if (set.size === 0) delete next[field] + else next[field] = set + return next + }) + } + function clearFilters() { setSelections({}); setMalformedOnly(false) } + const hasActiveFilters = Object.keys(selections).length > 0 || malformedOnly + return (