Files
rfc-app/docs/superpowers/plans/2026-06-07-slice5-bulk-metadata.md
T
Ben Stull 282706d7ef feat(slice5): POST .../meta/bulk one-commit bulk metadata edit (§22.4a PUC-2)
set/add/remove ops reusing the SLICE-4 sidecar write-through; per-entry
partial-rejection; contributor+ gated (INV-4); validated at the write
boundary. Tests cover one-commit, add/remove, partial reject, authz,
and op/field/empty guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:48:54 -07:00

32 KiB

SLICE-5 — Bulk tag/untag Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let an authorized user apply one metadata field change (set / add-tag / remove-tag) to many catalog entries at once, committed as a single git commit, with per-entry partial-rejection reported — completing PUC-2 of the Configurable Collection Metadata design (§6.4, §6.5, D7).

Architecture: A new backend endpoint POST /api/projects/{pid}/collections/{cid}/meta/bulk reuses the SLICE-4 sidecar-aware git helpers (metadata.read_entry_from_git / apply_values / write_entry_files) and the bot.commit_entry_files multi-file primitive: read each entry, apply the op, validate at the write boundary (INV-4), collect file ops for the passing entries, and commit them all in one change_files call (D7: bulk = 1 commit). Entries that fail validation or are missing are reported in rejected, others in applied. The frontend adds row multi-select + a sticky bulk action bar in Catalog.jsx (faceted, contributor+ only), driven by the collection fields: schema, calling a new bulkEntryMeta API helper and toasting partial rejections.

Tech Stack: Python / FastAPI / pytest (backend); React / Vitest (frontend); Gitea ChangeFiles for the one-commit write.


File Structure

  • backend/app/api_metadata.pymodify: add the bulk_meta route + a BulkMetaBody model alongside the existing single-edit and migrate routes. Add a small pure helper _apply_op(entry, op, field, value) (or inline) computing the new field value for set/add/remove.
  • backend/tests/test_metadata_bulk_endpoint.pycreate: endpoint tests (one commit, partial reject, authz, op validation), reusing the test_propose_vertical fake-Gitea harness like test_metadata_edit_endpoint.py.
  • frontend/src/api.jsmodify: add bulkEntryMeta(projectId, collectionId, { slugs, op, field, value }).
  • frontend/src/components/BulkActionBar.jsxcreate: the sticky bar (N selected · Set ▾ · Add/Remove tag · Clear), driven by fields.
  • frontend/src/components/BulkActionBar.test.jsxcreate: render + interaction unit tests.
  • frontend/src/components/Catalog.jsxmodify: per-row selection checkboxes (faceted + contributor only), selection state, render BulkActionBar, apply handler + toast, re-fetch on success.
  • CHANGELOG.md, VERSION, frontend/package.jsonmodify: v0.51.0 release entry + version bump.

Backend op semantics (reference for all backend tasks)

Endpoint: POST /api/projects/{pid}/collections/{cid}/meta/bulk Body: { "slugs": ["a","b"], "op": "set"|"add"|"remove", "field": "priority", "value": <any> } Response (200): { "ok": true, "applied": ["a"], "rejected": [{"slug":"b","reason":"..."}], "committed": true }

Rules:

  • Authz: auth.can_contribute_in_collection(viewer, collection_id) — else 403 (anonymous → 403). Same gate as the single-entry edit.
  • Collection-in-project: mismatch → 404. No fields: → 422. Empty slugs → 422. op not in {set,add,remove} → 422. field not in schema → 422.
  • set valid for any field; new value = value as given.
  • add / remove valid only for tags-type fields (else 422); they read the entry's current list and append / drop the single value.
  • Per entry: read via read_entry_from_git; missing .md → reject {slug, reason:"not found"}. Apply op → apply_values. Validate metadata_schema.validate(metadata_dict(new_entry), fields); problems → reject {slug, reason:"<problem messages>"}. If the resulting metadata equals the old, count as applied but emit no file op (avoid redundant writes). Otherwise collect write_entry_files(md_path, new_entry, state) ops.
  • Commit: concatenate all passing entries' file ops into one list; if non-empty, one bot.commit_entry_files(..., message="Bulk <op> <field>: N entries"); committed=true. If empty (all rejected, or all no-op) → no commit, committed=false.
  • Re-ingest: cache.refresh_meta_repo once when committed.

Task 1: Bulk endpoint — happy path, one commit

Files:

  • Modify: backend/app/api_metadata.py

  • Test: backend/tests/test_metadata_bulk_endpoint.py

  • Step 1: Write the failing test

Create backend/tests/test_metadata_bulk_endpoint.py:

"""SLICE-5 — bulk metadata edit endpoint (PUC-2, §6.4/§6.5).

Through the real API: contributor+ gating (INV-4), set/add/remove ops,
validation at the write boundary, one commit for N sidecars (D7), and
partial-rejection reporting. Reuses the fake-Gitea harness.
"""
from __future__ import annotations

import asyncio
import json

import yaml
from fastapi.testclient import TestClient

from app import cache, db, gitea as gitea_mod
from app.config import load_config

from test_propose_vertical import (  # noqa: F401 (fixtures)
    app_with_fake_gitea,
    provision_user_row,
    sign_in_as,
    tmp_env,
)

PID = "default"
CID = "default"
BASE = f"/api/projects/{PID}/collections/{CID}"


def _refresh():
    cfg = load_config()
    asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))


def _set_fields(schema):
    db.conn().execute(
        "UPDATE collections SET config_json = ? WHERE id = 'default'",
        (json.dumps({"fields": schema}),))


def _seed_legacy(fake, slug, *, state="active", **front):
    fm = {"slug": slug, "title": slug.title(), "state": state, **front}
    body = yaml.safe_dump(fm, sort_keys=False).strip()
    fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
        "content": f"---\n{body}\n---\n\nBody.\n", "sha": slug}


def _login_owner(client):
    provision_user_row(user_id=1, login="ben", role="owner")
    sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")


