Skip to content

feat(web): select assignments and act on them in bulk - #769

Open
graphics80 wants to merge 5 commits into
foundation50:mainfrom
graphics80:feat/assignments-bulk-actions
Open

feat(web): select assignments and act on them in bulk#769
graphics80 wants to merge 5 commits into
foundation50:mainfrom
graphics80:feat/assignments-bulk-actions

Conversation

@graphics80

Copy link
Copy Markdown
Contributor

Summary

Every assignment action on /$org/$classroom/assignments is single-target. The
manage modal (#745) gathers seven of them behind one row button, but each still
applies to exactly the assignment whose row you opened. The work that fills a
teacher's term is not single-target:

  • End of term — lock every assignment in the classroom. Today: open the
    manage modal, Lock, confirm, close. Times twelve.
  • New term — copy last year's assignments into the new classroom. Twelve
    round-trips through the reuse modal, each re-picking the same target
    classroom.
  • Cleaning up a trial classroom — delete the handful of assignments that
    were only ever scaffolding.

The roster and org-members pages already solved this shape. The assignments
table was the one list of comparable size without it.

Ticking a row turns the table header into a bulk bar — count on the left,
actions on the right, lined up over the per-row action icons.

Bulk actions on the assignments list

Shift-click fills a range; the header checkbox takes everything the current
search and filters show. A selection survives searching and filtering — a row
the search hides stays selected and is still acted on — so the count always
states what the next action really covers.

Which actions, and why only these

Action Why it qualifies
Lock / Unlock Reversible, idempotent, the archetypal end-of-term sweep
Delete Metadata-only (drops the assignments.json entry; student repositories are untouched), so a batch is well-defined
Reuse The biggest time-saver; slug collisions already resolve exactly as the single-assignment modal resolves them

Left out deliberately: Edit and View settings navigate to one page and
have no plural form, and Template access opens a diagnostic modal — a bulk
"re-grant staff read" is a different action, not the plural of that one.

One commit per action, not one per assignment

Lock, unlock and delete are batched in the domain layer: one read, one tree, one
commit for the whole selection. Looping the single-assignment writers would have
meant N commits to one file, each serialized on the ref the previous one just
moved, and each its own conflict-retry candidate. The batch makes a half-applied
selection impossible — every selected assignment changes, or none does — and
leaves the classroom's history with one entry per user action.

Delete asks you to type the word first, since it is the only action here with no
undo in the app.

Confirming a bulk lock

Reuse is the exception and cannot batch: each copy writes the target
classroom's assignments.json on the same ref and may create a repository. So
it runs sequentially, shows progress through the shared bulk progress block
(#758), and reports a per-assignment outcome — a single "done" would hide which
of twelve copies actually landed.

Reuse shows every slug before it copies

Pick a target and a slug field appears per assignment, pre-filled with the slug
that copy would take. Where the slug is already used in the target, the numbered
suffix is filled in — hw1 into a classroom that has one gives hw1-2 — and
any of them can be overwritten before the run starts.

Choosing target and slugs for a bulk reuse

A field blocks the run when its slug is taken in the target, reserved by a
renamed assignment, over the classroom's repository-name budget (#691), or
collides with another copy in the same run. The same rules the single-assignment
reuse applies, once per row, resolved against each other rather than one at a
time.

Shared rather than forked

pages/orgMembers/selection.ts and useRangeSelection.ts move to
util/rowSelection.ts and hooks/useRangeSelection.ts now that a third table
uses them, and gained an explicit keyOf so a row keyed on something other than
.key (Assignment.slug) is passed straight through instead of being mapped
into a throwaway array on every render. The org-members and roster call sites are
updated; behaviour there is unchanged.

Two things worth a reviewer's eye

The bar sits inside the table header, so both its ends are sticky: below
roughly 1400px the table's eight columns overflow and scroll, and without the
pins the right-aligned actions rode out past the visible edge — the count
readable, every button unreachable. Measured in Chromium at 1200/1300/1440/1600/
1800px; there is a browser-mode test that scrolls a real table and asserts the
controls stayed in the scrollport.

Modal's box now resets white-space/overflow-wrap. A <dialog> inherits
from wherever its trigger is mounted, and daisyUI sets
.table :where(thead,tfoot){white-space:nowrap} — so a dialog opened from a
table header rendered every paragraph on one line and clipped it. The reset is at
the box because that is the level the problem lives at, and it survived #758's
rewrite of the modal layer untouched. modalWrap.browser.test.tsx guards it.

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Refactor / maintenance

Checklist

  • I built, tested, and linted the module(s) I touched: cd web && npm run check (web only — no Go, Python or schema change). Also python3 src/locales/audit_i18n.py --strict, npx depcruise, and the browser-mode project.
  • If I changed a cross-binary contract, I updated schemas/*.schema.json and every mirror — n/a: no schema or field change. The batched writers produce the same assignments.json shape as the single-assignment ones, including the locked omitempty collapse.
  • If I added or changed a CLI command or flag, I documented it in the wiki — n/a for the CLI, but the web behaviour is documented: wiki/Web-Teacher-Guide.md gains "Act on several assignments at once".
  • My commits follow Conventional Commits.

Lock, unlock, delete and reuse now take a selection. Lock and delete
commit the whole selection in one write to assignments.json, so a
half-applied bulk action is impossible. Reuse copies one at a time —
each copy writes the target classroom on the same ref — and reports a
per-assignment outcome, with every target slug shown and editable
before the run starts.

The row-selection helpers move out of pages/orgMembers/ into util/ and
hooks/ now that a third table uses them.
Selecting rows changes what the table header means, so the section leads
with a screenshot and spells out the two things that are not visible:
a selection outlives the search that hides it, and lock, unlock and
delete land as one commit rather than one per assignment.
The copies are sequential, so a twelve-assignment run sat on one static
line of text long enough to read as frozen. It now uses the shared bulk
progress block (foundation50#758) — spinner, bar, caption — indeterminate until the
first copy lands, since that write is the slow one.
- A duplicate slug in a hand-edited assignments.json made the selection
  prune rewrite the same state forever ("Maximum update depth"). It now
  compares the resolved key set instead of its length.
- A bulk reuse dropped copyAssignment's template-grant warning, so a
  private-template copy students cannot accept reported as a plain
  success. The warning now travels to the run's report.
- A shift-click whose anchor had been filtered out of the rendered order
  filled no range and swallowed the endpoint toggle too, so the click did
  nothing at all.
- An all-missing bulk delete announced both "already gone" and "nothing
  changed" for the same news; lock already guarded against this.
- The head-row takeover was a `th scope="col"`, announcing the bulk
  toolbar as the column header for all seven data columns.

Also replaces the overflow guard, which asserted Tailwind class strings
in a DOM that does not lay out, with a Chromium test that scrolls a real
table and measures whether the controls stayed in the scrollport.
Copilot AI lite review requested due to automatic review settings August 27, 2026 08:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds bulk selection and bulk actions to the teacher assignments list, enabling teachers to lock/unlock, reuse, and delete multiple assignments in one workflow (including batched domain-layer commits where possible), while sharing selection/range-selection utilities across tables.

Changes:

  • Introduce bulk-selection UI in the assignments table header with sticky/pinned bulk action controls (lock/unlock/reuse/delete/clear).
  • Add domain + hook support for batched lock/delete (single commit) and sequential bulk reuse with progress + per-assignment outcomes.
  • Generalize and relocate shared row-selection + shift-range selection utilities; update docs, i18n, and add regression tests (including browser-mode layout tests and modal wrapping).

Reviewed changes

Copilot reviewed 29 out of 32 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
wiki/Web-Teacher-Guide.md Documents how to use bulk actions on the assignments page.
web/src/util/rowSelection.ts Generalizes row selection helpers via keyOf for multiple table row types.
web/src/util/rowSelection.test.ts Updates selection helper tests for the new keyOf signature and import path.
web/src/util/bulkReuseSlugs.ts Adds pure slug planning/validation for bulk reuse runs (target + intra-run collision rules).
web/src/util/bulkReuseSlugs.test.ts Tests bulk reuse slug planning scenarios (taken/reserved/duplicate/budget/empty).
web/src/pages/students/EnrolledStudents.tsx Migrates roster selection/range-selection imports to shared utilities.
web/src/pages/OrgMembersPage.tsx Migrates org members selection/range-selection imports to shared utilities.
web/src/pages/AssignmentsPage.tsx Owns bulk selection state and wires bulk bar + selection props into the assignments table.
web/src/pages/AssignmentsPage.test.tsx Mocks the new bulk bar child component for page-level tests.
web/src/pages/assignments/bulkBarOverflow.browser.test.tsx Browser-mode regression test ensuring sticky bulk bar controls stay visible in horizontal scroll.
web/src/pages/assignments/AssignmentsTable.tsx Adds optional selection checkbox column + header takeover area for bulk actions and custom empty state.
web/src/pages/assignments/AssignmentsTable.test.tsx Tests header takeover/checkbox column behavior and custom empty rendering.
web/src/pages/assignments/AssignmentsBulkBar.tsx Implements bulk action bar UI + confirm flows + notifications + reuse modal entry point.
web/src/pages/assignments/AssignmentsBulkBar.test.tsx Unit tests for bulk bar selection scope, lock state gating, and selection lifetime behaviors.
web/src/locales/en.json Adds i18n strings for bulk assignment selection/actions and reuse flow messaging.
web/src/hooks/useRangeSelection.ts Generalizes shift-click range selection to use keyOf and supports assignments table usage.
web/src/hooks/useRangeSelection.test.tsx Updates range selection tests for the new keyOf parameter.
web/src/hooks/mutations/useBulkAssignmentActions.ts Adds mutations for bulk lock/delete and a stateful sequential bulk reuse runner.
web/src/hooks/mutations/useBulkAssignmentActions.test.ts Tests the bulk reuse re-entrancy latch behavior.
web/src/domain/assignments/createEdit.ts Exports reconcileLockTemplateAccess for reuse by bulk lock.
web/src/domain/assignments/bulkActions.ts Adds batched lock/delete writers + sequential bulk reuse executor in the domain layer.
web/src/domain/assignments/bulkActions.test.ts Tests batched commit behavior, missing handling, template warnings, and bulk copy outcomes/progress.
web/src/domain/assignments.ts Re-exports new bulk domain APIs/types.
web/src/components/ui/Modal.tsx Resets modal box wrapping (whitespace-normal/break-words) to avoid inherited nowrap from table headers.
web/src/components/modals/modalWrap.browser.test.tsx Browser-mode regression test ensuring modal prose wraps when opened from a table header.
web/src/components/modals/BulkReuseAssignmentsModal.tsx Adds the bulk reuse modal with per-row slug editing/validation and progress + results view.
web/src/components/modals/BulkReuseAssignmentsModal.test.tsx Unit tests for bulk reuse modal behavior (prefill, validation, submit payload, progress UI).
web/src/components/bulk/resultView.tsx Improves table cell vertical alignment for wrapped result details.
web/.gitignore Broadens screenshot ignore pattern under src/**/__screenshots__.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread web/src/pages/AssignmentsPage.tsx Outdated
Comment on lines +182 to +191
const selectedAssignments = useMemo(
() =>
resolveSelectedRows(
sourceAssignments ?? [],
selectedSlugs,
() => true,
slugKey,
),
[sourceAssignments, selectedSlugs, slugKey],
)
Comment on lines +232 to +235
// The column renders only when the page handed in selection state; a viewer
// who cannot author has no bulk action to reach, so it stays off for them.
const selectable = Boolean(selectedSlugs && onToggleRow && onToggleSelectAll)
// The header box describes the VIEW ("is everything I can see ticked"),
Comment thread web/.gitignore
# screenshots. Regenerated per run, never committed.
.vitest-attachments
src/test/__screenshots__
# Anywhere, not just src/test: a browser test may live beside the component
- A duplicate row for one slug in assignments.json resolved to two
  selected records, overstating the bulk bar's count and sending the slug
  twice to a batched write, which reconciled its template twice for one
  flag flip. The selection now resolves one row per slug.
- The checkbox column could be wired without the bulk actions, so ticking
  a row traded the column titles for an empty head cell. `bulkActions` is
  now part of the contract that enables the column.
- The screenshot ignore claimed to apply anywhere but was scoped to src/.
@rongxin-liu
rongxin-liu self-requested a review August 27, 2026 09:31
@rongxin-liu

Copy link
Copy Markdown
Contributor

@graphics80 Thanks for the contribution! We'll need to make a few changes to the app before we can incorporate this feature.

@rongxin-liu rongxin-liu added enhancement New feature or request Web Web frontend (web/) labels Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request Web Web frontend (web/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants