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