feat(slice3): faceted catalog pane gated on collection fields (§22.4a §5.1)

This commit is contained in:
Ben Stull
2026-06-07 17:40:29 -07:00
parent ae083bfcaa
commit ae3afe2f48
2 changed files with 123 additions and 16 deletions
+90 -16
View File
@@ -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 && (