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
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react'
// §22.4a SLICE-5 (PUC-2, UX §5.3): the sticky bulk action bar shown when ≥1
// catalog row is selected. Driven by the collection `fields:` schema — one
// "Set <field>" control per enum field, and an Add/Remove tag control per
// tags field. Each gesture calls onApply({ op, field, value }); the parent
// (Catalog) sends one bulk request and re-fetches.
const labelFor = (name, def) =>
def?.label || name.charAt(0).toUpperCase() + name.slice(1)
export default function BulkActionBar({ fields, count, onApply, onClear }) {
const [tagValue, setTagValue] = useState('')
const entries = Object.entries(fields || {})
const tagField = entries.find(([, d]) => d.type === 'tags')
return (
<div className="bulk-action-bar">
<span className="bulk-count">{count} selected</span>
{entries
.filter(([, d]) => d.type === 'enum')
.map(([name, def]) => (
<label key={name} className="bulk-set">
<span>Set {labelFor(name, def)}</span>
<select
aria-label={`Set ${labelFor(name, def)}`}
value=""
onChange={e => {
if (e.target.value) onApply({ op: 'set', field: name, value: e.target.value })
}}
>
<option value=""></option>
{(def.values || []).map(v => <option key={v} value={v}>{v}</option>)}
</select>
</label>
))}
{tagField && (
<div className="bulk-tags">
<input
placeholder="tag…"
value={tagValue}
onChange={e => setTagValue(e.target.value)}
/>
<button
type="button"
disabled={!tagValue.trim()}
onClick={() => { onApply({ op: 'add', field: tagField[0], value: tagValue.trim() }); setTagValue('') }}
>
Add tag
</button>
<button
type="button"
disabled={!tagValue.trim()}
onClick={() => { onApply({ op: 'remove', field: tagField[0], value: tagValue.trim() }); setTagValue('') }}
>
Remove tag
</button>
</div>
)}
<button type="button" className="bulk-clear" onClick={onClear}>Clear</button>
</div>
)
}
@@ -0,0 +1,45 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import BulkActionBar from './BulkActionBar.jsx'
const FIELDS = {
priority: { type: 'enum', values: ['P0', 'P1', 'P2'], label: 'Priority' },
tags: { type: 'tags', label: 'Tags' },
}
describe('BulkActionBar', () => {
it('shows the selected count', () => {
render(<BulkActionBar fields={FIELDS} count={3} onApply={() => {}} onClear={() => {}} />)
expect(screen.getByText(/3 selected/i)).toBeInTheDocument()
})
it('applies a set op when an enum value is chosen', () => {
const onApply = vi.fn()
render(<BulkActionBar fields={FIELDS} count={2} onApply={onApply} onClear={() => {}} />)
fireEvent.change(screen.getByLabelText(/set priority/i), { target: { value: 'P0' } })
expect(onApply).toHaveBeenCalledWith({ op: 'set', field: 'priority', value: 'P0' })
})
it('applies an add-tag op', () => {
const onApply = vi.fn()
render(<BulkActionBar fields={FIELDS} count={2} onApply={onApply} onClear={() => {}} />)
fireEvent.change(screen.getByPlaceholderText(/tag…/i), { target: { value: 'checkout' } })
fireEvent.click(screen.getByRole('button', { name: /add tag/i }))
expect(onApply).toHaveBeenCalledWith({ op: 'add', field: 'tags', value: 'checkout' })
})
it('applies a remove-tag op', () => {
const onApply = vi.fn()
render(<BulkActionBar fields={FIELDS} count={2} onApply={onApply} onClear={() => {}} />)
fireEvent.change(screen.getByPlaceholderText(/tag…/i), { target: { value: 'checkout' } })
fireEvent.click(screen.getByRole('button', { name: /remove tag/i }))
expect(onApply).toHaveBeenCalledWith({ op: 'remove', field: 'tags', value: 'checkout' })
})
it('calls onClear', () => {
const onClear = vi.fn()
render(<BulkActionBar fields={FIELDS} count={2} onApply={() => {}} onClear={onClear} />)
fireEvent.click(screen.getByRole('button', { name: /clear/i }))
expect(onClear).toHaveBeenCalled()
})
})
+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>
+52 -1
View File
@@ -7,11 +7,14 @@ import { MemoryRouter } from 'react-router-dom'
// getCollection drives the field schema and listRFCs returns { items, facets }.
const listRFCs = vi.fn()
const getCollection = vi.fn()
const bulkEntryMeta = vi.fn()
vi.mock('../api', () => ({
listRFCs: (...a) => listRFCs(...a),
listProposals: () => Promise.resolve({ items: [] }),
getCollection: (...a) => getCollection(...a),
bulkEntryMeta: (...a) => bulkEntryMeta(...a),
}))
vi.mock('./ToastHost.jsx', () => ({ showToast: vi.fn() }))
vi.mock('../lib/entryPaths', () => ({
entryPath: () => '/x', proposalPath: () => '/p',
useProjectId: () => 'ohm', useCollectionId: () => 'default',
@@ -22,7 +25,7 @@ const renderCatalog = () =>
render(<MemoryRouter><Catalog viewer={null} onProposeRFC={() => {}} version={0} /></MemoryRouter>)
describe('Catalog faceted pane', () => {
beforeEach(() => { listRFCs.mockReset(); getCollection.mockReset() })
beforeEach(() => { listRFCs.mockReset(); getCollection.mockReset(); bulkEntryMeta.mockReset() })
it('renders facet groups with counts when the collection declares fields', async () => {
getCollection.mockResolvedValue({
@@ -53,6 +56,54 @@ describe('Catalog faceted pane', () => {
})
})
it('reveals the bulk action bar when a row is selected (faceted + contributor)', async () => {
getCollection.mockResolvedValue({
fields: { priority: { type: 'enum', values: ['P0', 'P1'] } },
viewer: { can_contribute: true }, entry_noun: 'RFC',
})
listRFCs.mockResolvedValue({
items: [{ slug: 'a', title: 'A', state: 'active', tags: [], starred_by_me: false }],
facets: { priority: { P0: 1 }, state: { active: 1 } },
})
renderCatalog()
const checkbox = await screen.findByLabelText(/select A/i)
fireEvent.click(checkbox)
expect(await screen.findByText(/1 selected/i)).toBeInTheDocument()
})
it('sends a bulk request and re-fetches when a set op is applied', async () => {
getCollection.mockResolvedValue({
fields: { priority: { type: 'enum', values: ['P0', 'P1'] } },
viewer: { can_contribute: true }, entry_noun: 'RFC',
})
listRFCs.mockResolvedValue({
items: [{ slug: 'a', title: 'A', state: 'active', tags: [], starred_by_me: false }],
facets: { priority: { P0: 1 }, state: { active: 1 } },
})
bulkEntryMeta.mockResolvedValue({ applied: ['a'], rejected: [], committed: true })
renderCatalog()
fireEvent.click(await screen.findByLabelText(/select A/i))
fireEvent.change(await screen.findByLabelText(/set priority/i), { target: { value: 'P1' } })
await waitFor(() => {
expect(bulkEntryMeta).toHaveBeenCalledWith('ohm', 'default',
{ slugs: ['a'], op: 'set', field: 'priority', value: 'P1' })
})
})
it('does not offer row selection for a non-contributor', async () => {
getCollection.mockResolvedValue({
fields: { priority: { type: 'enum', values: ['P0'] } },
viewer: { can_contribute: false }, entry_noun: 'RFC',
})
listRFCs.mockResolvedValue({
items: [{ slug: 'a', title: 'A', state: 'active', tags: [], starred_by_me: false }],
facets: { priority: { P0: 1 }, state: { active: 1 } },
})
renderCatalog()
await screen.findByText('A')
expect(screen.queryByLabelText(/select A/i)).not.toBeInTheDocument()
})
it('renders legacy state chips when the collection declares no fields', async () => {
getCollection.mockResolvedValue({ fields: null, viewer: {}, entry_noun: 'RFC' })
listRFCs.mockResolvedValue({ items: [], facets: {} })