feat(memory): bulk-edit batch bar on All Items tab (#129) (#325)

Follow-up to #62 / PR #126 — that PR shipped the bulk-edit batch bar
in the Review tab only and deferred the symmetric bar on All Items.
This adds it.

Scope:
- New .batch-bar block in #tab-all with selectAllAll, selectedCountAll,
  and the five bulk-edit buttons (Move to category / Move to domain /
  Add tag / Remove tag / Set audience).
- renderItemCard signature: third param widened from boolean isReview
  to a 'review' | 'all' | 'browse' mode enum so the Browse tab's
  call site can explicitly suppress the row checkbox. The earlier
  iteration of this PR widened the checkbox condition without
  auditing other callers, which left the Browse tab with orphan
  checkboxes that fired updateSelectionCount('all') against an
  invisible tab. Adversarial-review fix.
- updateSelectionCount('all') toggles the *BtnAll set; renderAllItems
  resets the header checkbox + recomputes counts on every re-render so
  stale selection state can't survive a list refresh.

Approve / Reject stay scoped to Review per the issue's scope decision
— status-change actions belong with the per-row action buttons or the
keyboard workflow in Review.

Existing JS plumbing already assumed tab-aware selection
(getSelectedIds(tab), toggleSelectAll(tab), openBulkEditModal reads
currentTab); the All-items DOM and the *BtnAll ID set are the only
additions.

Tests in tests/test_admin_memory_page_all_items_batch_bar.py:
- test_admin_page_renders_all_items_batch_bar — all five button IDs
  + the select-all checkbox + the toggleSelectAll('all') callback
  are present on the rendered admin page.
- test_all_items_bar_omits_approve_reject — Review-only Approve /
  Reject IDs do not appear with the All suffix (scope guard).
- test_browse_tab_omits_row_checkbox — regression guard for the
  Browse-tab orphan checkbox: confirms the call site uses 'browse'
  mode and renderItemCard omits the checkbox markup on that branch.

Closes #129.
This commit is contained in:
ZdenekSrotyr 2026-05-15 20:05:21 +02:00 committed by GitHub
parent ff8c0f9656
commit e77f6067fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 144 additions and 12 deletions

View file

@ -10,6 +10,16 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased] ## [Unreleased]
### Added
- **Corporate Memory — bulk-edit batch bar on the All Items tab.**
Symmetric to the Review-tab bar shipped in #126; row checkboxes,
"Select all" header, and the five bulk-edit actions (Move to
category / Move to domain / Add tag / Remove tag / Set audience)
now appear on `/corporate-memory-admin` All Items as well. Approve
/ Reject stay scoped to Review per #129's scope decision (status
flips belong with the per-row actions or the keyboard workflow).
Closes #129.
## [0.54.19] — 2026-05-15 ## [0.54.19] — 2026-05-15
### Changed ### Changed

View file

