feat(web): select assignments and act on them in bulk - #769
Open
graphics80 wants to merge 14 commits into
Open
Conversation
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.
Contributor
There was a problem hiding this comment.
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.
- 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
self-requested a review
August 27, 2026 09:31
Contributor
|
@graphics80 Thanks for the contribution! We'll need to make a few changes to the app before we can incorporate this feature. |
…-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
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Every assignment action on
/$org/$classroom/assignmentsis 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: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
assignments.jsonentry; student repositories are untouched), so a batch is well-definedLeft 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,assertSlugFreeInTargetincopyReuse.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 (
hw1into a classroom that has one giveshw1-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 existingSeparator): 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.clearSelectionreplace five per-namespace copies of the same strings.pages/orgMembers/selection.tsanduseRangeSelection.tsmove toutil/rowSelection.tsandhooks/useRangeSelection.ts, and gain an explicitkeyOfso 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.SelectAllCheckbox/Checkboxprimitives.Merged with main
Rebased onto the current
main(about a hundred commits). Adopted along the way:ConfirmModal'stone+ tailoredwarningslot (#886), the funnel roster / include-staff props on the table (#860),TableShellin the shared result view, and the keep-tab-open convention (#891): the batched lock declaresmeta: { keepTabOpen: true }, the batched reuse declares it too (one commit, then a grant per copy), and the single-commit delete stays unflagged per thehooks/mutations/README.mdrule.Hardening after review
A multi-lens review of the branch turned up no blocking defects; the changes it prompted:
assignments.json, so it now batches like lock and delete and all of that machinery is gone.planBulkReuseSlugsusesObject.hasOwn: an assignment sluggedconstructorused to throw inside the dialog's render.keepTabOpen.Follow-ups (not in this PR)
useRowSelectionhook. All three pages composeuseState<Set>+useRangeSelection+selectAllState+resolveSelectedRowsby 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.assignments.jsonquery key is rebuilt by hand in a dozen files onmain; agithubKeys.assignmentsFile(org, classroom)would be the single source. It would also letAssignmentsTablestop reaching foruseQueryClientdirectly.reconcileLockTemplateAccessnever throws by contract and returns a warning string, sosetAssignmentsLockcannot see a rate limit to latch on. Surfacing that needs a decision about its contract first.Type of change
Checklist
cd web && npm run check(web only; no Go, Python or schema change). Alsopython3 src/locales/audit_i18n.py --strictand the browser-mode project.schemas/*.schema.jsonand every mirror. n/a: no schema or field change. The batched writers produce the sameassignments.jsonshape as the single-assignment ones, including thelockedomitempty collapse.wiki/Web-Teacher-Guide.mdgains "Act on several assignments at once".