Skip to content

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

Open
graphics80 wants to merge 14 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 14 commits into
foundation50:mainfrom
graphics80:feat/assignments-bulk-actions

Conversation

@graphics80

@graphics80 graphics80 commented Aug 27, 2026

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 puts the same selection cluster those two pages use into the assignments toolbar: the count, one Actions menu with text-labelled entries, and Clear selection. It takes the place of the Collect all / Include staff cluster while rows are selected, exactly as the roster's cluster takes the place of its Sync cluster.

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 resolve exactly as the single-assignment modal resolves them, and the copies land in one commit like the rest

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, delete, and reuse 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.

Reuse batches the same way, into the target classroom's assignments.json: every valid copy in one commit, then one template grant per copy. The single copy's live template re-check and authoritative slug check (reuseTemplateNeedsGrant, assertSlugFreeInTarget in copyReuse.ts) run per copy against the file as read for the write, so a copy whose slug was taken since the form loaded, or whose template is gone, is left out and reported while the others land. A template shared by several copies is probed once.

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.

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 rules live once, in util/bulkReuseSlugs: the single-assignment reuse hook now plans its one source through the same planner, so the two cannot drift. The picker lists classrooms by display name, adding the slug only where two share a name.

Shared rather than forked

Three tables now carry a multi-select, so the pieces they had each hand-rolled become primitives, and all three use them:

  • BulkSelectionCluster (components/bulk/): the count / Actions / Clear cluster. The roster and org-members bars had identical copies.
  • DropdownMenu.Item (components/ui/, beside the existing Separator): the one menu-item recipe (icon + label, red when destructive, closes the menu, ignores clicks while disabled). Replaces a dozen hand-written <li><button> blocks.
  • common.actions / common.clearSelection replace five per-namespace copies of the same strings.
  • pages/orgMembers/selection.ts and useRangeSelection.ts move to util/rowSelection.ts and hooks/useRangeSelection.ts, and gain an explicit keyOf so a row keyed on something other than .key (Assignment.slug) is passed straight through. The org-members and roster call sites are updated; behaviour there is unchanged.
  • The table's checkboxes use the existing SelectAllCheckbox / Checkbox primitives.

Merged with main

Rebased onto the current main (about a hundred commits). Adopted along the way: ConfirmModal's tone + tailored warning slot (#886), the funnel roster / include-staff props on the table (#860), TableShell in the shared result view, and the keep-tab-open convention (#891): the batched lock declares meta: { keepTabOpen: true }, the batched reuse declares it too (one commit, then a grant per copy), and the single-commit delete stays unflagged per the hooks/mutations/README.md rule.

Hardening after review

A multi-lens review of the branch turned up no blocking defects; the changes it prompted:

  • Reuse was originally a sequential loop, one commit per copy, with progress, a rate-limit deferral, and a run owned by the bar so a dismissed dialog could not orphan it. None of that was needed: a copy is one write to the target's assignments.json, so it now batches like lock and delete and all of that machinery is gone.
  • The template-access warning after a bulk lock or unlock names the affected slugs and says to run the action again, instead of reporting a count.
  • planBulkReuseSlugs uses Object.hasOwn: an assignment slugged constructor used to throw inside the dialog's render.
  • Tests for the shift-click re-anchor branch, every bulk-bar toast outcome (including the Unlock path), the render-time selection prune on removal and re-add, the batched copy (one commit, left-out copies, shared-template probe, grant after commit), and the hook's invalidation and keepTabOpen.
  • Copy and comment drift: the toast no longer says "reconciled", and comments that still described the bar living in a table head cell are reworded.

Follow-ups (not in this PR)

  • A shared useRowSelection hook. All three pages compose useState<Set> + useRangeSelection + selectAllState + resolveSelectedRows by hand. The roster and members pages carry twists (viewer-keyed memo deps, the none-selectable notice, grouping-aware shift-ranges), so folding them together is its own change.
  • The assignments.json query key is rebuilt by hand in a dozen files on main; a githubKeys.assignmentsFile(org, classroom) would be the single source. It would also let AssignmentsTable stop reaching for useQueryClient directly.
  • A rate-limit stop in the lock's template reconcile fan-out. reconcileLockTemplateAccess never throws by contract and returns a warning string, so setAssignmentsLock cannot see a rate limit to latch on. Surfacing that needs a decision about its contract first.

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 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 thread web/src/pages/assignments/AssignmentsTable.tsx Outdated
Comment thread web/.gitignore Outdated
- 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
…-actions

# Conflicts:
#	web/src/components/bulk/resultView.tsx
#	web/src/pages/AssignmentsPage.test.tsx
#	web/src/pages/AssignmentsPage.tsx
#	web/src/pages/assignments/AssignmentsTable.tsx
#	wiki/Web-Teacher-Guide.md
@rongxin-liu rongxin-liu self-assigned this Sep 7, 2026
The bulk bar now owns the reuse run, so a dismissed dialog can't orphan
copies in flight; a rate-limited copy defers the rest instead of failing
each in turn. The single-reuse hook consumes the shared slug planner.
The reuse dialog no longer owns the run, copies never create repositories,
and a rate-limited run defers the rest; the comments and the teacher guide
said otherwise. The end-of-term checklist points at the bulk lock.
rongxin-liu
rongxin-liu previously approved these changes Sep 7, 2026
The slug is appended only where two classrooms share a name, since the
name alone would not tell them apart.
A copy is one write to the target's assignments.json plus an optional
team grant, the same shape lock and delete already batch. The sequential
loop, its progress block, rate-limit deferral, and bar-owned run go.
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