def _sidecar(fake, slug):
    return yaml.safe_load(
        fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.meta.yaml")]["content"])


def test_bulk_set_applies_to_all_and_one_commit(app_with_fake_gitea):
    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        _set_fields({"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}})
        _seed_legacy(fake, "a", priority="P2")
        _seed_legacy(fake, "b", priority="P1")
        _refresh()
        _login_owner(client)
        commits_before = fake.change_files_calls
        r = client.post(f"{BASE}/meta/bulk",
                        json={"slugs": ["a", "b"], "op": "set",
                              "field": "priority", "value": "P0"})
        assert r.status_code == 200, r.text
        body = r.json()
        assert set(body["applied"]) == {"a", "b"}
        assert body["rejected"] == []
        assert body["committed"] is True
        # exactly one ChangeFiles commit covered both entries (D7)
        assert fake.change_files_calls - commits_before == 1
    assert _sidecar(fake, "a")["priority"] == "P0"
    assert _sidecar(fake, "b")["priority"] == "P0"

NOTE: the test asserts fake.change_files_calls — a counter on the fake Gitea. If the fake does not already expose one, Step 3 adds it (see Task 1 Step 3a).

  • Step 2: Run test to verify it fails

Run: cd backend && python -m pytest tests/test_metadata_bulk_endpoint.py::test_bulk_set_applies_to_all_and_one_commit -v Expected: FAIL (404 — route not defined, or AttributeError on change_files_calls).

  • Step 3a: Add a commit counter to the fake Gitea (only if missing)

Inspect the fake's change_files in backend/tests/test_propose_vertical.py. If it has no call counter, add one. Find the fake class's change_files method and increment a counter:

    async def change_files(self, owner, repo, *, files, message, branch,
                           author_name=None, author_email=None):
        self.change_files_calls = getattr(self, "change_files_calls", 0) + 1
        # ... existing body unchanged ...

If a counter already exists under another name, use that name in the test instead and skip this step.

  • Step 3b: Implement the bulk route

In backend/app/api_metadata.py, add the request model near MetaEditBody:

class BulkMetaBody(BaseModel):
    slugs: list[str]
    op: str
    field: str
    value: Any = None

Add the pure op helper above make_router (module level):

def _apply_op(entry: Any, op: str, field: str, value: Any) -> Any:
    """Return the new value for `field` after applying `op` to `entry`.

    set → `value`; add/remove operate on the entry's current tags-list value
    for `field` (the route restricts add/remove to tags-type fields).
    """
    if op == "set":
        return value
    current = metadata_mod.metadata_dict(entry).get(field) or []
    if not isinstance(current, list):
        current = [current]
    if op == "add":
        return current if value in current else [*current, value]
    if op == "remove":
        return [x for x in current if x != value]
    return value  # unreachable; op validated by the route

Add the route inside make_router, after the single-entry edit_meta route:

    @router.post("/api/projects/{project_id}/collections/{collection_id}/meta/bulk")
    async def bulk_meta(
        project_id: str, collection_id: str,
        body: BulkMetaBody, request: Request,
    ) -> dict[str, Any]:
        viewer = auth.current_user(request)
        if collections_mod.project_of_collection(collection_id) != project_id:
            raise HTTPException(404, "Collection not in project")
        if not auth.can_contribute_in_collection(viewer, collection_id):
            raise HTTPException(403, "Contributor access required to edit metadata")
        col = collections_mod.get_collection(collection_id)
        fields = (col or {}).get("fields") or {}
        if not fields:
            raise HTTPException(422, "Collection declares no editable fields")
        if not body.slugs:
            raise HTTPException(422, "Provide at least one entry")
        if body.op not in ("set", "add", "remove"):
            raise HTTPException(422, f"Unknown op: {body.op}")
        if body.field not in fields:
            raise HTTPException(422, f"Unknown field: {body.field}")
        if body.op in ("add", "remove") and fields[body.field].get("type") != "tags":
            raise HTTPException(422, f"op {body.op} requires a tags field")

        org, repo = _content_repo()
        applied: list[str] = []
        rejected: list[dict[str, str]] = []
        all_ops: list[dict[str, Any]] = []
        for slug in body.slugs:
            md_path = _md_path(collection_id, slug)
            st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
            if st is None:
                rejected.append({"slug": slug, "reason": "not found"})
                continue
            new_value = _apply_op(st.entry, body.op, body.field, body.value)
            new_entry = metadata_mod.apply_values(st.entry, {body.field: new_value})
            problems = metadata_schema.validate(
                metadata_mod.metadata_dict(new_entry), fields)
            if problems:
                rejected.append({"slug": slug,
                                 "reason": "; ".join(p.message for p in problems)})
                continue
            applied.append(slug)
            if metadata_mod.metadata_dict(new_entry) != metadata_mod.metadata_dict(st.entry):
                all_ops.extend(metadata_mod.write_entry_files(md_path, new_entry, st))

        committed = False
        if all_ops:
            n = len(applied)
            msg = f"Bulk {body.op} {body.field}: {n} entr{'y' if n == 1 else 'ies'}"
            try:
                await bot.commit_entry_files(
                    viewer.as_actor(), org=org, repo=repo, files=all_ops,
                    message=msg, branch="main")
            except GiteaError as e:
                raise HTTPException(502, f"Gitea: {e.detail}")
            committed = True
            await cache.refresh_meta_repo(config, gitea)
        return {"ok": True, "applied": applied,
                "rejected": rejected, "committed": committed}

Problem exposes .message (see metadata_schema.Problem/as_dict); confirm the attribute name when implementing and adjust the join if it differs.

  • Step 4: Run test to verify it passes

Run: cd backend && python -m pytest tests/test_metadata_bulk_endpoint.py::test_bulk_set_applies_to_all_and_one_commit -v Expected: PASS

  • Step 5: Commit
git add backend/app/api_metadata.py backend/tests/test_metadata_bulk_endpoint.py backend/tests/test_propose_vertical.py
git commit -m "feat(slice5): POST .../meta/bulk one-commit bulk metadata edit (§22.4a PUC-2)"

Task 2: Bulk add/remove tags

Files:

  • Test: backend/tests/test_metadata_bulk_endpoint.py

  • Step 1: Write the failing tests

Append:

def test_bulk_add_tag(app_with_fake_gitea):
    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        _set_fields({"tags": {"type": "tags"}})
        _seed_legacy(fake, "a", tags=["x"])
        _seed_legacy(fake, "b", tags=["x", "y"])
        _refresh()
        _login_owner(client)
        r = client.post(f"{BASE}/meta/bulk",
                        json={"slugs": ["a", "b"], "op": "add",
                              "field": "tags", "value": "y"})
        assert r.status_code == 200, r.text
        assert set(r.json()["applied"]) == {"a", "b"}
    assert _sidecar(fake, "a")["tags"] == ["x", "y"]
    # idempotent: "b" already had y → unchanged, still no duplicate
    assert _sidecar(fake, "b")["tags"] == ["x", "y"] or "b" not in [
        # b may be a no-op write skip; the cache still shows ["x","y"]
    ]


def test_bulk_remove_tag(app_with_fake_gitea):
    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        _set_fields({"tags": {"type": "tags"}})
        _seed_legacy(fake, "a", tags=["x", "y"])
        _refresh()
        _login_owner(client)
        r = client.post(f"{BASE}/meta/bulk",
                        json={"slugs": ["a"], "op": "remove",
                              "field": "tags", "value": "x"})
        assert r.status_code == 200, r.text
        assert r.json()["applied"] == ["a"]
    assert _sidecar(fake, "a")["tags"] == ["y"]


def test_bulk_add_remove_requires_tags_field(app_with_fake_gitea):
    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        _set_fields({"priority": {"type": "enum", "values": ["P0"]}})
        _seed_legacy(fake, "a", priority="P0")
        _refresh()
        _login_owner(client)
        r = client.post(f"{BASE}/meta/bulk",
                        json={"slugs": ["a"], "op": "add",
                              "field": "priority", "value": "z"})
        assert r.status_code == 422, r.text

Simplify the test_bulk_add_tag "b" assertion to whatever the no-op-skip semantics produce — verify the sidecar for "b" still reads ["x", "y"] if a sidecar was written, and don't assert a sidecar exists for "b" if it was a pure no-op. Adjust after observing the first run.

  • Step 2: Run to verify they fail/pass appropriately

Run: cd backend && python -m pytest tests/test_metadata_bulk_endpoint.py -v -k "add or remove" Expected: pass for the logic implemented in Task 1; fix the test_bulk_add_tag "b" assertion to match the no-op-skip behavior actually observed.

  • Step 3: Finalize assertions

Edit the test_bulk_add_tag "b" branch to a concrete assertion based on the observed behavior (sidecar absent for a pure no-op, or present and equal to ["x","y"]).

  • Step 4: Run to verify all pass

Run: cd backend && python -m pytest tests/test_metadata_bulk_endpoint.py -v Expected: PASS

  • Step 5: Commit
git add backend/tests/test_metadata_bulk_endpoint.py
git commit -m "test(slice5): bulk add/remove tag ops + tags-field guard"

Task 3: Partial rejection, authz, op validation

Files:

  • Test: backend/tests/test_metadata_bulk_endpoint.py

  • Step 1: Write the failing tests

Append:

def test_bulk_partial_reject_invalid_value(app_with_fake_gitea):
    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        _set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
        _seed_legacy(fake, "a", priority="P1")
        _seed_legacy(fake, "missing-source", priority="P1")  # has source
        _refresh()
        _login_owner(client)
        # invalid value rejects ALL (set value is global), so use a per-entry
        # rejection: one slug exists, one does not.
        r = client.post(f"{BASE}/meta/bulk",
                        json={"slugs": ["a", "ghost"], "op": "set",
                              "field": "priority", "value": "P0"})
        assert r.status_code == 200, r.text
        body = r.json()
        assert body["applied"] == ["a"]
        assert body["rejected"] == [{"slug": "ghost", "reason": "not found"}]
        assert body["committed"] is True
    assert _sidecar(fake, "a")["priority"] == "P0"


def test_bulk_invalid_value_rejects_all_no_commit(app_with_fake_gitea):
    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        _set_fields({"priority": {"type": "enum", "values": ["P0", "P1"]}})
        _seed_legacy(fake, "a", priority="P1")
        _refresh()
        _login_owner(client)
        r = client.post(f"{BASE}/meta/bulk",
                        json={"slugs": ["a"], "op": "set",
                              "field": "priority", "value": "ZZZ"})
        assert r.status_code == 200, r.text
        assert r.json()["applied"] == []
        assert len(r.json()["rejected"]) == 1
        assert r.json()["committed"] is False
        assert ("wiggleverse", "meta", "main", "rfcs/a.meta.yaml") not in fake.files


def test_bulk_forbidden_for_anonymous(app_with_fake_gitea):
    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        _set_fields({"priority": {"type": "enum", "values": ["P0"]}})
        _seed_legacy(fake, "a", priority="P0")
        _refresh()
        r = client.post(f"{BASE}/meta/bulk",
                        json={"slugs": ["a"], "op": "set",
                              "field": "priority", "value": "P0"})
        assert r.status_code == 403, r.text


def test_bulk_unknown_field_and_op(app_with_fake_gitea):
    app, fake = app_with_fake_gitea
    with TestClient(app) as client:
        _set_fields({"priority": {"type": "enum", "values": ["P0"]}})
        _seed_legacy(fake, "a", priority="P0")
        _refresh()
        _login_owner(client)
        assert client.post(f"{BASE}/meta/bulk",
                           json={"slugs": ["a"], "op": "set",
                                 "field": "nope", "value": "P0"}).status_code == 422
        assert client.post(f"{BASE}/meta/bulk",
                           json={"slugs": ["a"], "op": "frobnicate",
                                 "field": "priority", "value": "P0"}).status_code == 422
        assert client.post(f"{BASE}/meta/bulk",
                           json={"slugs": [], "op": "set",
                                 "field": "priority", "value": "P0"}).status_code == 422
  • Step 2: Run to verify pass

Run: cd backend && python -m pytest tests/test_metadata_bulk_endpoint.py -v Expected: PASS (logic from Task 1 covers these). Fix any assertion mismatches.

  • Step 3: Commit
git add backend/tests/test_metadata_bulk_endpoint.py
git commit -m "test(slice5): bulk partial-reject, authz, validation guards"

Task 4: Frontend API helper

Files:

  • Modify: frontend/src/api.js

  • Step 1: Add the helper

After saveEntryMeta in frontend/src/api.js:

// §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)
}
  • Step 2: Commit
git add frontend/src/api.js
git commit -m "feat(slice5): bulkEntryMeta API helper"

Task 5: BulkActionBar component

Files:

  • Create: frontend/src/components/BulkActionBar.jsx

  • Test: frontend/src/components/BulkActionBar.test.jsx

  • Step 1: Write the failing test

Create frontend/src/components/BulkActionBar.test.jsx:

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('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()
  })
})
  • Step 2: Run to verify it fails

Run: cd frontend && npx vitest run src/components/BulkActionBar.test.jsx Expected: FAIL (module not found).

  • Step 3: Implement the component

Create frontend/src/components/BulkActionBar.jsx:

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>
  )
}
  • Step 4: Run to verify it passes

Run: cd frontend && npx vitest run src/components/BulkActionBar.test.jsx Expected: PASS

  • Step 5: Commit
git add frontend/src/components/BulkActionBar.jsx frontend/src/components/BulkActionBar.test.jsx
git commit -m "feat(slice5): BulkActionBar component (UX §5.3)"

Task 6: Wire selection + bulk bar into Catalog

Files:

  • Modify: frontend/src/components/Catalog.jsx

  • Test: frontend/src/components/Catalog.test.jsx

  • Step 1: Write the failing test

Add to frontend/src/components/Catalog.test.jsx a test that, with a faceted collection and a contributor viewer, selecting a row reveals the bulk bar. Inspect the existing Catalog.test.jsx mock setup first (how it mocks ../api listRFCs/getCollection) and mirror it. Skeleton:

it('reveals the bulk action bar when a row is selected (faceted + contributor)', async () => {
  // mock getCollection → { viewer: { can_contribute: true }, fields: { priority: { type: 'enum', values: ['P0','P1'] } } }
  // mock listRFCs → { items: [{ slug: 'a', title: 'A', state: 'active', tags: [] }], facets: {} }
  // render Catalog within the same providers/router the other tests use
  // click the row's selection checkbox
  // expect screen.getByText(/1 selected/i) to be in the document
})

