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>
This commit is contained in:
@@ -0,0 +1,802 @@
|
||||
# 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/2026-06-06-configurable-collection-metadata.md) 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.py` — **modify**: 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.py` — **create**: 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.js` — **modify**: add `bulkEntryMeta(projectId, collectionId, { slugs, op, field, value })`.
|
||||
- `frontend/src/components/BulkActionBar.jsx` — **create**: the sticky bar (N selected · Set <enum> ▾ · Add/Remove tag · Clear), driven by `fields`.
|
||||
- `frontend/src/components/BulkActionBar.test.jsx` — **create**: render + interaction unit tests.
|
||||
- `frontend/src/components/Catalog.jsx` — **modify**: per-row selection checkboxes (faceted + contributor only), selection state, render `BulkActionBar`, apply handler + toast, re-fetch on success.
|
||||
- `CHANGELOG.md`, `VERSION`, `frontend/package.json` — **modify**: 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`:
|
||||
|
||||
```python
|
||||
"""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:
|
||||
|
||||
```python
|
||||
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`:
|
||||
|
||||
```python
|
||||
class BulkMetaBody(BaseModel):
|
||||
slugs: list[str]
|
||||
op: str
|
||||
field: str
|
||||
value: Any = None
|
||||
```
|
||||
|
||||
Add the pure op helper above `make_router` (module level):
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```python
|
||||
@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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```python
|
||||
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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```python
|
||||
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**
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```javascript
|
||||
// §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**
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```javascript
|
||||
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`:
|
||||
|
||||
```javascript
|
||||
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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```javascript
|
||||
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:
|
||||
|
||||
```javascript
|
||||
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.
|
||||
|
||||
2. Selection state + reset on collection/filter change:
|
||||
|
||||
```javascript
|
||||
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.
|
||||
|
||||
3. Toggle helper:
|
||||
|
||||
```javascript
|
||||
function toggleSelect(slug) {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(slug) ? next.delete(slug) : next.add(slug)
|
||||
return next
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
4. 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):
|
||||
|
||||
```jsx
|
||||
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>`.
|
||||
|
||||
5. Apply handler:
|
||||
|
||||
```javascript
|
||||
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')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
6. Render the bar above the list when `selected.size > 0`:
|
||||
|
||||
```jsx
|
||||
{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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
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.
|
||||
Reference in New Issue
Block a user