Repository navigation
Description
The sheet that opens from a subject's Assignments tab has a red footer button labelled Cancel (t('core.cancel'), "Cancel" / "Cancelar" / "Annuler"). Everywhere else in the app that word dismisses a dialog, but here it cancels the remote assignment: clicking it immediately sends PATCH /v1/assignments/:id with status: 'CANCELED', and the API then deletes the assignment from the gateway (assignments.service.ts:203-205). There is no confirmation step and no way to undo it. A clinician who clicks "Cancel" to close the sheet kills the link the participant was sent, and the participant can no longer complete the assignment. The button beside it, "Resend Notification", is permanently disabled with no explanation, so the only enabled action in the footer is the destructive one.
Where
apps/web/src/routes/_app/datahub/subjects/$subjectId/assignments.tsx:66-79:
<Sheet.Footer>
<Button
className="w-full text-sm"
disabled={assignment?.status !== 'OUTSTANDING'}
label={t('core.cancel')}
variant="danger"
onClick={() => onCancel(assignment!)}
/>
<Button
disabled
className="w-full text-sm whitespace-nowrap"
label={t('datahub.assignments.resendNotification')}
variant="secondary"
/>
</Sheet.Footer>and assignments.tsx:174-177, which cancels with no confirmation:
onCancel={({ id }) => {
updateAssignmentMutation.mutate({ data: { status: 'CANCELED' }, params: { id } });
setIsEditSliderOpen(false);
}}Reproduce
- With the gateway enabled, start a session and create a remote assignment for the subject.
- Open View Current Subject → Assignments and click the outstanding assignment's row.
- Click Cancel in the sheet footer, intending to close the sheet.
Actual: the sheet closes, the assignment's status becomes Canceled, and the participant's link stops working. Nothing asked for confirmation.
Expected: the button says what it does ("Cancel assignment"), clicking it opens a confirmation that states the link will stop working, and only confirming cancels it. The always-disabled "Resend Notification" button is not shown.
Tests
apps/web/src/routes/_app/datahub/subjects/$subjectId/__tests__/assignments.test.tsx:it('should ask for confirmation before cancelling an assignment, so closing the sheet cannot cancel it by accident').testing/src/specs/subject-detail.spec.ts: open an outstanding assignment, click the cancel action, dismiss the confirmation, and assert the row still reads Outstanding.
Suggested fix
Relabel the button with a specific action (e.g. "Cancel assignment" in all three languages), route onCancel through a confirmation Dialog like the archive-user flow in admin/users/index.tsx, and remove the disabled "Resend Notification" button until the feature exists.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e), and no open PR touchesassignments.tsx. It is the onlyvariant="danger"button inapps/web/src(tests and stories aside) that mutates on the first click. Every other one opens aDialogor sits inside one.On the suggested fix
apps/web/src/routes/_app/admin/groups/index.tsx:156-268is a closer model thanadmin/users: aSheetwhose footer Delete is aDialog.Trigger(:218-222) for aDialognested insideSheet.Content.core.cancelout of the whole flow. The confirmation needs a dismiss button, and "Cancel" next to "Cancel assignment" brings the ambiguity back. Use "Keep assignment" / "Cancel assignment", or acore.yes/core.nopair under a title that names the action. If "Cancel assignment" labels both the trigger and the confirm button, it is reused, so perapps/web/AGENTS.mdit goes indatahub.jsonrather than inline, with the "asignación" / "assignation" wording the sheet's other strings use.AssignmentEmailForm(assignments.tsx:57-63), which emails the link when mail is enabled. Delete the button anddatahub.assignments.resendNotification(datahub.json:28-32), which nothing else reads. Put a secondaryt('core.close')in its place, as the post-create sheet has (session/remote-assignment.tsx:67-74), so the footer has an unambiguous way out.isPendingand close on success, asDeleteRemoteAssignments.tsx:98-109and:269do. A second PATCH is not a no-op:updateById(apps/api/src/assignments/assignments.service.ts:198-209) never checks the current status, so it callsdeleteRemoteAssignmentagain, the gateway answers 404 (apps/gateway/src/routers/api.router.ts:165-167), andthrowOnError: true(apps/web/src/services/react-query.ts:5-7) sends the failure to the error boundary.Tests
assignments.test.tsx:209-215pins the one-click cancel. Replace it with two tests: the trigger opens the confirmation without callingmutate, and confirming calls it. The tests at:206and:212query{ name: 'Cancel' }, which Testing Library matches exactly, so both break on the relabel. TheuseUpdateAssignmentmock (:29) returns only{ mutate }. It needsisPending, andmutatehas to call theonSuccessit is passed for a close-on-success test.testing/src/specs/subject-detail.spec.ts:108-112fails as soon as the confirmation exists. Extend it: dismiss, assert the row still reads Outstanding and that noPATCH /v1/assignments/was sent (page.on('request'), asinstrument-completion.spec.ts:252does), then confirm and expect Canceled.SubjectAssignmentsPage.cancelButton(testing/src/pages/_app/datahub/subjects/$subjectId/assignments.page.ts:14) isgetByRole('button', { name: 'Cancel' }). Playwright matches that as a case-insensitive substring, so it would quietly match the relabelled trigger or a "Cancel" dismiss button.testing/AGENTS.mdwantsdata-testidselectors: addcancel-assignmentandconfirm-cancel-assignment.#1730 (
:128-134) and #1757 (:154-169) also edit this route and its unit test, on different lines, so they can land in any order.Separate from this issue, and worth its own: an intended cancel can lose a submission. The gateway
DELETE(api.router.ts:158-173) deletes the row,encryptedDataincluded, whatever its status. The API learns of a completion only on the next sync pass (GATEWAY_REFRESH_INTERVAL, 10 s in.env.template:56). Until then the row still reads Outstanding and Cancel is enabled, and a cancel deletes the participant's answers before they are imported.deleteBulk(assignments.service.ts:158-163) has the same window. The gateway cannot simply refuse to delete aCOMPLETErow, because the sync deletes it the same way after importing (gateway.synchronizer.ts:216).Triage note written with Claude Code. Code references point at
main@d86740e.