Fill the mocks to match the file's existing pattern exactly.

  • Step 2: Run to verify it fails

Run: cd frontend && npx vitest run src/components/Catalog.test.jsx Expected: FAIL (no checkbox / no bulk bar).

  • Step 3: Implement in Catalog.jsx
  1. Imports:
import { bulkEntryMeta } from '../api'
import BulkActionBar from './BulkActionBar.jsx'
import { useToast } from '../context/...'  // match how other components toast; see below

Check how ToastHost is consumed elsewhere (e.g. grep useToast frontend/src). If there's no hook, accept an onToast/use window-level host the app already wires. If toasting is awkward here, fall back to an inline message line in the bulk bar area. Do not invent a toast system.

  1. Selection state + reset on collection/filter change:
const [selected, setSelected] = useState(() => new Set())

Clear it in the collection-change effect (the [version, pid, cid] effect) and whenever the list re-fetches: add setSelected(new Set()) alongside setSelections({}) in the collection effect, and clear it after a successful bulk apply.

  1. Toggle helper:
function toggleSelect(slug) {
  setSelected(prev => {
    const next = new Set(prev)
    next.has(slug) ? next.delete(slug) : next.add(slug)
    return next
  })
}
  1. Show a checkbox per row only in faceted mode and when canContribute. The row is a <Link>; render the checkbox as a sibling before it inside a wrapper so the checkbox click doesn't navigate (onClick={e => e.stopPropagation()} on the checkbox, and don't nest it in the Link):
