From 82ab50d0e5e6bed08a02323bf1eed4bd18b7937b Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Thu, 25 Jun 2026 13:06:08 -0400 Subject: [PATCH 1/5] Reapply "feat(web): add dedicated Remote Assignment page" This reverts commit 865f39b2e8473d22ae93a42c22f4bb0004076932. --- apps/web/src/hooks/useCreateAssignment.ts | 6 +- apps/web/src/hooks/useNavItems.ts | 18 +- apps/web/src/route-tree.ts | 22 + .../_app/datahub/$subjectId/assignments.tsx | 204 +- .../routes/_app/session/remote-assignment.tsx | 196 + apps/web/src/translations/datahub.json | 14 - apps/web/src/translations/layout.json | 4 + pnpm-lock.yaml | 5837 +++++++++-------- testing/e2e/src/2.4-remote-assignment.spec.ts | 54 + testing/e2e/src/helpers/fixtures.ts | 2 + .../e2e/src/pages/remote-assignment.page.ts | 30 + 11 files changed, 3388 insertions(+), 2999 deletions(-) create mode 100644 apps/web/src/routes/_app/session/remote-assignment.tsx create mode 100644 testing/e2e/src/2.4-remote-assignment.spec.ts create mode 100644 testing/e2e/src/pages/remote-assignment.page.ts diff --git a/apps/web/src/hooks/useCreateAssignment.ts b/apps/web/src/hooks/useCreateAssignment.ts index 3e3d565fd..d424de8f8 100644 --- a/apps/web/src/hooks/useCreateAssignment.ts +++ b/apps/web/src/hooks/useCreateAssignment.ts @@ -1,4 +1,5 @@ import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks'; +import { $Assignment } from '@opendatacapture/schemas/assignment'; import type { CreateAssignmentData } from '@opendatacapture/schemas/assignment'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import axios from 'axios'; @@ -7,7 +8,10 @@ export function useCreateAssignment() { const queryClient = useQueryClient(); const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ - mutationFn: ({ data }: { data: CreateAssignmentData }) => axios.post('/v1/assignments', data), + mutationFn: async ({ data }: { data: CreateAssignmentData }) => { + const response = await axios.post('/v1/assignments', data); + return $Assignment.parse(response.data); + }, onSuccess() { addNotification({ type: 'success' }); void queryClient.invalidateQueries({ queryKey: ['assignments'] }); diff --git a/apps/web/src/hooks/useNavItems.ts b/apps/web/src/hooks/useNavItems.ts index b53714656..17cb7d5c3 100644 --- a/apps/web/src/hooks/useNavItems.ts +++ b/apps/web/src/hooks/useNavItems.ts @@ -11,6 +11,7 @@ import { LogsIcon, PackageIcon, PaletteIcon, + SendIcon, UploadIcon, UserCogIcon, UsersIcon @@ -148,6 +149,15 @@ export function useNavItems() { url: '/instruments/accessible-instruments' }); } + // Remote assignment requires the gateway to be enabled, since assignments are served through it + if (ability?.can('create', 'Assignment') && setupStateQuery.data.isGatewayEnabled) { + sessionItems.push({ + disabled: currentSession === null, + icon: SendIcon, + label: t('layout.navLinks.remoteAssignment'), + url: '/session/remote-assignment' + }); + } if (ability?.can('read', 'Subject') && ability.can('read', 'InstrumentRecord')) { sessionItems.push({ disabled: currentSession === null, @@ -157,7 +167,13 @@ export function useNavItems() { }); } setNavItems([globalItems, adminItems, sessionItems].filter((arr) => arr.length)); - }, [currentSession, currentUser, resolvedLanguage, setupStateQuery.data.isExperimentalFeaturesEnabled]); + }, [ + currentSession, + currentUser, + resolvedLanguage, + setupStateQuery.data.isExperimentalFeaturesEnabled, + setupStateQuery.data.isGatewayEnabled + ]); return navItems; } diff --git a/apps/web/src/route-tree.ts b/apps/web/src/route-tree.ts index 19b91df58..cd0faa60b 100644 --- a/apps/web/src/route-tree.ts +++ b/apps/web/src/route-tree.ts @@ -21,6 +21,7 @@ import { Route as AppUploadIndexRouteImport } from './routes/_app/upload/index' import { Route as AppDatahubIndexRouteImport } from './routes/_app/datahub/index' import { Route as AppUploadInstrumentIdRouteImport } from './routes/_app/upload/$instrumentId' import { Route as AppSessionStartSessionRouteImport } from './routes/_app/session/start-session' +import { Route as AppSessionRemoteAssignmentRouteImport } from './routes/_app/session/remote-assignment' import { Route as AppInstrumentsAccessibleInstrumentsRouteImport } from './routes/_app/instruments/accessible-instruments' import { Route as AppGroupManageRouteImport } from './routes/_app/group/manage' import { Route as AppAdminSettingsRouteImport } from './routes/_app/admin/settings' @@ -98,6 +99,12 @@ const AppSessionStartSessionRoute = AppSessionStartSessionRouteImport.update({ path: '/session/start-session', getParentRoute: () => AppRouteRoute, } as any) +const AppSessionRemoteAssignmentRoute = + AppSessionRemoteAssignmentRouteImport.update({ + id: '/session/remote-assignment', + path: '/session/remote-assignment', + getParentRoute: () => AppRouteRoute, + } as any) const AppInstrumentsAccessibleInstrumentsRoute = AppInstrumentsAccessibleInstrumentsRouteImport.update({ id: '/instruments/accessible-instruments', @@ -204,6 +211,7 @@ export interface FileRoutesByFullPath { '/admin/settings': typeof AppAdminSettingsRoute '/group/manage': typeof AppGroupManageRoute '/instruments/accessible-instruments': typeof AppInstrumentsAccessibleInstrumentsRoute + '/session/remote-assignment': typeof AppSessionRemoteAssignmentRoute '/session/start-session': typeof AppSessionStartSessionRoute '/upload/$instrumentId': typeof AppUploadInstrumentIdRoute '/datahub/': typeof AppDatahubIndexRoute @@ -234,6 +242,7 @@ export interface FileRoutesByTo { '/admin/settings': typeof AppAdminSettingsRoute '/group/manage': typeof AppGroupManageRoute '/instruments/accessible-instruments': typeof AppInstrumentsAccessibleInstrumentsRoute + '/session/remote-assignment': typeof AppSessionRemoteAssignmentRoute '/session/start-session': typeof AppSessionStartSessionRoute '/upload/$instrumentId': typeof AppUploadInstrumentIdRoute '/datahub': typeof AppDatahubIndexRoute @@ -266,6 +275,7 @@ export interface FileRoutesById { '/_app/admin/settings': typeof AppAdminSettingsRoute '/_app/group/manage': typeof AppGroupManageRoute '/_app/instruments/accessible-instruments': typeof AppInstrumentsAccessibleInstrumentsRoute + '/_app/session/remote-assignment': typeof AppSessionRemoteAssignmentRoute '/_app/session/start-session': typeof AppSessionStartSessionRoute '/_app/upload/$instrumentId': typeof AppUploadInstrumentIdRoute '/_app/datahub/': typeof AppDatahubIndexRoute @@ -298,6 +308,7 @@ export interface FileRouteTypes { | '/admin/settings' | '/group/manage' | '/instruments/accessible-instruments' + | '/session/remote-assignment' | '/session/start-session' | '/upload/$instrumentId' | '/datahub/' @@ -328,6 +339,7 @@ export interface FileRouteTypes { | '/admin/settings' | '/group/manage' | '/instruments/accessible-instruments' + | '/session/remote-assignment' | '/session/start-session' | '/upload/$instrumentId' | '/datahub' @@ -359,6 +371,7 @@ export interface FileRouteTypes { | '/_app/admin/settings' | '/_app/group/manage' | '/_app/instruments/accessible-instruments' + | '/_app/session/remote-assignment' | '/_app/session/start-session' | '/_app/upload/$instrumentId' | '/_app/datahub/' @@ -470,6 +483,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppSessionStartSessionRouteImport parentRoute: typeof AppRouteRoute } + '/_app/session/remote-assignment': { + id: '/_app/session/remote-assignment' + path: '/session/remote-assignment' + fullPath: '/session/remote-assignment' + preLoaderRoute: typeof AppSessionRemoteAssignmentRouteImport + parentRoute: typeof AppRouteRoute + } '/_app/instruments/accessible-instruments': { id: '/_app/instruments/accessible-instruments' path: '/instruments/accessible-instruments' @@ -623,6 +643,7 @@ interface AppRouteRouteChildren { AppAdminSettingsRoute: typeof AppAdminSettingsRoute AppGroupManageRoute: typeof AppGroupManageRoute AppInstrumentsAccessibleInstrumentsRoute: typeof AppInstrumentsAccessibleInstrumentsRoute + AppSessionRemoteAssignmentRoute: typeof AppSessionRemoteAssignmentRoute AppSessionStartSessionRoute: typeof AppSessionStartSessionRoute AppUploadInstrumentIdRoute: typeof AppUploadInstrumentIdRoute AppDatahubIndexRoute: typeof AppDatahubIndexRoute @@ -649,6 +670,7 @@ const AppRouteRouteChildren: AppRouteRouteChildren = { AppGroupManageRoute: AppGroupManageRoute, AppInstrumentsAccessibleInstrumentsRoute: AppInstrumentsAccessibleInstrumentsRoute, + AppSessionRemoteAssignmentRoute: AppSessionRemoteAssignmentRoute, AppSessionStartSessionRoute: AppSessionStartSessionRoute, AppUploadInstrumentIdRoute: AppUploadInstrumentIdRoute, AppDatahubIndexRoute: AppDatahubIndexRoute, diff --git a/apps/web/src/routes/_app/datahub/$subjectId/assignments.tsx b/apps/web/src/routes/_app/datahub/$subjectId/assignments.tsx index f2443d27f..1368725a2 100644 --- a/apps/web/src/routes/_app/datahub/$subjectId/assignments.tsx +++ b/apps/web/src/routes/_app/datahub/$subjectId/assignments.tsx @@ -1,35 +1,21 @@ -/* eslint-disable perfectionist/sort-objects */ +// Assignment creation has been moved to the dedicated remote assignment page (/session/remote-assignment). +// This page now only displays existing assignments and allows viewing/canceling them. import React, { useEffect, useState } from 'react'; import { toBasicISOString } from '@douglasneuroinformatics/libjs'; -import { - Button, - ClientTable, - Dialog, - Form, - Heading, - Input, - Label, - Sheet -} from '@douglasneuroinformatics/libui/components'; +import { Button, Heading, Input, Label, Sheet, Table } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; -import { PlusIcon } from '@heroicons/react/24/solid'; import { CopyButton } from '@opendatacapture/react-core'; -import type { Assignment, AssignmentStatus, CreateAssignmentData } from '@opendatacapture/schemas/assignment'; +import type { Assignment, AssignmentStatus } from '@opendatacapture/schemas/assignment'; import type { UnilingualInstrumentInfo } from '@opendatacapture/schemas/instrument'; import { createFileRoute } from '@tanstack/react-router'; -import { z } from 'zod/v4'; import { QRCode } from '@/components/QRCode'; import { useAssignmentsQuery } from '@/hooks/useAssignmentsQuery'; -import { useCreateAssignment } from '@/hooks/useCreateAssignment'; import { useInstrument } from '@/hooks/useInstrument'; import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { useUpdateAssignment } from '@/hooks/useUpdateAssignment'; -import { useAppStore } from '@/store'; - -const ONE_YEAR = 31556952000; const AssignmentSlider: React.FC<{ assignment: Assignment | null; @@ -84,7 +70,8 @@ const AssignmentSlider: React.FC<{ const AssignmentsTable: React.FC<{ assignments: Assignment[]; onSelection: (assignment: Assignment) => void; -}> = ({ assignments, onSelection }) => { + selectedId: null | string; +}> = ({ assignments, onSelection, selectedId }) => { const { t } = useTranslation('datahub'); const [instruments, setInstruments] = useState<{ [key: string]: UnilingualInstrumentInfo }>({}); @@ -96,140 +83,83 @@ const AssignmentsTable: React.FC<{ ); }, [instrumentInfoQuery.data]); + const formatStatus = (status: AssignmentStatus) => { + switch (status) { + case 'CANCELED': + return t('assignments.statusOptions.canceled'); + case 'COMPLETE': + return t('assignments.statusOptions.complete'); + case 'EXPIRED': + return t('assignments.statusOptions.expired'); + case 'OUTSTANDING': + return t('assignments.statusOptions.outstanding'); + } + }; + return ( - - columns={[ - { - field: (entry) => { - return instruments[entry.instrumentId]?.details.title ?? entry.instrumentId; - }, - label: t('assignments.title') - }, - { - field: 'createdAt', - formatter: (value: Date) => toBasicISOString(value), - label: t('assignments.assignedAt') - }, - { - field: 'expiresAt', - formatter: (value: Date) => toBasicISOString(value), - label: t('assignments.expiresAt') - }, - { - field: 'status', - formatter: (value: AssignmentStatus) => { - switch (value) { - case 'CANCELED': - return t('assignments.statusOptions.canceled'); - case 'COMPLETE': - return t('assignments.statusOptions.complete'); - case 'EXPIRED': - return t('assignments.statusOptions.expired'); - case 'OUTSTANDING': - return t('assignments.statusOptions.outstanding'); - } - }, - label: t('assignments.status') - } - ]} - data={assignments} - entriesPerPage={15} - minRows={15} - onEntryClick={onSelection} - /> +
+ + + + {t('assignments.title')} + {t('assignments.assignedAt')} + {t('assignments.expiresAt')} + {t('assignments.status')} + + + + {assignments.map((assignment) => ( + onSelection(assignment)} + > + {instruments[assignment.instrumentId]?.details.title ?? assignment.instrumentId} + {toBasicISOString(assignment.createdAt)} + {toBasicISOString(assignment.expiresAt)} + {formatStatus(assignment.status)} + + ))} + +
+
); }; const RouteComponent = () => { const params = Route.useParams(); const { t } = useTranslation(); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isEditSliderOpen, setIsEditSliderOpen] = useState(false); - const currentGroup = useAppStore((store) => store.currentGroup); const [selectedAssignment, setSelectedAssignment] = useState(null); const assignmentsQuery = useAssignmentsQuery({ params: { subjectId: params.subjectId } }); - const createAssignmentMutation = useCreateAssignment(); const updateAssignmentMutation = useUpdateAssignment(); - const instrumentInfoQuery = useInstrumentInfoQuery(); - - const instrumentOptions = Object.fromEntries( - (instrumentInfoQuery.data ?? []) - .sort((a, b) => a.details.title.localeCompare(b.details.title)) - .filter((instrument) => { - return currentGroup?.accessibleInstrumentIds.includes(instrument.id) ?? true; - }) - .map((instrument) => [instrument.id, instrument.details.title]) - ); - return ( - -
-
- {t('datahub.assignments.assignedInstruments')} - - - -
- { - setSelectedAssignment(assignment); - setIsEditSliderOpen(true); - }} - /> - { - updateAssignmentMutation.mutate({ data: { status: 'CANCELED' }, params: { id } }); - setIsEditSliderOpen(false); - }} - /> - - - {t('datahub.assignments.addAssignment')} - {t('datahub.assignments.addAssignmentDesc')} - -
> - } - onSubmit={async (data) => { - await createAssignmentMutation.mutateAsync({ - data: { ...data, groupId: currentGroup?.id, subjectId: params.subjectId } - }); - setIsCreateModalOpen(false); - }} - /> - +
+
+ {t('datahub.assignments.assignedInstruments')}
-
+ { + setSelectedAssignment(assignment); + setIsEditSliderOpen(true); + }} + /> + { + updateAssignmentMutation.mutate({ data: { status: 'CANCELED' }, params: { id } }); + setIsEditSliderOpen(false); + }} + /> + ); }; diff --git a/apps/web/src/routes/_app/session/remote-assignment.tsx b/apps/web/src/routes/_app/session/remote-assignment.tsx new file mode 100644 index 000000000..9383bff76 --- /dev/null +++ b/apps/web/src/routes/_app/session/remote-assignment.tsx @@ -0,0 +1,196 @@ +import React, { useEffect, useState } from 'react'; + +import { Button, Dialog, Form, Heading, Input, Label, Sheet } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { CopyButton } from '@opendatacapture/react-core'; +import type { CreateAssignmentData } from '@opendatacapture/schemas/assignment'; +import type { TranslatedInstrumentInfo } from '@opendatacapture/schemas/instrument'; +import { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { z } from 'zod/v4'; + +import { InstrumentShowcase } from '@/components/InstrumentShowcase'; +import { PageHeader } from '@/components/PageHeader'; +import { QRCode } from '@/components/QRCode'; +import { WithFallback } from '@/components/WithFallback'; +import { useCreateAssignment } from '@/hooks/useCreateAssignment'; +import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; +import { useAppStore } from '@/store'; + +const ONE_YEAR = 31556952000; + +/** Slide-over panel shown after an assignment is created, displaying the URL, copy button, and QR code */ +const AssignmentResultSlider: React.FC<{ + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + title: string | undefined; + url: string | undefined; +}> = ({ isOpen, setIsOpen, title, url }) => { + const { t } = useTranslation(); + return ( + + + + {title} + + {t({ + en: 'Share this link with the subject to complete the assignment remotely. If the link is lost, it can be found by navigating to View Current Subject, selecting the Assignments tab, and clicking on the assignment.', + fr: "Partagez ce lien avec le client pour compléter la tâche à distance. Si le lien est perdu, il peut être retrouvé en naviguant vers Voir le client actuel, en sélectionnant l'onglet Tâches, puis en cliquant sur la tâche." + })} + + + + + + +