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
+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: {} })