From e2e30f0ba975612b0bc442c9465ddcc08e9182da Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Wed, 30 Sep 2026 23:04:40 -0400 Subject: [PATCH 1/2] feat: add bulk delete assignments with sidebar navigation Add a bulk delete endpoint (POST /v1/assignments/bulk/delete) that removes outstanding and expired assignments, skipping the gateway call for already-expired ones and reporting per-item failures. On the frontend, add a Delete Remote Assignments page with a sortable data table, checkbox selection, and a confirmation dialog. The remote assignments route now has a landing page with Create/Delete action cards and mode-dependent page headers. The sidebar navigation gains nested group support so Remote Assignments expands into Create and Delete children via search params (?mode=create or ?mode=delete). NavGroup, NavButton, and Navbar all support the new search-aware active state and navigation. Other changes: "Create More" button on the wizard's done step, wider instrument column with truncation and title tooltip, boundary parse fix in the delete mutation hook. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/assignments.service.spec.ts | 43 +++ .../src/assignments/assignments.controller.ts | 26 +- .../src/assignments/assignments.service.ts | 35 ++- .../dto/delete-bulk-assignments.dto.ts | 10 + .../delete-remote-assignments.test.tsx | 153 ++++++++++ .../BulkRemoteAssignmentWizard.tsx | 12 + .../BulkRemoteAssignmentWizard/ErrorList.tsx | 2 +- .../BulkRemoteAssignmentWizard/ReviewStep.tsx | 6 +- .../BulkRemoteAssignmentWizard/SourceStep.tsx | 32 +- .../DeleteRemoteAssignments.tsx | 281 ++++++++++++++++++ .../DeleteRemoteAssignments/index.ts | 1 + .../src/components/NavButton/NavButton.tsx | 11 +- apps/web/src/components/NavGroup/NavGroup.tsx | 59 ++-- apps/web/src/components/Navbar/Navbar.tsx | 4 +- apps/web/src/hooks/useAssignmentsQuery.ts | 17 +- apps/web/src/hooks/useBulkAssignments.ts | 10 +- .../hooks/useDeleteBulkAssignmentsMutation.ts | 22 ++ apps/web/src/hooks/useNavItems.ts | 21 +- .../routes/_app/group/remote-assignments.tsx | 121 ++++++-- packages/schemas/src/assignment/assignment.ts | 15 + 20 files changed, 802 insertions(+), 79 deletions(-) create mode 100644 apps/api/src/assignments/dto/delete-bulk-assignments.dto.ts create mode 100644 apps/web/src/__tests__/delete-remote-assignments.test.tsx create mode 100644 apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx create mode 100644 apps/web/src/components/DeleteRemoteAssignments/index.ts create mode 100644 apps/web/src/hooks/useDeleteBulkAssignmentsMutation.ts diff --git a/apps/api/src/assignments/__tests__/assignments.service.spec.ts b/apps/api/src/assignments/__tests__/assignments.service.spec.ts index 9d382ffa6..e5602001c 100644 --- a/apps/api/src/assignments/__tests__/assignments.service.spec.ts +++ b/apps/api/src/assignments/__tests__/assignments.service.spec.ts @@ -353,6 +353,49 @@ describe('AssignmentsService', () => { }); }); + describe('deleteBulk', () => { + it('should delete outstanding assignments from the gateway and then from the database', async () => { + assignmentModel.findMany.mockResolvedValueOnce([ + { id: 'a-1', status: 'OUTSTANDING' }, + { id: 'a-2', status: 'OUTSTANDING' } + ]); + assignmentModel.delete.mockResolvedValue({}); + const result = await assignmentsService.deleteBulk(['a-1', 'a-2'], { ability: permissiveUser().ability }); + expect(result).toEqual({ deletedCount: 2, failedIds: [] }); + expect(gatewayService.deleteRemoteAssignment).toHaveBeenCalledTimes(2); + expect(assignmentModel.delete).toHaveBeenCalledTimes(2); + }); + + it('should skip the gateway call for expired assignments, since their links are already dead', async () => { + assignmentModel.findMany.mockResolvedValueOnce([{ id: 'a-1', status: 'EXPIRED' }]); + assignmentModel.delete.mockResolvedValue({}); + const result = await assignmentsService.deleteBulk(['a-1'], { ability: permissiveUser().ability }); + expect(result).toEqual({ deletedCount: 1, failedIds: [] }); + expect(gatewayService.deleteRemoteAssignment).not.toHaveBeenCalled(); + expect(assignmentModel.delete).toHaveBeenCalledTimes(1); + }); + + it('should report a failed assignment without stopping the rest of the batch', async () => { + assignmentModel.findMany.mockResolvedValueOnce([ + { id: 'a-1', status: 'OUTSTANDING' }, + { id: 'a-2', status: 'OUTSTANDING' } + ]); + gatewayService.deleteRemoteAssignment.mockRejectedValueOnce(new Error('gateway down')); + assignmentModel.delete.mockResolvedValue({}); + const result = await assignmentsService.deleteBulk(['a-1', 'a-2'], { ability: permissiveUser().ability }); + expect(result.deletedCount).toBe(1); + expect(result.failedIds).toEqual(['a-1']); + }); + + it('should only find assignments with OUTSTANDING or EXPIRED status', async () => { + assignmentModel.findMany.mockResolvedValueOnce([]); + await assignmentsService.deleteBulk(['a-1'], { ability: permissiveUser().ability }); + expect(assignmentModel.findMany.mock.lastCall?.[0]).toMatchObject({ + where: { id: { in: ['a-1'] }, status: { in: ['OUTSTANDING', 'EXPIRED'] } } + }); + }); + }); + describe('updateById', () => { it('should refuse an assignment the caller cannot update before deleting it on the gateway, which cannot be undone', async () => { assignmentModel.exists.mockResolvedValueOnce(false); diff --git a/apps/api/src/assignments/assignments.controller.ts b/apps/api/src/assignments/assignments.controller.ts index ce51ad6db..3d3bba314 100644 --- a/apps/api/src/assignments/assignments.controller.ts +++ b/apps/api/src/assignments/assignments.controller.ts @@ -14,7 +14,11 @@ import { } from '@nestjs/common'; import { ApiOperation } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; -import type { Assignment, BulkAssignmentPreflightResult } from '@opendatacapture/schemas/assignment'; +import type { + Assignment, + BulkAssignmentPreflightResult, + DeleteBulkAssignmentsResult +} from '@opendatacapture/schemas/assignment'; import { DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail'; import type { EmailDeliveryResult, MailTemplate } from '@opendatacapture/schemas/mail'; @@ -28,6 +32,7 @@ import { MailService } from '@/mail/mail.service'; import { AssignmentsService } from './assignments.service'; import { BulkAssignmentPreflightDto, CreateBulkAssignmentsDto } from './dto/bulk-assignment.dto'; import { CreateAssignmentDto } from './dto/create-assignment.dto'; +import { DeleteBulkAssignmentsDto } from './dto/delete-bulk-assignments.dto'; import { SendAssignmentEmailDto } from './dto/send-assignment-email.dto'; import { UpdateAssignmentDto } from './dto/update-assignment.dto'; @@ -74,11 +79,26 @@ export class AssignmentsController { return this.assignmentsService.createBulk(data, currentUser); } + @ApiOperation({ summary: 'Delete Assignments in Bulk' }) + @HttpCode(HttpStatus.OK) + @Post('bulk/delete') + @RouteAccess({ action: 'delete', subject: 'Assignment' }) + deleteBulk( + @Body() data: DeleteBulkAssignmentsDto, + @CurrentUser('ability') ability?: AppAbility + ): Promise { + return this.assignmentsService.deleteBulk(data.ids, { ability }); + } + @ApiOperation({ summary: 'Get All Assignments' }) @Get() @RouteAccess({ action: 'read', subject: 'Assignment' }) - find(@CurrentUser('ability') ability?: AppAbility, @Query('subjectId') subjectId?: string): Promise { - return this.assignmentsService.find({ subjectId }, { ability }); + find( + @CurrentUser('ability') ability?: AppAbility, + @Query('groupId') groupId?: string, + @Query('subjectId') subjectId?: string + ): Promise { + return this.assignmentsService.find({ groupId, subjectId }, { ability }); } @ApiOperation({ summary: 'Email Assignment Link' }) diff --git a/apps/api/src/assignments/assignments.service.ts b/apps/api/src/assignments/assignments.service.ts index 1328f2811..a9d15f045 100644 --- a/apps/api/src/assignments/assignments.service.ts +++ b/apps/api/src/assignments/assignments.service.ts @@ -138,13 +138,44 @@ export class AssignmentsService { return staged.map(({ assignment }) => assignment); } + async deleteBulk(ids: string[], { ability }: EntityOperationOptions = {}) { + const assignments = await this.assignmentModel.findMany({ + where: { + AND: [accessibleQuery(ability, 'delete', 'Assignment')], + id: { in: ids }, + status: { in: ['OUTSTANDING', 'EXPIRED'] } + } + }); + + const deletedIds: string[] = []; + const failedIds: string[] = []; + + for (const assignment of assignments) { + try { + if (assignment.status === 'OUTSTANDING') { + await this.gatewayService.deleteRemoteAssignment(assignment.id); + } + await this.assignmentModel.delete({ where: { id: assignment.id } }); + deletedIds.push(assignment.id); + } catch (err) { + this.loggingService.error({ + error: err, + message: `Failed to delete assignment ${assignment.id}` + }); + failedIds.push(assignment.id); + } + } + + return { deletedCount: deletedIds.length, failedIds }; + } + async find( - { subjectId }: { subjectId?: string } = {}, + { groupId, subjectId }: { groupId?: string; subjectId?: string } = {}, { ability }: EntityOperationOptions = {} ): Promise { return this.assignmentModel.findMany({ where: { - AND: [accessibleQuery(ability, 'read', 'Assignment'), { subjectId }] + AND: [accessibleQuery(ability, 'read', 'Assignment'), { groupId, subjectId }] } }); } diff --git a/apps/api/src/assignments/dto/delete-bulk-assignments.dto.ts b/apps/api/src/assignments/dto/delete-bulk-assignments.dto.ts new file mode 100644 index 000000000..e84e53ec9 --- /dev/null +++ b/apps/api/src/assignments/dto/delete-bulk-assignments.dto.ts @@ -0,0 +1,10 @@ +import { ValidationSchema } from '@douglasneuroinformatics/libnest'; +import { ApiProperty } from '@nestjs/swagger'; +import { $DeleteBulkAssignmentsData } from '@opendatacapture/schemas/assignment'; +import type { DeleteBulkAssignmentsData } from '@opendatacapture/schemas/assignment'; + +@ValidationSchema($DeleteBulkAssignmentsData) +export class DeleteBulkAssignmentsDto implements DeleteBulkAssignmentsData { + @ApiProperty() + ids: string[]; +} diff --git a/apps/web/src/__tests__/delete-remote-assignments.test.tsx b/apps/web/src/__tests__/delete-remote-assignments.test.tsx new file mode 100644 index 000000000..e08b71e97 --- /dev/null +++ b/apps/web/src/__tests__/delete-remote-assignments.test.tsx @@ -0,0 +1,153 @@ +import React from 'react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DeleteRemoteAssignments } from '@/components/DeleteRemoteAssignments'; + +import '@/services/i18n'; + +const mocks = vi.hoisted(() => ({ + assignments: [] as any[], + deleteMutate: vi.fn(), + instrumentInfo: [] as any[] +})); + +vi.mock('@/hooks/useAssignmentsQuery', () => ({ + ASSIGNMENTS_QUERY_KEY_PREFIX: 'assignments', + assignmentsQueryOptions: () => ({}), + useAssignmentsQuery: () => ({ data: mocks.assignments }) +})); + +vi.mock('@/hooks/useInstrumentInfoQuery', () => ({ + useInstrumentInfoQuery: () => ({ + data: mocks.instrumentInfo + }) +})); + +vi.mock('@/hooks/useDeleteBulkAssignmentsMutation', () => ({ + useDeleteBulkAssignmentsMutation: () => ({ + isPending: false, + mutate: mocks.deleteMutate + }) +})); + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +}; + +const renderComponent = (props: Partial> = {}) => + render(, { + wrapper + }); + +beforeEach(() => { + cleanup(); + vi.clearAllMocks(); + mocks.assignments = []; + mocks.instrumentInfo = []; +}); + +describe('DeleteRemoteAssignments', () => { + it('should show an empty state when there are no deletable assignments', () => { + mocks.assignments = [ + { + createdAt: new Date('2026-01-01'), + expiresAt: new Date('2027-01-01'), + id: 'a-1', + instrumentId: 'i-1', + status: 'COMPLETE', + subjectId: 'subject-1' + } + ]; + renderComponent(); + expect(screen.getByText(/no outstanding or expired/i)).toBeTruthy(); + }); + + it('should list outstanding and expired assignments', () => { + mocks.assignments = [ + { + createdAt: new Date('2026-01-01'), + expiresAt: new Date('2027-01-01'), + id: 'a-1', + instrumentId: 'i-1', + status: 'OUTSTANDING', + subjectId: 'subject-1' + }, + { + createdAt: new Date('2025-01-01'), + expiresAt: new Date('2025-06-01'), + id: 'a-2', + instrumentId: 'i-1', + status: 'EXPIRED', + subjectId: 'subject-2' + }, + { + createdAt: new Date('2025-01-01'), + expiresAt: new Date('2025-06-01'), + id: 'a-3', + instrumentId: 'i-1', + status: 'COMPLETE', + subjectId: 'subject-3' + } + ]; + mocks.instrumentInfo = [{ details: { title: 'Test Instrument' }, id: 'i-1' }]; + renderComponent(); + expect(screen.getAllByText('Test Instrument')).toHaveLength(2); + }); + + it('should disable the delete button when nothing is selected', () => { + mocks.assignments = [ + { + createdAt: new Date('2026-01-01'), + expiresAt: new Date('2027-01-01'), + id: 'a-1', + instrumentId: 'i-1', + status: 'OUTSTANDING', + subjectId: 'subject-1' + } + ]; + renderComponent(); + const button = screen.getByTestId('delete-selected-assignments'); + expect(button.hasAttribute('disabled')).toBe(true); + }); + + it('should open a confirmation dialog when the delete button is clicked', () => { + mocks.assignments = [ + { + createdAt: new Date('2026-01-01'), + expiresAt: new Date('2027-01-01'), + id: 'a-1', + instrumentId: 'i-1', + status: 'OUTSTANDING', + subjectId: 'subject-1' + } + ]; + renderComponent(); + const checkbox = screen.getByTestId('delete-select-assignment-a-1'); + fireEvent.click(checkbox); + fireEvent.click(screen.getByTestId('delete-selected-assignments')); + expect(screen.getByTestId('confirm-delete-assignments')).toBeTruthy(); + }); + + it('should call the delete mutation with selected ids on confirmation', () => { + mocks.assignments = [ + { + createdAt: new Date('2026-01-01'), + expiresAt: new Date('2027-01-01'), + id: 'a-1', + instrumentId: 'i-1', + status: 'OUTSTANDING', + subjectId: 'subject-1' + } + ]; + renderComponent(); + fireEvent.click(screen.getByTestId('delete-select-assignment-a-1')); + fireEvent.click(screen.getByTestId('delete-selected-assignments')); + fireEvent.click(screen.getByTestId('confirm-delete-assignments')); + expect(mocks.deleteMutate).toHaveBeenCalledTimes(1); + expect(mocks.deleteMutate.mock.lastCall?.[0]).toMatchObject({ ids: ['a-1'] }); + }); +}); diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx index b4a742ae9..3bce7b17a 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx @@ -33,6 +33,7 @@ export type BulkRemoteAssignmentWizardProps = { /** Needed to turn a pasted custom identifier back into the scoped form it is stored under. */ groupName: string; instruments: InstrumentOption[]; + onBack?: () => void; /** Group setting controlling how much of an identifier the subject picker shows. */ subjectIdDisplayLength: number; subjects: Subject[]; @@ -50,6 +51,7 @@ export const BulkRemoteAssignmentWizard = ({ groupId, groupName, instruments, + onBack, subjectIdDisplayLength, subjects }: BulkRemoteAssignmentWizardProps) => { @@ -63,6 +65,7 @@ export const BulkRemoteAssignmentWizard = ({ selectedIds={wizard.subjectIds} subjectIdDisplayLength={subjectIdDisplayLength} subjects={subjects} + onBack={onBack} onParsed={wizard.acceptParsed} onSelectedChange={wizard.setSelectedSubjectIds} onStepChange={wizard.goTo} @@ -116,6 +119,15 @@ export const BulkRemoteAssignmentWizard = ({ })} footer={ + + ); + + const footers: { [K in SourceMode]: React.ReactNode } = { + FILE: backButton ?? null, PASTE: ( - + + {backButton} + + ), SELECT: ( + {backButton} {selected.size > BULK_ASSIGNMENT_MAX_SUBJECTS && (

{t({ diff --git a/apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx b/apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx new file mode 100644 index 000000000..e3872a9c4 --- /dev/null +++ b/apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx @@ -0,0 +1,281 @@ +import React, { useState } from 'react'; + +import { toBasicISOString } from '@douglasneuroinformatics/libjs'; +import { Button, Card, Checkbox, DataTable, Dialog } from '@douglasneuroinformatics/libui/components'; +import type { TanstackTable } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { cn } from '@douglasneuroinformatics/libui/utils'; +import type { Assignment } from '@opendatacapture/schemas/assignment'; +import type { UnilingualInstrumentInfo } from '@opendatacapture/schemas/instrument'; +import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; +import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon, CircleAlertIcon, CircleDotIcon } from 'lucide-react'; + +import { useAssignmentsQuery } from '@/hooks/useAssignmentsQuery'; +import { useDeleteBulkAssignmentsMutation } from '@/hooks/useDeleteBulkAssignmentsMutation'; +import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; + +type AssignmentRow = { + createdAt: string; + expiresAt: string; + id: string; + instrument: string; + status: 'EXPIRED' | 'OUTSTANDING'; + subject: string; +}; + +const SortableHeader = ({ column, label }: { column: TanstackTable.Column; label: string }) => { + const sorted = column.getIsSorted(); + const Icon = sorted === 'asc' ? ChevronUpIcon : sorted === 'desc' ? ChevronDownIcon : ChevronsUpDownIcon; + return ( + + ); +}; + +const StatusCell = ({ status }: { status: 'EXPIRED' | 'OUTSTANDING' }) => { + const { t } = useTranslation(); + if (status === 'OUTSTANDING') { + return ( + + + {t({ en: 'Outstanding', fr: 'En attente' })} + + ); + } + return ( + + + {t({ en: 'Expired', fr: 'Expiré' })} + + ); +}; + +export type DeleteRemoteAssignmentsProps = { + groupId: string; + onBack?: () => void; + subjectIdDisplayLength: number; +}; + +export const DeleteRemoteAssignments = ({ groupId, onBack, subjectIdDisplayLength }: DeleteRemoteAssignmentsProps) => { + const { t } = useTranslation(); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + + const assignmentsQuery = useAssignmentsQuery({ params: { groupId } }); + const instrumentInfoQuery = useInstrumentInfoQuery(); + const deleteMutation = useDeleteBulkAssignmentsMutation(); + + const instrumentMap = new Map( + (instrumentInfoQuery.data ?? []).map((info) => [info.id, info]) + ); + + const deletableAssignments = (assignmentsQuery.data ?? []).filter( + (a): a is Assignment & { status: 'EXPIRED' | 'OUTSTANDING' } => a.status === 'OUTSTANDING' || a.status === 'EXPIRED' + ); + + const rows: AssignmentRow[] = deletableAssignments.map((assignment) => ({ + createdAt: toBasicISOString(assignment.createdAt), + expiresAt: toBasicISOString(assignment.expiresAt), + id: assignment.id, + instrument: instrumentMap.get(assignment.instrumentId)?.details.title ?? assignment.instrumentId, + status: assignment.status, + subject: removeSubjectIdScope(assignment.subjectId).slice(0, subjectIdDisplayLength) + })); + + const toggle = (id: string) => { + const next = new Set(selectedIds); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + setSelectedIds(next); + }; + + const toggleFiltered = (filteredRows: AssignmentRow[]) => { + const allSelected = filteredRows.length > 0 && filteredRows.every((row) => selectedIds.has(row.id)); + const next = new Set(selectedIds); + for (const row of filteredRows) { + if (allSelected) { + next.delete(row.id); + } else { + next.add(row.id); + } + } + setSelectedIds(next); + }; + + const handleDelete = () => { + deleteMutation.mutate( + { ids: [...selectedIds] }, + { + onSuccess() { + setSelectedIds(new Set()); + setIsConfirmOpen(false); + onBack?.(); + } + } + ); + }; + + return ( + +

+
+ {t({ en: 'Delete Assignments', fr: 'Supprimer des tâches' })} + + {t({ + en: 'Select outstanding or expired assignments to delete.', + fr: 'Sélectionnez les tâches en attente ou expirées à supprimer.' + })} + +
+ + {rows.length === 0 ? ( +

+ {t({ + en: 'No outstanding or expired assignments in this group.', + fr: 'Aucune tâche en attente ou expirée dans ce groupe.' + })} +

+ ) : ( + ( + toggle(row.original.id)} + /> + ), + enableSorting: false, + header: ({ table }) => { + const filteredRows = table.getFilteredRowModel().rows.map((row) => row.original); + const allFilteredSelected = + filteredRows.length > 0 && filteredRows.every((row) => selectedIds.has(row.id)); + return ( + toggleFiltered(filteredRows)} + /> + ); + }, + id: 'select', + maxSize: 40, + size: 40 + }, + { + accessorKey: 'subject', + header: ({ column }) => , + id: 'subject' + }, + { + accessorKey: 'instrument', + cell: ({ getValue }) => { + const value = getValue(); + return ( + + {value} + + ); + }, + header: ({ column }) => ( + + ), + id: 'instrument', + minSize: 400, + size: 500 + }, + { + accessorKey: 'createdAt', + header: ({ column }) => ( + + ), + id: 'createdAt', + maxSize: 110, + size: 110 + }, + { + accessorKey: 'expiresAt', + header: ({ column }) => , + id: 'expiresAt', + maxSize: 110, + size: 110 + }, + { + accessorKey: 'status', + cell: ({ getValue }) => ()} />, + header: ({ column }) => , + id: 'status', + maxSize: 130, + size: 130 + } + ]} + data={rows} + rootStyle={{ '--color-background': 'var(--color-card)' }} + onRowClick={(row) => toggle(row.id)} + /> + )} +
+
+ {onBack && ( + + )} + {rows.length > 0 && ( + + )} +
+ + + + + {t({ en: 'Delete Assignments', fr: 'Supprimer des tâches' })} + + {t({ + en: `Delete ${selectedIds.size} assignment${selectedIds.size === 1 ? '' : 's'}? This cannot be undone.`, + fr: `Supprimer ${selectedIds.size} tâche${selectedIds.size === 1 ? '' : 's'} ? Cette action est irréversible.` + })} + + + + + + + + + + ); +}; diff --git a/apps/web/src/components/DeleteRemoteAssignments/index.ts b/apps/web/src/components/DeleteRemoteAssignments/index.ts new file mode 100644 index 000000000..a52453412 --- /dev/null +++ b/apps/web/src/components/DeleteRemoteAssignments/index.ts @@ -0,0 +1 @@ +export * from './DeleteRemoteAssignments'; diff --git a/apps/web/src/components/NavButton/NavButton.tsx b/apps/web/src/components/NavButton/NavButton.tsx index 3d32dafb5..a5ae4f912 100644 --- a/apps/web/src/components/NavButton/NavButton.tsx +++ b/apps/web/src/components/NavButton/NavButton.tsx @@ -5,16 +5,17 @@ import { useNavigate } from '@tanstack/react-router'; import type { NavItem } from '@/hooks/useNavItems'; -type NavButtonProps = Omit & { +type NavButtonProps = Omit & { activeClassName?: string; className?: string; isActive: boolean; - onClick?: (path: string) => void; + onClick?: (path: string, search?: { [key: string]: string }) => void; + search?: { [key: string]: string }; url: string; }; export const NavButton = React.forwardRef(function NavButton( - { activeClassName, className, disabled, icon: Icon, isActive, label, onClick, url, ...props }, + { activeClassName, className, disabled, icon: Icon, isActive, label, onClick, search, url, ...props }, ref ) { const navigate = useNavigate(); @@ -34,9 +35,9 @@ export const NavButton = React.forwardRef(fun type="button" onClick={() => { if (onClick) { - onClick(url); + onClick(url, search); } else { - void navigate({ to: url }); + void navigate({ search: search, to: url }); } }} {...props} diff --git a/apps/web/src/components/NavGroup/NavGroup.tsx b/apps/web/src/components/NavGroup/NavGroup.tsx index 3d470eb86..687f160ac 100644 --- a/apps/web/src/components/NavGroup/NavGroup.tsx +++ b/apps/web/src/components/NavGroup/NavGroup.tsx @@ -15,7 +15,16 @@ type NavGroupProps = { icon: NavItem['icon']; items: NavItem[]; label: string; - onNavigate?: (url: string) => void; + onNavigate?: (url: string, search?: { [key: string]: string }) => void; +}; + +const isItemActive = (item: NavItem, pathname: string, searchParams: { [key: string]: unknown }): boolean => { + if (item.children) { + return item.children.some((child) => isItemActive(child, pathname, searchParams)); + } + if (item.url !== pathname) return false; + if (!item.search) return true; + return Object.entries(item.search).every(([k, v]) => String(searchParams[k]) === v); }; export const NavGroup = ({ @@ -28,7 +37,8 @@ export const NavGroup = ({ onNavigate }: NavGroupProps) => { const location = useLocation(); - const containsActive = items.some((item) => item.url === location.pathname); + const searchParams = (location.search ?? {}) as { [key: string]: unknown }; + const containsActive = items.some((item) => isItemActive(item, location.pathname, searchParams)); const [isOpen, setIsOpen] = React.useState(containsActive); React.useEffect(() => { @@ -37,7 +47,7 @@ export const NavGroup = ({ } else { setIsOpen(false); } - }, [location.pathname]); + }, [location.pathname, location.search]); return (
@@ -57,22 +67,33 @@ export const NavGroup = ({ {isOpen && (
- {/* `children` is destructured out rather than spread: NavButton renders a
)}
diff --git a/apps/web/src/components/Navbar/Navbar.tsx b/apps/web/src/components/Navbar/Navbar.tsx index 53a3c4f81..57f137bea 100644 --- a/apps/web/src/components/Navbar/Navbar.tsx +++ b/apps/web/src/components/Navbar/Navbar.tsx @@ -83,9 +83,9 @@ export const Navbar = () => { items={props.children} key={props.label} label={props.label} - onNavigate={(to) => { + onNavigate={(to, search) => { setIsOpen(false); - void navigate({ to }); + void navigate({ search: search, to }); }} /> ) : ( diff --git a/apps/web/src/hooks/useAssignmentsQuery.ts b/apps/web/src/hooks/useAssignmentsQuery.ts index 39266099a..2b41ef1ee 100644 --- a/apps/web/src/hooks/useAssignmentsQuery.ts +++ b/apps/web/src/hooks/useAssignmentsQuery.ts @@ -1,19 +1,18 @@ import { $Assignment } from '@opendatacapture/schemas/assignment'; -import { useQuery } from '@tanstack/react-query'; +import { queryOptions, useQuery } from '@tanstack/react-query'; import axios from 'axios'; export const ASSIGNMENTS_QUERY_KEY_PREFIX = 'assignments' as const; -export function useAssignmentsQuery({ params }: { params?: { subjectId?: string } }) { - return useQuery({ +export const assignmentsQueryOptions = ({ params }: { params?: { groupId?: string; subjectId?: string } } = {}) => + queryOptions({ queryFn: async () => { - const response = await axios.get('/v1/assignments', { - params: { - subjectId: params?.subjectId - } - }); + const response = await axios.get('/v1/assignments', { params }); return $Assignment.array().parse(response.data); }, - queryKey: [ASSIGNMENTS_QUERY_KEY_PREFIX, params?.subjectId] + queryKey: [ASSIGNMENTS_QUERY_KEY_PREFIX, params?.groupId, params?.subjectId] }); + +export function useAssignmentsQuery({ params }: { params?: { groupId?: string; subjectId?: string } }) { + return useQuery(assignmentsQueryOptions({ params })); } diff --git a/apps/web/src/hooks/useBulkAssignments.ts b/apps/web/src/hooks/useBulkAssignments.ts index 35753721c..dae60dd84 100644 --- a/apps/web/src/hooks/useBulkAssignments.ts +++ b/apps/web/src/hooks/useBulkAssignments.ts @@ -36,9 +36,10 @@ export function useBulkAssignmentPreflightMutation() { return useMutation({ // A refusal is a considered answer from the server, not a transient fault - retrying would // re-run the same validation and produce the same refusal. - meta: { disableDefaultErrorNotification: true }, mutationFn: async (data: BulkAssignmentPreflightData) => { - const response = await axios.post('/v1/assignments/bulk/preflight', data); + const response = await axios.post('/v1/assignments/bulk/preflight', data, { + meta: { disableDefaultErrorNotification: true } + }); return $BulkAssignmentPreflightResult.parse(response.data); }, retry: false, @@ -54,9 +55,10 @@ export function useCreateBulkAssignmentsMutation() { const queryClient = useQueryClient(); const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ - meta: { disableDefaultErrorNotification: true }, mutationFn: async (data: CreateBulkAssignmentsData) => { - const response = await axios.post('/v1/assignments/bulk', data); + const response = await axios.post('/v1/assignments/bulk', data, { + meta: { disableDefaultErrorNotification: true } + }); return $Assignment.array().parse(response.data); }, onSuccess() { diff --git a/apps/web/src/hooks/useDeleteBulkAssignmentsMutation.ts b/apps/web/src/hooks/useDeleteBulkAssignmentsMutation.ts new file mode 100644 index 000000000..36fd57892 --- /dev/null +++ b/apps/web/src/hooks/useDeleteBulkAssignmentsMutation.ts @@ -0,0 +1,22 @@ +import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks'; +import { $DeleteBulkAssignmentsResult } from '@opendatacapture/schemas/assignment'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; + +import { ASSIGNMENTS_QUERY_KEY_PREFIX } from '@/hooks/useAssignmentsQuery'; + +export function useDeleteBulkAssignmentsMutation() { + const queryClient = useQueryClient(); + const addNotification = useNotificationsStore((store) => store.addNotification); + return useMutation({ + mutationFn: async ({ ids }: { ids: string[] }) => { + const response = await axios.post('/v1/assignments/bulk/delete', { ids }); + return $DeleteBulkAssignmentsResult.parse(response.data); + }, + onSuccess(result) { + addNotification({ type: 'success' }); + void queryClient.invalidateQueries({ queryKey: [ASSIGNMENTS_QUERY_KEY_PREFIX] }); + return result; + } + }); +} diff --git a/apps/web/src/hooks/useNavItems.ts b/apps/web/src/hooks/useNavItems.ts index 34509e141..288b06295 100644 --- a/apps/web/src/hooks/useNavItems.ts +++ b/apps/web/src/hooks/useNavItems.ts @@ -12,8 +12,10 @@ import { MailIcon, PackageIcon, PaletteIcon, + PlusIcon, SendIcon, ShieldIcon, + Trash2Icon, UploadIcon, UserCogIcon, UsersIcon @@ -30,6 +32,8 @@ export type NavItem = { disabled?: boolean; icon: React.ComponentType, 'ref'>>; label: string; + /** Search params to append to the URL when navigating. */ + search?: { [key: string]: string }; /** The route to navigate to. Omitted for collapsible group headers. */ url?: string; }; @@ -111,9 +115,22 @@ export function useNavItems() { ability.can('read', 'Subject') ) { groupItems.push({ + children: [ + { + icon: PlusIcon, + label: t({ en: 'Create Assignments', fr: 'Créer des tâches' }), + search: { mode: 'create' }, + url: '/group/remote-assignments' + }, + { + icon: Trash2Icon, + label: t({ en: 'Delete Assignments', fr: 'Supprimer des tâches' }), + search: { mode: 'delete' }, + url: '/group/remote-assignments' + } + ], icon: SendIcon, - label: t({ en: 'Remote Assignments', fr: 'Tâches à distance' }), - url: '/group/remote-assignments' + label: t({ en: 'Remote Assignments', fr: 'Tâches à distance' }) }); } if (groupItems.length > 0) { diff --git a/apps/web/src/routes/_app/group/remote-assignments.tsx b/apps/web/src/routes/_app/group/remote-assignments.tsx index 34072ac47..c56fd5d27 100644 --- a/apps/web/src/routes/_app/group/remote-assignments.tsx +++ b/apps/web/src/routes/_app/group/remote-assignments.tsx @@ -1,11 +1,13 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { toBasicISOString } from '@douglasneuroinformatics/libjs'; -import { Heading } from '@douglasneuroinformatics/libui/components'; +import { Card, Heading } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { createFileRoute, redirect } from '@tanstack/react-router'; +import { PlusIcon, SendIcon, Trash2Icon } from 'lucide-react'; import { BulkRemoteAssignmentWizard } from '@/components/BulkRemoteAssignmentWizard'; +import { DeleteRemoteAssignments } from '@/components/DeleteRemoteAssignments'; import { PageHeader } from '@/components/PageHeader'; import { config } from '@/config'; import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; @@ -14,19 +16,64 @@ import { subjectsQueryOptions, useSubjectsQuery } from '@/hooks/useSubjectsQuery import { useAppStore } from '@/store'; import { getDefaultAssignmentExpiry } from '@/utils/assignment-duration'; +type Mode = 'CREATE' | 'DELETE' | 'LANDING'; + +type ActionCardProps = { + icon: React.ReactNode; + onClick: () => void; + testId: string; + title: string; +}; + +const ActionCard = ({ icon, onClick, testId, title }: ActionCardProps) => ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + > + {icon} + {title} + +); + +const CompositeIcon = ({ Badge }: { Badge: React.FC<{ className?: string }> }) => ( +
+ + +
+); + +const searchModeToMode = (searchMode: string | undefined): Mode => { + if (searchMode === 'create') return 'CREATE'; + if (searchMode === 'delete') return 'DELETE'; + return 'LANDING'; +}; + const RouteComponent = () => { const { t } = useTranslation(); + const { mode: searchMode } = Route.useSearch(); + const [mode, setMode] = useState(searchModeToMode(searchMode)); const currentGroup = useAppStore((store) => store.currentGroup); const setupStateQuery = useSetupStateQuery(); const instrumentInfoQuery = useInstrumentInfoQuery(); const subjectsQuery = useSubjectsQuery({ params: { groupId: currentGroup?.id } }); + useEffect(() => { + setMode(searchModeToMode(searchMode)); + }, [searchMode]); + if (!currentGroup) { return null; } - // Only what this group has opted into may be assigned; the API enforces the same list, so an - // instrument missing here would be refused there anyway. const instruments = (instrumentInfoQuery.data ?? []) .filter((instrument) => currentGroup.accessibleInstrumentIds.includes(instrument.id)) .map((instrument) => ({ id: instrument.id, title: instrument.details.title })); @@ -35,27 +82,60 @@ const RouteComponent = () => { - {t({ en: 'Remote Assignments', fr: 'Tâches à distance' })} + {mode === 'CREATE' + ? t({ en: 'Create Remote Assignments', fr: 'Créer des tâches à distance' }) + : mode === 'DELETE' + ? t({ en: 'Delete Remote Assignments', fr: 'Supprimer des tâches à distance' }) + : t({ en: 'Remote Assignments', fr: 'Tâches à distance' })} - + + {mode === 'LANDING' && ( +
+ } + testId="remote-assignments-create" + title={t({ en: 'Create Assignments', fr: 'Créer des tâches' })} + onClick={() => setMode('CREATE')} + /> + } + testId="remote-assignments-delete" + title={t({ en: 'Delete Assignments', fr: 'Supprimer des tâches' })} + onClick={() => setMode('DELETE')} + /> +
+ )} + + {mode === 'CREATE' && ( + + )} + + {mode === 'DELETE' && ( +
+ +
+ )}
); }; export const Route = createFileRoute('/_app/group/remote-assignments')({ - // Guarded rather than merely hidden from the nav: assignments are served by the gateway, and the - // API only mounts AssignmentsModule when it is enabled, so a bookmarked link would otherwise reach - // a page whose endpoints do not exist. The instance toggle gates it the same way. beforeLoad: async ({ context }) => { if (!config.setup.isGatewayEnabled) { throw redirect({ to: '/dashboard' }); @@ -70,5 +150,8 @@ export const Route = createFileRoute('/_app/group/remote-assignments')({ const groupId = useAppStore.getState().currentGroup?.id; void context.queryClient.ensureQueryData(setupStateQueryOptions()); void context.queryClient.ensureQueryData(subjectsQueryOptions({ params: { groupId } })); - } + }, + validateSearch: (search: { [key: string]: unknown }): { mode?: 'create' | 'delete' } => ({ + mode: search.mode === 'create' || search.mode === 'delete' ? search.mode : undefined + }) }); diff --git a/packages/schemas/src/assignment/assignment.ts b/packages/schemas/src/assignment/assignment.ts index 3054e7263..fa4cb13c5 100644 --- a/packages/schemas/src/assignment/assignment.ts +++ b/packages/schemas/src/assignment/assignment.ts @@ -177,6 +177,17 @@ const $MutateAssignmentResponseBody = z.object({ success: z.boolean() }); +type DeleteBulkAssignmentsData = z.infer; +const $DeleteBulkAssignmentsData = z.object({ + ids: z.array(z.string().min(1)).min(1) +}); + +type DeleteBulkAssignmentsResult = z.infer; +const $DeleteBulkAssignmentsResult = z.object({ + deletedCount: z.number().int().nonnegative(), + failedIds: z.array(z.string()) +}); + type UpdateAssignmentData = z.infer; const $UpdateAssignmentData = z.object({ status: $AssignmentStatus @@ -201,6 +212,8 @@ export type { CreateBulkAssignmentsData, CreateRemoteAssignmentInputData, CreateRemoteAssignmentsInputData, + DeleteBulkAssignmentsData, + DeleteBulkAssignmentsResult, MutateAssignmentResponseBody, RemoteAssignment, UpdateAssignmentData, @@ -219,6 +232,8 @@ export { $CreateBulkAssignmentsData, $CreateRemoteAssignmentData, $CreateRemoteAssignmentsData, + $DeleteBulkAssignmentsData, + $DeleteBulkAssignmentsResult, $MutateAssignmentResponseBody, $RemoteAssignment, $UpdateAssignmentData, From f75337096cddc7f94b3c634149299ed774cbc6dc Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Thu, 1 Oct 2026 01:13:29 -0400 Subject: [PATCH 2/2] fix(testing): navigate wizard e2e spec to ?mode=create The remote-assignments route now shows a landing page by default; the wizard is only rendered when mode=create. Co-Authored-By: Claude Opus 4.6 --- testing/src/specs/remote-assignments-wizard.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testing/src/specs/remote-assignments-wizard.spec.ts b/testing/src/specs/remote-assignments-wizard.spec.ts index ba1616978..d14a6c1f6 100644 --- a/testing/src/specs/remote-assignments-wizard.spec.ts +++ b/testing/src/specs/remote-assignments-wizard.spec.ts @@ -50,8 +50,8 @@ test.describe('remote assignments wizard', () => { } await authenticateAs(credentials); - await page.goto('/group/remote-assignments'); - await expect(page).toHaveURL('/group/remote-assignments'); + await page.goto('/group/remote-assignments?mode=create'); + await expect(page).toHaveURL(/\/group\/remote-assignments\?mode=create/); const wizardPage = new RemoteAssignmentsPage(page); await expect(wizardPage.wizard).toBeVisible();