filtered.map(r => {
  const isActive = slug === r.slug
  const isSuper = r.state === 'super-draft'
  const selectable = faceted && canContribute
  return (
    <div key={r.slug} className={`catalog-row-wrap ${selectable ? 'selectable' : ''}`}>
      {selectable && (
        <input
          type="checkbox"
          className="row-select"
          aria-label={`select ${r.title}`}
          checked={selected.has(r.slug)}
          onChange={() => toggleSelect(r.slug)}
        />
      )}
      <Link
        to={entryPath(pid, r.slug, cid)}
        className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`}
      >
        {/* ...existing row internals unchanged... */}
      </Link>
    </div>
  )
})

Preserve the existing row internals (row-top, row-id, malformed marker, row-title, row-tags) verbatim inside the <Link>.

  1. Apply handler:
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 })
    if (res.rejected?.length) {
      // surface which entries failed; see toast note above
      showToast?.(`${res.applied.length} updated, ${res.rejected.length} skipped`)
    }
    setSelected(new Set())
    // re-fetch the list (re-run the facet effect): bump a local nonce or
    // re-call listRFCs directly. Simplest: replicate the list fetch here.
    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?.(e.message || 'Bulk update failed')
  }
}
  1. Render the bar above the list when selected.size > 0:
{faceted && canContribute && selected.size > 0 && (
  <BulkActionBar
    fields={fields}
    count={selected.size}
    onApply={applyBulk}
    onClear={() => setSelected(new Set())}
  />
)}
  • Step 4: Run to verify it passes

Run: cd frontend && npx vitest run src/components/Catalog.test.jsx Expected: PASS

  • Step 5: Add minimal styling

Add CSS for .bulk-action-bar (sticky, visible bar) and .row-select / .catalog-row-wrap (flex row) to the catalog stylesheet. Find where .catalog-row is styled (grep -rn "catalog-row" frontend/src) and add the new rules in the same file.

  • Step 6: Commit
git add frontend/src/components/Catalog.jsx frontend/src/components/Catalog.test.jsx frontend/src/styles
git commit -m "feat(slice5): catalog row multi-select + bulk action bar wired (PUC-2)"

Task 7: Full suites green

  • Step 1: Backend

Run: cd backend && python -m pytest -q Expected: all pass (prior 640 + new bulk tests).

  • Step 2: Frontend

Run: cd frontend && npx vitest run Expected: all pass (prior 44 + new BulkActionBar + Catalog tests).

  • Step 3: Fix any regressions, then commit if anything changed.

Task 8: Version bump + changelog

Files:

  • Modify: VERSION, frontend/package.json, CHANGELOG.md

  • Step 1: Bump version to 0.51.0

Set VERSION to 0.51.0; set frontend/package.json#version to 0.51.0 (mirror rule, SPEC §20).

  • Step 2: Add the changelog entry

Prepend a ## 0.51.0 — 2026-06-07 minor entry to CHANGELOG.md describing the bulk endpoint + bulk bar, citing §22.4a SLICE-5 / PUC-2 and the design doc. No deployment upgrade steps required (additive; opt-in per collection via fields:, INV-5) — state that explicitly.

  • Step 3: Commit
git add VERSION frontend/package.json CHANGELOG.md
git commit -m "release(slice5): v0.51.0 — bulk tag/untag metadata (§22.4a PUC-2 SLICE-5)"

Self-review notes

  • Spec coverage: DoD = "multi-select + bulk bar (Task 5/6); POST …/meta/bulk applies set/add/remove as one commit (Task 1/2); partial-rejection reported (Task 3)." Tests test_bulk_one_commit ≈ Task 1, test_partial_reject ≈ Task 3 (traceability §8). INV-1/4/8 honored by reusing SLICE-4 sidecar write-through. INV-5 (no fields: → unchanged): bulk bar gated on faceted + the endpoint 422s with no fields:.
  • Type consistency: bulkEntryMeta({slugs, op, field, value}) shape matches BulkMetaBody and onApply({op, field, value}). _apply_op is the single op interpreter.
  • Open confirmations during execution: (a) metadata_schema.Problem attribute for the human message (.message vs .detail); (b) the fake Gitea commit counter name; (c) the toast mechanism in the frontend (use existing or fall back to inline). Each is called out at its task.