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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user