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:
@@ -152,6 +152,23 @@
|
|||||||
.row-tags { font-size: var(--text-xs); color: var(--c-gray-500); margin-top: 2px; }
|
.row-tags { font-size: var(--text-xs); color: var(--c-gray-500); margin-top: 2px; }
|
||||||
.row-malformed { font-size: var(--text-2xs); color: var(--c-warning-accent); font-weight: 600; }
|
.row-malformed { font-size: var(--text-2xs); color: var(--c-warning-accent); font-weight: 600; }
|
||||||
|
|
||||||
|
/* §22.4a SLICE-5 — row multi-select + bulk action bar (PUC-2, §5.3). */
|
||||||
|
.catalog-row-wrap { display: flex; align-items: flex-start; }
|
||||||
|
.catalog-row-wrap .row-select { margin: 10px 0 0 10px; flex: none; }
|
||||||
|
.catalog-row-wrap .catalog-row { flex: 1; min-width: 0; }
|
||||||
|
.bulk-action-bar {
|
||||||
|
position: sticky; top: 0; z-index: 2;
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
||||||
|
padding: 8px 14px; margin: 0;
|
||||||
|
background: var(--c-gray-150); border-bottom: 1px solid var(--c-gray-300);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
.bulk-action-bar .bulk-count { font-weight: 700; }
|
||||||
|
.bulk-action-bar .bulk-set { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.bulk-action-bar .bulk-tags { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.bulk-action-bar .bulk-tags input { width: 90px; }
|
||||||
|
.bulk-action-bar .bulk-clear { margin-left: auto; }
|
||||||
|
|
||||||
/* §22.4a SLICE-3 — faceted left-pane filters (§5.1). */
|
/* §22.4a SLICE-3 — faceted left-pane filters (§5.1). */
|
||||||
.facets {
|
.facets {
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
|
|||||||
@@ -696,6 +696,20 @@ export async function saveEntryMeta(projectId, collectionId, slug, values) {
|
|||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §22.4a SLICE-5 (PUC-2): apply one field op (set | add | remove) to many
|
||||||
|
// entries at once — one commit server-side; returns { applied, rejected }.
|
||||||
|
export async function bulkEntryMeta(projectId, collectionId, { slugs, op, field, value }) {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/projects/${projectId}/collections/${collectionId}/meta/bulk`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ slugs, op, field, value }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return jsonOrThrow(res)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Slice 5: §13 graduation + §13.1 claim ────────────────────────────────
|
// ── Slice 5: §13 graduation + §13.1 claim ────────────────────────────────
|
||||||
|
|
||||||
export async function claimOwnership(slug) {
|
export async function claimOwnership(slug) {
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useState } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
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 { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths'
|
||||||
import JoinRequestModal from './JoinRequestModal.jsx'
|
import JoinRequestModal from './JoinRequestModal.jsx'
|
||||||
import FacetGroups from './FacetGroups.jsx'
|
import FacetGroups from './FacetGroups.jsx'
|
||||||
|
import BulkActionBar from './BulkActionBar.jsx'
|
||||||
|
import { showToast } from './ToastHost.jsx'
|
||||||
|
|
||||||
const STATE_CHIPS = [
|
const STATE_CHIPS = [
|
||||||
{ id: 'super-draft', label: 'Super-draft' },
|
{ 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 [facets, setFacets] = useState({}) // { field: { value: count } }
|
||||||
const [selections, setSelections] = useState({}) // { field: Set<string> }
|
const [selections, setSelections] = useState({}) // { field: Set<string> }
|
||||||
const [malformedOnly, setMalformedOnly] = useState(false)
|
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 [loading, setLoading] = useState(false)
|
||||||
const { slug, prNumber } = useParams()
|
const { slug, prNumber } = useParams()
|
||||||
const pid = useProjectId()
|
const pid = useProjectId()
|
||||||
@@ -67,6 +73,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
setCanRequestJoin(false)
|
setCanRequestJoin(false)
|
||||||
setSelections({})
|
setSelections({})
|
||||||
setMalformedOnly(false)
|
setMalformedOnly(false)
|
||||||
|
setSelected(new Set())
|
||||||
getCollection(pid, cid)
|
getCollection(pid, cid)
|
||||||
.then(c => {
|
.then(c => {
|
||||||
setCanContribute(!!c?.viewer?.can_contribute)
|
setCanContribute(!!c?.viewer?.can_contribute)
|
||||||
@@ -141,6 +148,39 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
function clearFilters() { setSelections({}); setMalformedOnly(false) }
|
function clearFilters() { setSelections({}); setMalformedOnly(false) }
|
||||||
const hasActiveFilters = Object.keys(selections).length > 0 || malformedOnly
|
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 (
|
return (
|
||||||
<aside className="catalog">
|
<aside className="catalog">
|
||||||
<div className="catalog-search">
|
<div className="catalog-search">
|
||||||
@@ -181,6 +221,15 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{faceted && canContribute && selected.size > 0 && (
|
||||||
|
<BulkActionBar
|
||||||
|
fields={fields}
|
||||||
|
count={selected.size}
|
||||||
|
onApply={applyBulk}
|
||||||
|
onClear={() => setSelected(new Set())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="catalog-list">
|
<div className="catalog-list">
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
|
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
|
||||||
@@ -208,7 +257,9 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
filtered.map(r => {
|
filtered.map(r => {
|
||||||
const isActive = slug === r.slug
|
const isActive = slug === r.slug
|
||||||
const isSuper = r.state === 'super-draft'
|
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
|
<Link
|
||||||
key={r.slug}
|
key={r.slug}
|
||||||
to={entryPath(pid, r.slug, cid)}
|
to={entryPath(pid, r.slug, cid)}
|
||||||
@@ -228,6 +279,19 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import { MemoryRouter } from 'react-router-dom'
|
|||||||
// getCollection drives the field schema and listRFCs returns { items, facets }.
|
// getCollection drives the field schema and listRFCs returns { items, facets }.
|
||||||
const listRFCs = vi.fn()
|
const listRFCs = vi.fn()
|
||||||
const getCollection = vi.fn()
|
const getCollection = vi.fn()
|
||||||
|
const bulkEntryMeta = vi.fn()
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
listRFCs: (...a) => listRFCs(...a),
|
listRFCs: (...a) => listRFCs(...a),
|
||||||
listProposals: () => Promise.resolve({ items: [] }),
|
listProposals: () => Promise.resolve({ items: [] }),
|
||||||
getCollection: (...a) => getCollection(...a),
|
getCollection: (...a) => getCollection(...a),
|
||||||
|
bulkEntryMeta: (...a) => bulkEntryMeta(...a),
|
||||||
}))
|
}))
|
||||||
|
vi.mock('./ToastHost.jsx', () => ({ showToast: vi.fn() }))
|
||||||
vi.mock('../lib/entryPaths', () => ({
|
vi.mock('../lib/entryPaths', () => ({
|
||||||
entryPath: () => '/x', proposalPath: () => '/p',
|
entryPath: () => '/x', proposalPath: () => '/p',
|
||||||
useProjectId: () => 'ohm', useCollectionId: () => 'default',
|
useProjectId: () => 'ohm', useCollectionId: () => 'default',
|
||||||
@@ -22,7 +25,7 @@ const renderCatalog = () =>
|
|||||||
render(<MemoryRouter><Catalog viewer={null} onProposeRFC={() => {}} version={0} /></MemoryRouter>)
|
render(<MemoryRouter><Catalog viewer={null} onProposeRFC={() => {}} version={0} /></MemoryRouter>)
|
||||||
|
|
||||||
describe('Catalog faceted pane', () => {
|
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 () => {
|
it('renders facet groups with counts when the collection declares fields', async () => {
|
||||||
getCollection.mockResolvedValue({
|
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 () => {
|
it('renders legacy state chips when the collection declares no fields', async () => {
|
||||||
getCollection.mockResolvedValue({ fields: null, viewer: {}, entry_noun: 'RFC' })
|
getCollection.mockResolvedValue({ fields: null, viewer: {}, entry_noun: 'RFC' })
|
||||||
listRFCs.mockResolvedValue({ items: [], facets: {} })
|
listRFCs.mockResolvedValue({ items: [], facets: {} })
|
||||||
|
|||||||
Reference in New Issue
Block a user