Merge pull request 'v0.49.0 — SLICE-3: faceted left-pane filtering (§22.4a)' (#33) from worktree-metadata-slice3-facets into main
This commit is contained in:
@@ -23,6 +23,45 @@ skip versions are the composition of each intervening adjacent
|
||||
release's steps in order — no A-to-B path is pre-computed beyond
|
||||
that.
|
||||
|
||||
## 0.49.0 — 2026-06-07
|
||||
|
||||
**Minor — faceted left-pane filtering (§22.4a SLICE-3).** A collection that
|
||||
declares a metadata field schema (`fields:`, SLICE-2) now gets a **faceted
|
||||
catalog left pane**: one collapsible filter group per `enum`/`tags` field plus
|
||||
state, each with per-value **result counts** and multi-select checkboxes, and a
|
||||
"filter values…" search box on `tags` groups so they stay usable at many values.
|
||||
Filters compose — OR within a field, AND across fields — and a "malformed
|
||||
metadata only" toggle surfaces entries failing their schema (INV-3). This is
|
||||
SLICE-3 of the
|
||||
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||||
design (§7.2); the single-entry and bulk metadata-edit UIs (SLICE-4/5) follow.
|
||||
|
||||
The collection-scoped list endpoint
|
||||
(`GET /api/projects/{id}/collections/{cid}/rfcs`, and the project-scoped
|
||||
default-collection alias) now honours filter query params
|
||||
(`?priority=P0&tags=checkout&state=active&malformed=true`), returns a
|
||||
`facets: {field → {value → count}}` block with drill-down counts, and includes
|
||||
each entry's metadata `meta` mapping; an unknown filter field is a 400. Migration
|
||||
034 adds the additive `cached_rfcs.meta_json` column that persists per-entry
|
||||
metadata values for the index.
|
||||
|
||||
Non-breaking and opt-in (INV-5): a collection with **no `fields:`** keeps the
|
||||
existing state-chip catalog unchanged, and the §22.13 default `document`
|
||||
collection declares none — so **N=1 deployments see no change**. The unscoped
|
||||
cross-collection `GET /api/rfcs` is untouched.
|
||||
|
||||
**Upgrade steps**
|
||||
|
||||
- Migration 034 (`cached_rfcs.meta_json`) applies automatically on startup
|
||||
(additive, nullable). It is populated lazily as the corpus reconciler /
|
||||
content-repo webhooks re-ingest each collection; until an entry is re-ingested
|
||||
its `meta_json` is NULL and it contributes no facet values. Operators wanting
|
||||
facets populated immediately MAY trigger a corpus refresh (the reconciler sweep
|
||||
on next startup does this). **No data migration is required.**
|
||||
- A deployment opts a collection into faceting by declaring an `enum`/`tags`
|
||||
`fields:` block in its `.collection.yaml` (SLICE-2). A collection with no
|
||||
`fields:` keeps the existing state-chip catalog unchanged.
|
||||
|
||||
## 0.48.0 — 2026-06-07
|
||||
|
||||
**Minor — collection field schema + central validation (§22.4a SLICE-2).** A
|
||||
|
||||
@@ -897,7 +897,13 @@ a hierarchy on the user that gets in the way of finding by title.
|
||||
- **Filter chip strip** — multi-select, AND-combined. Chips:
|
||||
`State: super-draft | active | withdrawn`, `My RFCs` (I'm an owner
|
||||
or arbiter), `Has open PRs`, `Unclaimed` (super-drafts with empty
|
||||
`owners:`), `Tag: …`.
|
||||
`owners:`), `Tag: …`. A collection that declares a metadata field
|
||||
schema (§22.4a) replaces this chip strip with **faceted filter
|
||||
groups** — one collapsible group per `enum`/`tags` field plus state,
|
||||
each showing per-value result counts and multi-select checkboxes
|
||||
(OR within a field, AND across fields); see the Configurable
|
||||
Collection Metadata design. A collection with no `fields:` schema
|
||||
keeps the chip strip described here unchanged.
|
||||
|
||||
### 7.2 The list rows
|
||||
|
||||
|
||||
+50
-9
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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"}
|
||||
@@ -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"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.48.0",
|
||||
"version": "0.49.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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);
|
||||
|
||||
+20
-4
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string> }
|
||||
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 (
|
||||
<aside className="catalog">
|
||||
<div className="catalog-search">
|
||||
@@ -108,22 +156,45 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
{SORT_OPTIONS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="catalog-chips">
|
||||
{STATE_CHIPS.map(chip => (
|
||||
<button
|
||||
key={chip.id}
|
||||
className={`chip ${activeChips.has(chip.id) ? 'active' : ''}`}
|
||||
onClick={() => toggleChip(chip.id)}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{faceted ? (
|
||||
<FacetGroups
|
||||
facets={facets}
|
||||
fields={fields}
|
||||
selections={selections}
|
||||
onToggle={toggleFacet}
|
||||
malformedOnly={malformedOnly}
|
||||
onToggleMalformed={() => setMalformedOnly(m => !m)}
|
||||
onClear={clearFilters}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
/>
|
||||
) : (
|
||||
<div className="catalog-chips">
|
||||
{STATE_CHIPS.map(chip => (
|
||||
<button
|
||||
key={chip.id}
|
||||
className={`chip ${activeChips.has(chip.id) ? 'active' : ''}`}
|
||||
onClick={() => toggleChip(chip.id)}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="catalog-list">
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
|
||||
{rfcs.length === 0
|
||||
{loading
|
||||
? 'Loading…'
|
||||
// §22.4a SLICE-3: faceted mode with active filters → an
|
||||
// explicitly-clearable "no entries match" state (§5.1).
|
||||
: faceted && hasActiveFilters ? (
|
||||
<>
|
||||
No entries match.{' '}
|
||||
<button className="facet-clear" onClick={clearFilters}>Clear filters</button>
|
||||
</>
|
||||
)
|
||||
: rfcs.length === 0
|
||||
// C3.5: a contributor sees a propose-first call to action; a
|
||||
// granted viewer without contribute rights sees a bare empty
|
||||
// state; an anonymous reader sees the read-only note (the footer
|
||||
@@ -147,6 +218,9 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
<span className={`row-id ${isSuper ? 'super' : ''}`}>
|
||||
{isSuper ? 'super-draft' : (r.id || '—')}
|
||||
</span>
|
||||
{r.metadata_malformed && (
|
||||
<span className="row-malformed" title="Metadata fails this collection's schema">⚠ malformed</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="row-title">{r.title}</span>
|
||||
{r.tags.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
// §22.4a SLICE-3 — the faceted catalog pane (PUC-3). Mock the api so
|
||||
// getCollection drives the field schema and listRFCs returns { items, facets }.
|
||||
const listRFCs = vi.fn()
|
||||
const getCollection = vi.fn()
|
||||
vi.mock('../api', () => ({
|
||||
listRFCs: (...a) => listRFCs(...a),
|
||||
listProposals: () => Promise.resolve({ items: [] }),
|
||||
getCollection: (...a) => getCollection(...a),
|
||||
}))
|
||||
vi.mock('../lib/entryPaths', () => ({
|
||||
entryPath: () => '/x', proposalPath: () => '/p',
|
||||
useProjectId: () => 'ohm', useCollectionId: () => 'default',
|
||||
}))
|
||||
import Catalog from './Catalog.jsx'
|
||||
|
||||
const renderCatalog = () =>
|
||||
render(<MemoryRouter><Catalog viewer={null} onProposeRFC={() => {}} version={0} /></MemoryRouter>)
|
||||
|
||||
describe('Catalog faceted pane', () => {
|
||||
beforeEach(() => { listRFCs.mockReset(); getCollection.mockReset() })
|
||||
|
||||
it('renders facet groups with counts when the collection declares fields', async () => {
|
||||
getCollection.mockResolvedValue({
|
||||
fields: { priority: { type: 'enum', values: ['P0', 'P1'] } },
|
||||
viewer: { can_contribute: false }, entry_noun: 'RFC',
|
||||
})
|
||||
listRFCs.mockResolvedValue({
|
||||
items: [{ slug: 'a', title: 'A', state: 'active', tags: [], starred_by_me: false }],
|
||||
facets: { priority: { P0: 3, P1: 1 }, state: { active: 4 } },
|
||||
})
|
||||
renderCatalog()
|
||||
expect(await screen.findByText('P0')).toBeInTheDocument()
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('re-fetches with the selection when a facet checkbox is toggled', async () => {
|
||||
getCollection.mockResolvedValue({
|
||||
fields: { priority: { type: 'enum', values: ['P0'] } },
|
||||
viewer: {}, entry_noun: 'RFC',
|
||||
})
|
||||
listRFCs.mockResolvedValue({ items: [], facets: { priority: { P0: 2 }, state: {} } })
|
||||
renderCatalog()
|
||||
const box = await screen.findByText('P0')
|
||||
fireEvent.click(box.closest('label').querySelector('input'))
|
||||
await waitFor(() => {
|
||||
const last = listRFCs.mock.calls.at(-1)
|
||||
expect(last[2].selections.priority).toContain('P0')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders legacy state chips when the collection declares no fields', async () => {
|
||||
getCollection.mockResolvedValue({ fields: null, viewer: {}, entry_noun: 'RFC' })
|
||||
listRFCs.mockResolvedValue({ items: [], facets: {} })
|
||||
renderCatalog()
|
||||
expect(await screen.findByText('Super-draft')).toBeInTheDocument()
|
||||
expect(screen.getByText('Active')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div className="facet-group">
|
||||
<button className="facet-header" onClick={() => setOpen(o => !o)}>
|
||||
<span>{open ? '▾' : '▸'} {groupLabel(field, fields)}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="facet-values">
|
||||
{isTags && entries.length > 8 && (
|
||||
<input
|
||||
className="facet-value-search"
|
||||
placeholder="filter values…"
|
||||
value={valueSearch}
|
||||
onChange={e => setValueSearch(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{shown.length === 0 && (
|
||||
<div className="facet-empty">No values.</div>
|
||||
)}
|
||||
{shown.map(([value, count]) => (
|
||||
<label key={value} className="facet-value">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(value)}
|
||||
onChange={() => onToggle(field, value)}
|
||||
/>
|
||||
<span className="facet-value-label">{value}</span>
|
||||
<span className="facet-count">{count}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string> }.
|
||||
const fieldOrder = Object.keys(facets || {})
|
||||
// A `tags`-type field renders the value-search box.
|
||||
const isTags = (field) => fields?.[field]?.type === 'tags'
|
||||
return (
|
||||
<div className="facets">
|
||||
{hasActiveFilters && (
|
||||
<button className="facet-clear" onClick={onClear}>Clear filters</button>
|
||||
)}
|
||||
{fieldOrder.map(field => (
|
||||
<FacetGroup
|
||||
key={field}
|
||||
field={field}
|
||||
fields={fields}
|
||||
counts={facets[field]}
|
||||
selected={selections[field] || new Set()}
|
||||
onToggle={onToggle}
|
||||
isTags={isTags(field)}
|
||||
/>
|
||||
))}
|
||||
<label className="facet-malformed">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={malformedOnly}
|
||||
onChange={onToggleMalformed}
|
||||
/>
|
||||
<span>Malformed metadata only</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user