// Catalog.jsx — the §7 left pane. // // One scrollable flat list of every super-draft and active entry, // state-styled rather than grouped. Filter chips are AND-combined per // §7.1; search is fuzzy over title + slug + id. The "Pending ideas" // disclosure per §7.3 lives at the bottom, above the "+ Propose New RFC" // button. import { useEffect, useMemo, useState } from 'react' import { useParams, Link } from 'react-router-dom' import { listRFCs, listProposals, getCollection, bulkEntryMeta } from '../api' import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths' import JoinRequestModal from './JoinRequestModal.jsx' import FacetGroups from './FacetGroups.jsx' import BulkActionBar from './BulkActionBar.jsx' import { showToast } from './ToastHost.jsx' const STATE_CHIPS = [ { id: 'super-draft', label: 'Super-draft' }, { id: 'active', label: 'Active' }, ] const SORT_OPTIONS = [ { id: 'recent', label: 'Recently active' }, { id: 'title', label: 'Title' }, { id: 'id', label: 'ID' }, { id: 'state', label: 'State' }, ] export default function Catalog({ viewer, onProposeRFC, version }) { const [rfcs, setRfcs] = useState([]) const [proposals, setProposals] = useState([]) // §22 S4: the viewer's contribute capability in this collection, driving the // propose-first empty state (C3.5) and the propose control. `null` until the // collection's caps load; we fall back to "any authenticated viewer" so the // common case doesn't flicker, then refine. const [canContribute, setCanContribute] = useState(null) // §22.8: when the viewer can't contribute here but holds no role reaching the // collection, offer "Request to join" in the footer. const [canRequestJoin, setCanRequestJoin] = useState(false) const [joinOpen, setJoinOpen] = useState(false) // §22.4a: the type-driven entry noun ("RFC" | "Spec" | "Feature") for this // collection, read from the API. Defaults to the generic "RFC" until loaded. const [entryNoun, setEntryNoun] = useState('RFC') const [search, setSearch] = useState('') 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) // §22.4a SLICE-5: multi-select for the bulk action bar (PUC-2). A Set of // selected slugs; the sticky bar appears when ≥1 is selected (faceted + // contributor only). Cleared on collection switch and after a bulk apply. const [selected, setSelected] = useState(() => new Set()) 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(() => { listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([])) setCanContribute(null) setCanRequestJoin(false) setSelections({}) setMalformedOnly(false) setSelected(new Set()) 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); 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 || {}) // §22.4a SLICE-5: drop any bulk selections that the new (filtered) // list no longer contains, so the bar can't act on hidden rows. const visible = new Set(d.items.map(it => it.slug)) setSelected(prev => { const kept = [...prev].filter(s => visible.has(s)) return kept.length === prev.size ? prev : new Set(kept) }) }) .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 => { // 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) }) items = [...items].sort((a, b) => { // Starred items pin to the top of the current sort per §7.2. if (a.starred_by_me !== b.starred_by_me) return a.starred_by_me ? -1 : 1 if (sort === 'title') return a.title.localeCompare(b.title) if (sort === 'id') return (a.id || 'zzz').localeCompare(b.id || 'zzz') if (sort === 'state') return a.state.localeCompare(b.state) // recent return (b.last_active_at || '').localeCompare(a.last_active_at || '') }) return items }, [rfcs, search, sort, activeChips, faceted]) function toggleChip(id) { const next = new Set(activeChips) next.has(id) ? next.delete(id) : next.add(id) 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 // §22.4a SLICE-5: row selection + bulk apply (PUC-2). function toggleSelect(rowSlug) { setSelected(prev => { const next = new Set(prev) next.has(rowSlug) ? next.delete(rowSlug) : next.add(rowSlug) return next }) } async function applyBulk({ op, field, value }) { const slugs = [...selected] if (slugs.length === 0) return try { const res = await bulkEntryMeta(pid, cid, { slugs, op, field, value }) const nApplied = res.applied?.length || 0 const nRejected = res.rejected?.length || 0 showToast({ summary: nRejected ? `${nApplied} updated, ${nRejected} skipped` : `${nApplied} updated`, category: nRejected ? 'warning' : 'success', }) setSelected(new Set()) // Re-fetch the list so the new values + facet counts reflect the change. const selObj = Object.fromEntries( Object.entries(selections).map(([f, set]) => [f, [...set]])) const d = await listRFCs(pid, cid, { selections: selObj, malformed: malformedOnly }) setRfcs(d.items); setFacets(d.facets || {}) } catch (e) { showToast({ summary: e.message || 'Bulk update failed', category: 'error' }) } } return ( ) }