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 (