feat(web): select assignments and act on them in bulk - #769
Open
graphics80 wants to merge 5 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.
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"), |
| # 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
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. |
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. Themanage 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:
manage modal, Lock, confirm, close. Times twelve.
round-trips through the reuse modal, each re-picking the same target
classroom.
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.
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 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.
Reuse is the exception and cannot batch: each copy writes the target
classroom's
assignments.jsonon the same ref and may create a repository. Soit 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 —
hw1into a classroom that has one giveshw1-2— andany 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 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.tsanduseRangeSelection.tsmove toutil/rowSelection.tsandhooks/useRangeSelection.tsnow that a third tableuses them, and gained an explicit
keyOfso a row keyed on something other than.key(Assignment.slug) is passed straight through instead of being mappedinto 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: belowroughly 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 resetswhite-space/overflow-wrap. A<dialog>inheritsfrom wherever its trigger is mounted, and daisyUI sets
.table :where(thead,tfoot){white-space:nowrap}— so a dialog opened from atable 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.tsxguards it.Type of change
Checklist
cd web && npm run check(web only — no Go, Python or schema change). Alsopython3 src/locales/audit_i18n.py --strict,npx depcruise, and 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".