From edbf68909a12b9477d2bc6946c7b360e0f5e7d45 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 7 Jun 2026 20:51:48 -0700 Subject: [PATCH] =?UTF-8?q?feat(slice5):=20catalog=20multi-select=20+=20bu?= =?UTF-8?q?lk=20action=20bar=20(=C2=A722.4a=20PUC-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/App.css | 17 +++++ frontend/src/api.js | 14 ++++ frontend/src/components/BulkActionBar.jsx | 63 +++++++++++++++++ .../src/components/BulkActionBar.test.jsx | 45 ++++++++++++ frontend/src/components/Catalog.jsx | 68 ++++++++++++++++++- frontend/src/components/Catalog.test.jsx | 53 ++++++++++++++- 6 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/BulkActionBar.jsx create mode 100644 frontend/src/components/BulkActionBar.test.jsx diff --git a/frontend/src/App.css b/frontend/src/App.css index 62f350b..b8901f5 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -152,6 +152,23 @@ .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; } +/* §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). */ .facets { padding: 8px 14px; diff --git a/frontend/src/api.js b/frontend/src/api.js index 2195c97..911fc63 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -696,6 +696,20 @@ export async function saveEntryMeta(projectId, collectionId, slug, values) { 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 ──────────────────────────────── export async function claimOwnership(slug) { diff --git a/frontend/src/components/BulkActionBar.jsx b/frontend/src/components/BulkActionBar.jsx new file mode 100644 index 0000000..b56077a --- /dev/null +++ b/frontend/src/components/BulkActionBar.jsx @@ -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 " 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 ( +
+ {count} selected + {entries + .filter(([, d]) => d.type === 'enum') + .map(([name, def]) => ( + + ))} + {tagField && ( +
+ setTagValue(e.target.value)} + /> + + +
+ )} + +
+ ) +} diff --git a/frontend/src/components/BulkActionBar.test.jsx b/frontend/src/components/BulkActionBar.test.jsx new file mode 100644 index 0000000..d2ecc1d --- /dev/null +++ b/frontend/src/components/BulkActionBar.test.jsx @@ -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( {}} onClear={() => {}} />) + expect(screen.getByText(/3 selected/i)).toBeInTheDocument() + }) + + it('applies a set op when an enum value is chosen', () => { + const onApply = vi.fn() + render( {}} />) + 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( {}} />) + 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( {}} />) + 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( {}} onClear={onClear} />) + fireEvent.click(screen.getByRole('button', { name: /clear/i })) + expect(onClear).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/Catalog.jsx b/frontend/src/components/Catalog.jsx index 31153e2..9e21d4f 100644 --- a/frontend/src/components/Catalog.jsx +++ b/frontend/src/components/Catalog.jsx @@ -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 } 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 (