Skip to content

Select elections in the table and archive them in one go - #1504

Open
masiarek wants to merge 1 commit into
Equal-Vote:mainfrom
masiarek:feat/bulk-archive-elections
Open

Select elections in the table and archive them in one go#1504
masiarek wants to merge 1 commit into
Equal-Vote:mainfrom
masiarek:feat/bulk-archive-elections

Conversation

@masiarek

@masiarek masiarek commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Description

/manage lists every election you officiate, and the only way to tidy it up today is to open each one and archive it from its admin page. After a lot of testing that adds up. This adds row selection to the table plus an Actions menu in the toolbar, so you can archive a batch in one pass.

Why an Actions menu and not right-click (which #820 asks for): right-click has no touch story and is unusual on the web, and the ElectionBuddy example quoted in the issue isn't right-click either — it's "select the checkboxes, then choose Delete from the Actions menu". The MUI table this is built on already ships that pattern, so it came nearly free.

This is the archive half only. Notes on delete at the bottom.

What's in it

EnhancedTable was already carrying the skeleton of MUI's own EnhancedTable demo with the selection parts removed — a selected state destructured without its setter, a toolbar that still takes numSelected and still has the highlight styling for it, row ids still named enhanced-table-checkbox-N. Most of this reconnects what was there:

  • Selection is opt-in via new bulkActions + getRowId props. The other tables sharing this component (Open Elections, Public Archive, Elections You Voted In, Invitations, Query Tool, Voter Rolls, Election History, Upload Elections) render exactly as before.
  • The header checkbox selects the current page — "what we see on screen", per the issue. That's visibleRows, which the component already computes.
  • The checkbox cell stops click propagation. The whole <TableRow> is a link to the election, so without this, ticking a box navigates away from the list.
  • A selected row that a filter later hides is dropped from the selection, so a bulk action can never reach a row the user can no longer see.

No backend change

The client loops over the existing POST /Election/:id/archive in batches of 5. That endpoint's permission check, its expected_update_date optimistic-concurrency guard and its history entry all hang off electionsRouter.param('id', ...), so a new bulk endpoint taking an array of ids would get none of them and would have to reimplement all three. Reusing the per-election route keeps each archive a normal, fully-audited write — the DB shows each one getting a new head version with state='archived' and its predecessor demoted, exactly as a single archive does.

The trade-off is that a bulk run isn't atomic. Partial results are reported rather than rolled back, and re-running is harmless because the backend rejects an already-archived election.

Two smaller behaviours worth flagging for review:

  • Rows you don't own are filtered out client-side rather than sent and 401'd. canEditElectionState is [system_admin, owner], but this table also lists elections where you're only an admin, auditor or credentialer. Those are reported as skipped.
  • Archiving is one-way. Nothing moves an election out of archived (setOpenState refuses any state that isn't open/closed), so the confirm dialog names the count and says the action can't be undone. If an unarchive ever lands, a bulk archive becomes considerably less scary — happy to follow up with one if that's wanted.

All new strings are in en.yaml under bulk_actions, with _one/_other plurals.

One open question for reviewers. The toolbar is contextual — the Actions button only appears once a row is ticked, which is what MUI's own demo does and what Gmail-style tables do. The first person to try this build looked at the resting page and said "missing actions", then found it a moment later by ticking a box. So there's a real discoverability cost. The alternative is to always render the button and disable it until something is selected. It's a two-line change; say the word and I'll switch it.

Screenshots / Videos (frontend only)

Desktop

Three selected — the toolbar swaps the title for the count, the Actions button and a clear-selection ✕, and the header checkbox goes indeterminate:

Three elections selected

The Actions menu:

Actions menu open

Confirmation, naming the count:

Confirm dialog

After — the three are gone from the default view (the State filter already defaults to archived: false), selection cleared, one summary snackbar:

After archiving

Mobile (320px)

The toolbar fits without overflowing (measured: 288px content in a 288px box, no horizontal page scroll):

How it was tested

Local stack (postgres + keycloak + both dev servers), 11 seeded elections, driven by Playwright:

  • archived 3 → list went 11 → 8, snackbar "Archived 3 elections."
  • DB confirms each archived election got a new head row with state='archived', predecessor demoted to head=false
  • select-all-on-page ticked 8/8; the clear ✕ restored the title and zeroed the selection
  • ticking a checkbox did not navigate; clicking the row still did
  • selecting an already-archived row (via the archived filter chip) was refused client-side with a message, no request sent
  • 320px viewport: no toolbar or page overflow
  • tsc --noEmit clean

Related Issues

Part of #820.

On the mass-delete half of that issue — left out deliberately, and I think it needs a backend decision first. DELETE /Election/:id exists and is owner/system_admin gated, but nothing in the frontend calls it, and deleteElectionController calls ElectionsModel.delete(), which removes rows from electionDB only. The transactional deleteAllElectionData() right below it in Models/Elections.ts — the one that also clears ballotDB and electionRollDB — currently has no callers anywhere in the repo. So wiring a mass-delete button to today's endpoint would orphan ballots and voter rolls in bulk. Happy to open a separate issue or PR for that if you'd like it fixed; the table-side work here is method-agnostic and a Delete entry would drop straight into the same Actions menu once the backend is settled.

Addresses Equal-Vote#820's mass-processing request, archive half only.

/manage lists every election you officiate, and the only way to tidy it
is to open each one and archive it from its admin page. This adds row
selection to the table and an Actions menu in the toolbar, matching the
pattern the issue's ElectionBuddy example shows (select rows, choose
from Actions) rather than a right-click menu, which has no touch story.

EnhancedTable already carried the skeleton of MUI's demo -- a `selected`
state destructured without its setter, a toolbar taking `numSelected`
and highlighting for it, row ids named `...-checkbox-N` -- so this
mostly reconnects what was already there:

- selection is opt-in via `bulkActions` + `getRowId`, so the five other
  tables sharing this component (public archive, elections you voted in,
  invitations, ...) are untouched
- the header checkbox selects the current page, which is what the issue
  asks for and what `visibleRows` already computes
- the checkbox cell stops click propagation: the whole row is a link to
  the election, so without it ticking a box navigates away
- a selected row that a filter later hides is dropped from the
  selection, so an action can never reach a row the user can't see

No backend change. The client loops over the existing per-election
POST /Election/:id/archive in batches of 5, which keeps that endpoint's
permission check, its expected_update_date concurrency guard, and its
history entry -- all of which hang off `router.param('id', ...)` and
would have to be reimplemented inside a bulk endpoint. The trade is that
a bulk run isn't atomic, so partial results are reported instead of
rolled back; re-running is harmless because the backend rejects an
already-archived election.

Archiving is one-way (nothing moves an election out of 'archived'), so
the confirm dialog names the count and says so. Rows you don't own are
filtered out client-side rather than sent and 401'd -- canEditElectionState
is owner/system_admin, but this table also lists elections where you're
only an admin or auditor.

Verified against a local stack (postgres + keycloak + both dev servers)
with 11 seeded elections: archived 3 of them, list went 11 -> 8, each
one got a new head version with state='archived' and its predecessor
demoted, select-all-on-page ticked 8/8, ticking a box didn't navigate
while clicking the row still did, and selecting an already-archived row
was refused client-side without a request. tsc --noEmit passes.
@netlify

netlify Bot commented Aug 8, 2026

Copy link
Copy Markdown

Deploy Preview for bettervoting ready!

Name Link
🔨 Latest commit 468d21c
🔍 Latest deploy log https://app.netlify.com/projects/bettervoting/deploys/6a7791d5aa29e20008d13899
😎 Deploy Preview https://deploy-preview-1504--bettervoting.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@masiarek, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 34 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 12b4fe91-f2f6-4c38-8c41-226b820f474b

📥 Commits

Reviewing files that changed from the base of the PR and between 7bc75a8 and 468d21c.

📒 Files selected for processing (4)
  • packages/frontend/src/components/Elections/ElectionsYouManage.tsx
  • packages/frontend/src/components/EnhancedTable.tsx
  • packages/frontend/src/hooks/useAPI.ts
  • packages/frontend/src/i18n/en.yaml

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant