Files
rfc-app/frontend/src/components/Catalog.jsx
T
Ben Stull 7886840362 fix(slice5): validate raw metadata input + prune stale bulk selections
Code-review follow-ups:
- Validate the raw submitted value before apply_values coerces it, in both
  the single-edit and bulk endpoints — a scalar set onto a tags field now
  rejects (422 / rejected) instead of silently char-splitting into a list.
- Catalog prunes bulk selections to the currently-visible (filtered) entries
  after each list fetch, so the bulk bar can't act on rows the user has
  filtered out of view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:03:29 -07:00

363 lines
15 KiB
React

// 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<string> }
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 (
<aside className="catalog">
<div className="catalog-search">
<input
placeholder="Search title, slug, ID…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="catalog-controls">
<label htmlFor="catalog-sort">Sort:</label>
<select id="catalog-sort" value={sort} onChange={e => setSort(e.target.value)}>
{SORT_OPTIONS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</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>
)}
{faceted && canContribute && selected.size > 0 && (
<BulkActionBar
fields={fields}
count={selected.size}
onApply={applyBulk}
onClear={() => setSelected(new Set())}
/>
)}
<div className="catalog-list">
{filtered.length === 0 ? (
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
{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
// carries the sign-in prompt).
? (mayPropose
? 'No entries yet. Propose the first entry below.'
: 'No entries in the catalog yet.')
: 'No matches.'}
</div>
) : (
filtered.map(r => {
const isActive = slug === r.slug
const isSuper = r.state === 'super-draft'
// §22.4a SLICE-5: selectable rows in faceted mode for contributors.
const selectable = faceted && canContribute
const row = (
<Link
key={r.slug}
to={entryPath(pid, r.slug, cid)}
className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`}
>
<div className="row-top">
<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 && (
<span className="row-tags">{r.tags.join(' · ')}</span>
)}
</Link>
)
if (!selectable) return row
return (
<div key={r.slug} className="catalog-row-wrap selectable">
<input
type="checkbox"
className="row-select"
aria-label={`select ${r.title}`}
checked={selected.has(r.slug)}
onChange={() => toggleSelect(r.slug)}
/>
{row}
</div>
)
})
)}
</div>
<div className="catalog-pending">
<button className="pending-header" onClick={() => setPendingOpen(o => !o)}>
<span>{pendingOpen ? '▾' : '▸'} Pending ideas</span>
<span className="pending-count">{proposals.length}</span>
</button>
{pendingOpen && proposals.length > 0 && (
<div className="pending-list">
{proposals.map(p => (
<Link
key={p.pr_number}
to={proposalPath(pid, p.pr_number, cid)}
className={`pending-row ${String(prNumber) === String(p.pr_number) ? 'active' : ''}`}
>
<div>{p.title.replace(/^Propose:\s*/, '')}</div>
<div className="pending-by">by @{p.opened_by || '—'} · PR #{p.pr_number}</div>
</Link>
))}
</div>
)}
{pendingOpen && proposals.length === 0 && (
<div style={{ padding: '0 14px 12px', color: '#aaa', fontSize: 12 }}>
No pending proposals.
</div>
)}
</div>
<div className="catalog-footer">
{viewer ? (
// §22 S4: the propose control is offered only when the viewer may
// contribute to *this* collection (the scope-role gate); a granted
// viewer with no contribute right in this collection sees nothing.
mayPropose ? (
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New {entryNoun}</button>
) : (
// §22.8: signed in but no contribute right here — offer to join.
canRequestJoin && (
<button className="btn-propose" onClick={() => setJoinOpen(true)}>Request to join</button>
)
)
) : (
<a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose">
Sign in to propose <span className="beta-chip">Beta</span>
</a>
)}
</div>
{joinOpen && (
<JoinRequestModal
scopeType="collection"
scopeId={cid}
onClose={() => setJoinOpen(false)}
/>
)}
</aside>
)
}