From a0773ac71885adcc0676b2c79cdec41976a36b9a Mon Sep 17 00:00:00 2001 From: plind-junior <59729252+plind-junior@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:45:02 +0900 Subject: [PATCH] feat(webapp): batch-approve pending proposals from the review queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the pending queue could approve only one proposal at a time; clearing a review backlog meant opening and approving each in turn. selection checkboxes already existed but drove only page merges. show a selection checkbox on every approvable row, add a "select all" toggle and an "approve N selected" action that approves the checked rows in one pass — client-side fan-out per project, the same shape as the existing clear (reject-all) path, since there is no bulk-approve endpoint. merge stays pages-only off the same selection set. --- webapp/src/views/PendingView.multi.test.tsx | 23 +++++ webapp/src/views/PendingView.test.tsx | 12 ++- webapp/src/views/PendingView.tsx | 96 +++++++++++++++++++-- 3 files changed, 118 insertions(+), 13 deletions(-) diff --git a/webapp/src/views/PendingView.multi.test.tsx b/webapp/src/views/PendingView.multi.test.tsx index ee79c0f5..bbb293bc 100644 --- a/webapp/src/views/PendingView.multi.test.tsx +++ b/webapp/src/views/PendingView.multi.test.tsx @@ -77,3 +77,26 @@ test('approve routes to the project the proposal belongs to', async () => { const approveCalls = vi.mocked(rpc).mock.calls.filter(([, m]) => m === 'kb.approve') expect(approveCalls).toHaveLength(1) }) + +test('batch approve: select all then approve fires kb.approve once per checked row', async () => { + renderWithProviders() + await screen.findByText('claim living in project a') + await userEvent.click(screen.getByRole('checkbox', { name: /select all pending/i })) + await userEvent.click(await screen.findByRole('button', { name: /approve 2 selected/i })) + + await waitFor(() => { + const approveCalls = vi.mocked(rpc).mock.calls.filter(([, m]) => m === 'kb.approve') + expect(approveCalls).toHaveLength(2) + }) + // each approval routed to the project that owns the proposal + const approved = vi + .mocked(rpc) + .mock.calls.filter(([, m]) => m === 'kb.approve') + .map(([conn, , params]) => [conn.endpoint, (params as { proposal_id: string }).proposal_id]) + expect(approved).toEqual( + expect.arrayContaining([ + [TEST_ENDPOINT, 'prop-from-a'], + [TEST_ENDPOINT_B, 'prop-from-b'], + ]), + ) +}) diff --git a/webapp/src/views/PendingView.test.tsx b/webapp/src/views/PendingView.test.tsx index 1a7e1df3..f52b4b35 100644 --- a/webapp/src/views/PendingView.test.tsx +++ b/webapp/src/views/PendingView.test.tsx @@ -151,10 +151,10 @@ test('merge: selecting two page proposals sends kb.merge_pending and shows the m throw new Error(`unexpected ${method}`) }) renderWithProviders() - await userEvent.click(await screen.findByLabelText('select prop-page-a for merge')) + await userEvent.click(await screen.findByLabelText('select prop-page-a')) // one selection is not mergeable yet expect(screen.queryByRole('button', { name: /merge/i })).not.toBeInTheDocument() - await userEvent.click(screen.getByLabelText('select prop-page-b for merge')) + await userEvent.click(screen.getByLabelText('select prop-page-b')) await userEvent.click(screen.getByRole('button', { name: /merge 2 into one/i })) await waitFor(() => expect(rpc).toHaveBeenCalledWith(expect.anything(), 'kb.merge_pending', { @@ -164,7 +164,7 @@ test('merge: selecting two page proposals sends kb.merge_pending and shows the m expect(await screen.findByText(/merged 2 → prop-merged/i)).toBeInTheDocument() }) -test('merge checkboxes are absent when kb.merge_pending is not advertised', async () => { +test('selection checkbox drives batch-approve; merge button is absent without kb.merge_pending', async () => { const pageA = { ...PROPOSAL, id: 'prop-page-a', @@ -174,7 +174,11 @@ test('merge checkboxes are absent when kb.merge_pending is not advertised', asyn vi.mocked(rpc).mockResolvedValue([pageA]) renderWithProviders() await screen.findByText(/prop-page-a/) - expect(screen.queryByLabelText(/for merge/)).not.toBeInTheDocument() + // the row is approvable, so a selection checkbox is present for batch approve … + expect(screen.getByLabelText('select prop-page-a')).toBeInTheDocument() + // … but with kb.merge_pending unadvertised there is no merge action + await userEvent.click(screen.getByLabelText('select prop-page-a')) + expect(screen.queryByRole('button', { name: /merge/i })).not.toBeInTheDocument() }) test('approve removes the proposal from the queue before the server responds (optimistic)', async () => { diff --git a/webapp/src/views/PendingView.tsx b/webapp/src/views/PendingView.tsx index f72fdbd9..09a244d8 100644 --- a/webapp/src/views/PendingView.tsx +++ b/webapp/src/views/PendingView.tsx @@ -113,12 +113,21 @@ export function PendingView() { const clearTargets = rows.filter((r) => hasMethod('kb.reject', r.project.conn.endpoint)) const canClear = clearTargets.length > 0 // Intersect with the live queue: a proposal decided elsewhere mid-selection - // must not be sent to merge. Merge combines proposals inside ONE KB — a - // mixed-project selection cannot be merged. + // must not be acted on. Merge combines PAGES inside ONE KB; batch-approve can + // target any approvable row, across projects. const checkedRows = rows.filter((r) => checked.has(rowKey(r))) - const mergeProject = checkedRows[0]?.project ?? null - const mergeSameProject = checkedRows.every((r) => r.project === mergeProject) - const mergeIds = mergeSameProject ? checkedRows.map((r) => r.proposal.id) : [] + const mergeRows = checkedRows.filter((r) => r.proposal.kind === 'page') + const mergeProject = mergeRows[0]?.project ?? null + const mergeSameProject = mergeRows.every((r) => r.project === mergeProject) + const mergeIds = mergeSameProject ? mergeRows.map((r) => r.proposal.id) : [] + // Batch approve: every row whose endpoint advertises kb.approve is selectable; + // the action targets the checked subset. + const approvableRows = rows.filter((r) => hasMethod('kb.approve', r.project.conn.endpoint)) + const approveTargets = approvableRows.filter((r) => checked.has(rowKey(r))) + const allApprovableChecked = + approvableRows.length > 0 && approveTargets.length === approvableRows.length + const canCheck = (r: Row) => + hasMethod('kb.approve', r.project.conn.endpoint) || (canMerge && r.proposal.kind === 'page') function toggleChecked(key: string) { setChecked((prev) => { @@ -129,6 +138,19 @@ export function PendingView() { }) } + function toggleAllApprovable() { + setChecked((prev) => { + const next = new Set(prev) + const keys = approvableRows.map(rowKey) + const allOn = keys.length > 0 && keys.every((k) => next.has(k)) + for (const k of keys) { + if (allOn) next.delete(k) + else next.add(k) + } + return next + }) + } + function afterDecision() { setSelectedKey(null) setRejecting(false) @@ -229,6 +251,26 @@ export function PendingView() { }, }) + // Batch approve: approve every checked approvable row. No bulk endpoint exists — + // loop client-side per project, the same shape as clear (reject-all). + const approveSelected = useMutation({ + mutationFn: async () => { + const results = await Promise.allSettled( + approveTargets.map((r) => + rpc(r.project.conn, 'kb.approve', { proposal_id: r.proposal.id }), + ), + ) + const ok = results.filter((x) => x.status === 'fulfilled').length + return { ok, failed: results.length - ok } + }, + onError: decisionFailed, + onSuccess: ({ ok, failed }) => { + toast(failed ? 'info' : 'success', `Approved ${ok}${failed ? ` (${failed} failed)` : ''}`) + setChecked(new Set()) + afterDecision() + }, + }) + // Compile ingests ONE project's approved claims — it is offered when the // scope names a single project (use the scope switcher to pick one). const canCompile = !aggregated && !!conn && hasMethod('kb.compile') @@ -378,7 +420,43 @@ export function PendingView() { reject all {clearTargets.length} pending at once ))} - {canMerge && checkedRows.length >= 2 && ( + {approvableRows.length > 0 && ( +
+ + {approveTargets.length >= 1 && ( + <> + + + + )} +
+ )} + {canMerge && mergeRows.length >= 2 && (
{!mergeSameProject && ( pick pages from one project @@ -402,11 +480,11 @@ export function PendingView() {
    {rows.map((r) => (
  • - {canMerge && r.proposal.kind === 'page' && ( + {canCheck(r) && (