feat(slice5): catalog multi-select + bulk action bar (§22.4a PUC-2)

Faceted catalog rows are selectable for contributors; a sticky bulk bar
(BulkActionBar) drives set/add/remove gestures from the collection fields
schema, calls bulkEntryMeta (one commit), toasts applied/skipped counts,
and re-fetches. Non-contributors and legacy (no-fields) collections see
no selection (INV-5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-07 20:51:48 -07:00
parent 282706d7ef
commit edbf68909a
6 changed files with 257 additions and 3 deletions
+66 -2
View File
@@ -8,10 +8,12 @@
import { useEffect, useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { listRFCs, listProposals, getCollection } from '../api'
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' },
@@ -51,6 +53,10 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
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()
@@ -67,6 +73,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
setCanRequestJoin(false)
setSelections({})
setMalformedOnly(false)
setSelected(new Set())
getCollection(pid, cid)
.then(c => {
setCanContribute(!!c?.viewer?.can_contribute)
@@ -141,6 +148,39 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
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">
@@ -181,6 +221,15 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
</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 }}>
@@ -208,7 +257,9 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
filtered.map(r => {
const isActive = slug === r.slug
const isSuper = r.state === 'super-draft'
return (
// §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)}
@@ -228,6 +279,19 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
)}
</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>