Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions webapp/src/views/PendingView.multi.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<PendingView />)
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'],
]),
)
})
12 changes: 8 additions & 4 deletions webapp/src/views/PendingView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,10 @@ test('merge: selecting two page proposals sends kb.merge_pending and shows the m
throw new Error(`unexpected ${method}`)
})
renderWithProviders(<PendingView />)
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', {
Expand All @@ -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',
Expand All @@ -174,7 +174,11 @@ test('merge checkboxes are absent when kb.merge_pending is not advertised', asyn
vi.mocked(rpc).mockResolvedValue([pageA])
renderWithProviders(<PendingView />)
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 () => {
Expand Down
96 changes: 87 additions & 9 deletions webapp/src/views/PendingView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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)
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -378,15 +420,51 @@ export function PendingView() {
<span className="text-xs text-sepia">reject all {clearTargets.length} pending at once</span>
</div>
))}
{canMerge && checkedRows.length >= 2 && (
{approvableRows.length > 0 && (
<div className="flex items-center gap-3 border-b border-rule bg-paper-2 px-4 py-2.5">
<label className="flex cursor-pointer items-center gap-2 text-xs text-ink-2">
<input
type="checkbox"
aria-label="select all pending"
checked={allApprovableChecked}
onChange={toggleAllApprovable}
className="accent-accent"
/>
select all
</label>
{approveTargets.length >= 1 && (
<>
<button
onClick={() => approveSelected.mutate()}
disabled={approveSelected.isPending}
className="flex items-center gap-2 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-paper transition hover:bg-accent-2 disabled:opacity-40"
>
{approveSelected.isPending ? (
<LoaderCircle size={13} className="animate-spin" />
) : (
<Check size={13} />
)}
Approve {approveTargets.length} selected
</button>
<button
onClick={() => setChecked(new Set())}
className="text-xs text-sepia transition hover:text-ink"
>
clear
</button>
</>
)}
</div>
)}
{canMerge && mergeRows.length >= 2 && (
<div className="flex items-center gap-3 border-b border-rule bg-paper-2 px-4 py-2.5">
<button
onClick={() => merge.mutate({ project: mergeProject!, ids: mergeIds })}
disabled={merge.isPending || mergeIds.length < 2}
title={mergeSameProject ? undefined : 'merge combines proposals within one project'}
className="flex items-center gap-2 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-paper transition hover:bg-accent-2 disabled:opacity-40"
>
<Merge size={13} /> Merge {checkedRows.length} into one
<Merge size={13} /> Merge {mergeRows.length} into one
</button>
{!mergeSameProject && (
<span className="text-xs text-accent-2">pick pages from one project</span>
Expand All @@ -402,11 +480,11 @@ export function PendingView() {
<ul className="min-h-0 flex-1 overflow-y-auto">
{rows.map((r) => (
<li key={rowKey(r)} className="flex items-stretch border-b border-rule/60">
{canMerge && r.proposal.kind === 'page' && (
{canCheck(r) && (
<label className="flex shrink-0 cursor-pointer items-start py-5 pl-4">
<input
type="checkbox"
aria-label={`select ${r.proposal.id} for merge`}
aria-label={`select ${r.proposal.id}`}
checked={checked.has(rowKey(r))}
onChange={() => toggleChecked(rowKey(r))}
className="accent-accent"
Expand Down
Loading