@ -967,6 +967,25 @@
<!-- All Items Tab --> <!-- All Items Tab -->
<div id="tab-all" class="tab-content"> <div id="tab-all" class="tab-content">
<!-- Bulk-edit batch bar (issue #129) — mirrors Review tab's bar but
scoped to All Items selection state and without the Approve /
Reject actions (those stay in Review). The same JS plumbing
(`getSelectedIds('all')`, `updateSelectionCount('all')`,
`openBulkEditModal(...)` reading `currentTab`) already exists. -->
<div class="batch-bar">
<label>
<input type="checkbox" id="selectAllAll" onchange="toggleSelectAll('all')">
Select all
</label>
<span class="selected-count" id="selectedCountAll"></span>
<div class="batch-actions">
<button class="btn" id="batchMoveCategoryBtnAll" disabled onclick="openBulkEditModal('category')">Move to category…</button>
<button class="btn" id="batchMoveDomainBtnAll" disabled onclick="openBulkEditModal('domain')">Move to domain…</button>
<button class="btn" id="batchAddTagBtnAll" disabled onclick="openBulkEditModal('add_tag')">Add tag…</button>
<button class="btn" id="batchRemoveTagBtnAll" disabled onclick="openBulkEditModal('remove_tag')">Remove tag…</button>
<button class="btn" id="batchSetAudienceBtnAll" disabled onclick="openBulkEditModal('audience')">Set audience…</button>
</div>
</div>
<div class="filter-bar"> <div class="filter-bar">
<label for="statusFilter">Status:</label> <label for="statusFilter">Status:</label>
<select class="filter-select" id="statusFilter" onchange="loadAllItems(1)"> <select class="filter-select" id="statusFilter" onchange="loadAllItems(1)">
@ -1428,7 +1447,7 @@
<summary style="cursor: pointer; font-weight: var(--font-semibold);"> <summary style="cursor: pointer; font-weight: var(--font-semibold);">
${escapeHtml(g.label)} <span style="color: var(--text-secondary); font-weight: normal;">(${g.count})</span> ${escapeHtml(g.label)} <span style="color: var(--text-secondary); font-weight: normal;">(${g.count})</span>
</summary> </summary>
<div style="margin-top: var(--space-3);">${(g.items || []).map((it, idx) => renderItemCard(it, idx, false)).join('')}</div> <div style="margin-top: var(--space-3);">${(g.items || []).map((it, idx) => renderItemCard(it, idx, 'browse')).join('')}</div>
</details>`; </details>`;
}).join(''); }).join('');
} }
@ -1983,7 +2002,7 @@
return; return;
} }
list.innerHTML = data.items.map((item, idx) => renderItemCard(item, idx, true)).join(''); list.innerHTML = data.items.map((item, idx) => renderItemCard(item, idx, 'review')).join('');
renderPagination(paginationEl, data, 'loadReviewQueue'); renderPagination(paginationEl, data, 'loadReviewQueue');
updateSelectionCount('review'); updateSelectionCount('review');
} }
@ -2005,8 +2024,14 @@
return; return;
} }
list.innerHTML = data.items.map((item, idx) => renderItemCard(item, idx, false)).join(''); list.innerHTML = data.items.map((item, idx) => renderItemCard(item, idx, 'all')).join('');
renderPagination(paginationEl, data, 'loadAllItems'); renderPagination(paginationEl, data, 'loadAllItems');
// Reset the "Select all" header checkbox + button states after every
// re-render (issue #129); previous selection IDs no longer exist in
// the DOM after the list refreshes.
const selAll = document.getElementById('selectAllAll');
if (selAll) selAll.checked = false;
updateSelectionCount('all');
} }
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
@ -2018,7 +2043,15 @@
// from, without a fresh GET round-trip. // from, without a fresh GET round-trip.
const _itemsById = new Map(); const _itemsById = new Map();
function renderItemCard(item, idx, isReview) { // mode: 'review' | 'all' | 'browse'
// - 'review' renders the Approve / Reject action buttons; row checkbox
// drives the Review-tab bulk-edit bar (updateSelectionCount('review')).
// - 'all' renders the per-row Edit/Delete/Mandate buttons; row checkbox
// drives the All-Items-tab bulk-edit bar (updateSelectionCount('all')).
// - 'browse' omits the row checkbox entirely — Browse tab has no batch
// bar; rendering a checkbox there would leave it orphaned and
// dispatch updateSelectionCount() against an invisible tab.
function renderItemCard(item, idx, mode) {
_itemsById.set(item.id, item); _itemsById.set(item.id, item);
const status = item.status || 'pending'; const status = item.status || 'pending';
const categoryDisplay = (item.category || 'general').replace(/_/g, ' '); const categoryDisplay = (item.category || 'general').replace(/_/g, ' ');
@ -2029,7 +2062,7 @@
// Determine available actions based on current status // Determine available actions based on current status
let actionsHtml = ''; let actionsHtml = '';
if (isReview) { if (mode === 'review') {
actionsHtml = ` actionsHtml = `
<div class="item-actions"> <div class="item-actions">
<button class="btn btn-approve" onclick="adminAction('approve', '${esc(item.id)}')"> <button class="btn btn-approve" onclick="adminAction('approve', '${esc(item.id)}')">
@ -2075,7 +2108,7 @@
return ` return `
<div class="knowledge-item" data-id="${esc(item.id)}" data-idx="${idx}"> <div class="knowledge-item" data-id="${esc(item.id)}" data-idx="${idx}">
<div class="item-row"> <div class="item-row">
${isReview ? `<input type="checkbox" class="item-checkbox" data-id="${esc(item.id)}" onchange="updateSelectionCount('review')">` : ''} ${mode === 'browse' ? '' : `<input type="checkbox" class="item-checkbox" data-id="${esc(item.id)}" onchange="updateSelectionCount('${mode}')">`}
<div class="item-body"> <div class="item-body">
<div class="knowledge-header"> <div class="knowledge-header">
<div> <div>
@ -2085,7 +2118,7 @@
${item.domain ? `<span style="display:inline-block;padding:2px 8px;background:#e8f0fe;color:#1a73e8;border-radius:999px;font-size:11px;">${escapeHtml(item.domain)}</span>` : ''} ${item.domain ? `<span style="display:inline-block;padding:2px 8px;background:#e8f0fe;color:#1a73e8;border-radius:999px;font-size:11px;">${escapeHtml(item.domain)}</span>` : ''}
${item.confidence ? `<span style="display:inline-block;padding:2px 8px;background:#f0fdf4;color:#16a34a;border-radius:999px;font-size:11px;">${Math.round(item.confidence*100)}%</span>` : ''} ${item.confidence ? `<span style="display:inline-block;padding:2px 8px;background:#f0fdf4;color:#16a34a;border-radius:999px;font-size:11px;">${Math.round(item.confidence*100)}%</span>` : ''}
${item.source_type === 'user_verification' ? '<span style="display:inline-block;padding:2px 8px;background:#dbeafe;color:#1d4ed8;border-radius:999px;font-size:11px;">Verified</span>' : ''} ${item.source_type === 'user_verification' ? '<span style="display:inline-block;padding:2px 8px;background:#dbeafe;color:#1d4ed8;border-radius:999px;font-size:11px;">Verified</span>' : ''}
${!isReview ? `<span class="status-badge ${esc(status)}">${escapeHtml(status)}</span>` : ''} ${mode !== 'review' ? `<span class="status-badge ${esc(status)}">${escapeHtml(status)}</span>` : ''}
<span>Added ${escapeHtml(dateStr)}</span> <span>Added ${escapeHtml(dateStr)}</span>
</div> </div>
</div> </div>
@ -2374,15 +2407,20 @@
function updateSelectionCount(tab) { function updateSelectionCount(tab) {
const ids = getSelectedIds(tab); const ids = getSelectedIds(tab);
const countEl = document.getElementById(tab === 'review' ? 'selectedCountReview' : 'selectedCountAll'); const countEl = document.getElementById(tab === 'review' ? 'selectedCountReview' : 'selectedCountAll');
const approveBtn = document.getElementById(tab === 'review' ? 'batchApproveBtn' : 'batchApproveBtnAll'); // Approve / Reject buttons exist only in the Review tab — All Items
const rejectBtn = document.getElementById(tab === 'review' ? 'batchRejectBtn' : 'batchRejectBtnAll'); // batch bar (issue #129) deliberately omits them (status changes belong
// to per-row buttons or the keyboard workflow in Review).
const approveBtn = tab === 'review' ? document.getElementById('batchApproveBtn') : null;
const rejectBtn = tab === 'review' ? document.getElementById('batchRejectBtn') : null;
if (countEl) countEl.textContent = ids.length > 0 ? `${ids.length} selected` : ''; if (countEl) countEl.textContent = ids.length > 0 ? `${ids.length} selected` : '';
if (approveBtn) approveBtn.disabled = ids.length === 0; if (approveBtn) approveBtn.disabled = ids.length === 0;
if (rejectBtn) rejectBtn.disabled = ids.length === 0; if (rejectBtn) rejectBtn.disabled = ids.length === 0;
// Bulk-edit buttons (issue #62) — enable when at least one item ticked. // Bulk-edit buttons (issue #62 + #129) — Review tab uses bare IDs;
['batchMoveCategoryBtn', 'batchMoveDomainBtn', 'batchAddTagBtn', 'batchRemoveTagBtn', 'batchSetAudienceBtn'].forEach(id => { // All Items tab adds the "All" suffix. Each tab toggles its own set.
const btn = document.getElementById(id); const suffix = tab === 'review' ? '' : 'All';
['batchMoveCategoryBtn', 'batchMoveDomainBtn', 'batchAddTagBtn', 'batchRemoveTagBtn', 'batchSetAudienceBtn'].forEach(base => {
const btn = document.getElementById(base + suffix);
if (btn) btn.disabled = ids.length === 0; if (btn) btn.disabled = ids.length === 0;
}); });
} }

View file

@ -0,0 +1,84 @@
"""GET /admin/corporate-memory page — All Items tab batch bar (issue #129).
Follow-up to #62 / PR #126 which shipped the bulk-edit batch bar in the
Review tab only. This test guards the symmetric bar on the All Items tab:
- batch-bar block visible on page render (regardless of pending count)
- the 5 bulk-edit actions ship with distinct ``*BtnAll`` IDs so they don't
collide with the Review tab's bare-ID buttons
- Approve / Reject are intentionally absent those stay scoped to Review
per the issue's scope decision
"""
from __future__ import annotations
def _auth(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
class TestAllItemsBatchBar:
def test_admin_page_renders_all_items_batch_bar(self, seeded_app):
c = seeded_app["client"]
token = seeded_app["admin_token"]
resp = c.get("/admin/corporate-memory", headers=_auth(token))
assert resp.status_code == 200
body = resp.text
# All five bulk-edit buttons present with the All-suffix IDs the JS
# plumbing (`updateSelectionCount('all')`) toggles.
for btn_id in (
"batchMoveCategoryBtnAll",
"batchMoveDomainBtnAll",
"batchAddTagBtnAll",
"batchRemoveTagBtnAll",
"batchSetAudienceBtnAll",
):
assert f'id="{btn_id}"' in body, f"missing button id={btn_id}"
# Select-all checkbox + count span scoped to All Items.
assert 'id="selectAllAll"' in body
assert 'id="selectedCountAll"' in body
assert "toggleSelectAll('all')" in body
def test_all_items_bar_omits_approve_reject(self, seeded_app):
"""Approve / Reject are Review-only by design (issue #129 scope)."""
c = seeded_app["client"]
token = seeded_app["admin_token"]
resp = c.get("/admin/corporate-memory", headers=_auth(token))
assert resp.status_code == 200
body = resp.text
# Bare-suffix Review buttons stay; *BtnAll variants of approve/reject
# must NOT appear — otherwise the JS in updateSelectionCount('all')
# would silently enable a status-change action the All-tab UX hasn't
# signed off on.
assert 'id="batchApproveBtn"' in body # Review tab still has it
assert 'id="batchApproveBtnAll"' not in body
assert 'id="batchRejectBtnAll"' not in body
def test_browse_tab_omits_row_checkbox(self, seeded_app):
"""Regression guard for the adversarial-review finding: widening
``renderItemCard``'s checkbox gate from "Review-only" to "Review or
All" must NOT also render the checkbox in the Browse tab — Browse
has no batch bar of its own, so an orphan checkbox there would
fire ``updateSelectionCount('all')`` against an invisible tab and
confuse the UX.
The renderItemCard signature is now a ``mode`` enum
(``'review' | 'all' | 'browse'``); the Browse path passes
``'browse'`` and the function early-returns no checkbox markup
for that mode. Both invariants are checked at template level so
a future refactor can't silently regress either.
"""
c = seeded_app["client"]
token = seeded_app["admin_token"]
resp = c.get("/admin/corporate-memory", headers=_auth(token))
assert resp.status_code == 200
body = resp.text
# Browse-tab call site uses the 'browse' mode literal.
assert "renderItemCard(it, idx, 'browse')" in body
# And renderItemCard's checkbox is gated on `mode === 'browse'` so
# the input element is omitted entirely on that branch.
assert "mode === 'browse' ? ''" in body