Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,11 @@ export class InstrumentRecordsController {
) {
return this.instrumentRecordsService.updateById(id, data, { ability });
}

@ApiOperation({ summary: 'Get Instrument Record' })
@Get(':id')
@RouteAccess({ action: 'read', subject: 'InstrumentRecord' })
findById(@Param('id', ValidObjectIdPipe) id: string, @CurrentUser('ability') ability: AppAbility) {
return this.instrumentRecordsService.findById(id, { ability });
}
}
10 changes: 10 additions & 0 deletions apps/api/src/instrument-records/instrument-records.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,16 @@ export class InstrumentRecordsService {
return records;
}

async findById(id: string, { ability }: EntityOperationOptions = {}) {
const record = await this.instrumentRecordModel.findFirst({
where: { AND: [accessibleQuery(ability, 'read', 'InstrumentRecord')], id }
});
if (!record) {
throw new NotFoundException();
}
return record;
}

async linearModel(
{ groupId, instrumentId }: { groupId?: string; instrumentId: string },
{ ability }: EntityOperationOptions = {}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"lucide-react": "^0.507.0",
"motion": "catalog:",
"papaparse": "workspace:papaparse__5.x@*",
"qrcode": "^1.5.4",
"react": "workspace:react__19.x@*",
"react-dom": "workspace:react-dom__19.x@*",
"recharts": "^2.15.2",
Expand All @@ -65,6 +66,7 @@
"@tanstack/router-plugin": "^1.127.3",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "16.2.0",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-react-swc": "^3.9.0",
"happy-dom": "catalog:",
"tailwindcss": "catalog:",
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/components/QRCode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useEffect, useRef } from 'react';

import { useTheme } from '@douglasneuroinformatics/libui/hooks';
import qrcode from 'qrcode';

export const QRCode = ({ url }: { url: string }) => {
const ref = useRef<HTMLCanvasElement>(null);

const [theme] = useTheme();

useEffect(() => {
qrcode.toCanvas(
ref.current,
url,
{
color: {
dark: theme === 'dark' ? '#f1f5f9' : '#0f172a',
light: '#0000'
},
margin: 2,
scale: 6
},
(error) => {
if (error) {
console.error(error);
}
}
);
}, [theme, url]);

return <canvas className="rounded-md border" ref={ref} />;
};
17 changes: 17 additions & 0 deletions apps/web/src/hooks/useInstrumentRecordQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { $InstrumentRecord } from '@opendatacapture/schemas/instrument-records';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import axios from 'axios';

export const instrumentRecordQueryOptions = ({ params }: { params: { id: string } }) => {
return queryOptions({
queryFn: async () => {
const response = await axios.get(`/v1/instrument-records/${params.id}`);
return $InstrumentRecord.parse(response.data);
},
queryKey: ['instrument-records', `id-${params.id}`]
});
};

export function useInstrumentRecordQuery({ params }: { params: { id: string } }) {
return useSuspenseQuery(instrumentRecordQueryOptions({ params }));
}
4 changes: 3 additions & 1 deletion apps/web/src/hooks/useInstrumentVisualization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useFindSessionQuery } from './useFindSessionQuery';
type InstrumentVisualizationRecord = {
[key: string]: unknown;
__date__: Date;
__id__: string;
__time__: number;
};

Expand Down Expand Up @@ -76,7 +77,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio
instrument.internal.edition
}_${new Date().toISOString()}`;

const exportRecords = records.map((record) => omit(record, ['__time__']));
const exportRecords = records.map((record) => omit(record, ['__time__', '__id__']));

const makeWideRows = () => {
const columnNames = Object.keys(exportRecords[0]!);
Expand Down Expand Up @@ -229,6 +230,7 @@ export function useInstrumentVisualization({ params }: UseInstrumentVisualizatio

return {
__date__: record.date,
__id__: record.id,
__time__: record.date.getTime(),
username: username,
...record.computedMeasures,
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/hooks/useSubjectQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { $Subject } from '@opendatacapture/schemas/subject';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import axios from 'axios';

type SubjectQueryParams = {
id: string;
};

export const subjectQueryOptions = ({ params }: { params: SubjectQueryParams }) => {
return queryOptions({
queryFn: async () => {
const response = await axios.get(`/v1/subjects/${params.id}`, { params });
return $Subject.parseAsync(response.data);
},
queryKey: ['subjects', `id-${params.id}`]
});
};
Comment on lines +9 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove redundant params from axios config.

Line 12 passes params as query parameters, resulting in /v1/subjects/123?id=123. The ID is already in the URL path and shouldn't be duplicated as a query parameter.

Apply this diff:

 export const subjectQueryOptions = ({ params }: { params: SubjectQueryParams }) => {
   return queryOptions({
     queryFn: async () => {
-      const response = await axios.get(`/v1/subjects/${params.id}`, { params });
+      const response = await axios.get(`/v1/subjects/${params.id}`);
       return $Subject.parseAsync(response.data);
     },
     queryKey: ['subjects', `id-${params.id}`]
   });
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const subjectQueryOptions = ({ params }: { params: SubjectQueryParams }) => {
return queryOptions({
queryFn: async () => {
const response = await axios.get(`/v1/subjects/${params.id}`, { params });
return $Subject.parseAsync(response.data);
},
queryKey: ['subjects', `id-${params.id}`]
});
};
export const subjectQueryOptions = ({ params }: { params: SubjectQueryParams }) => {
return queryOptions({
queryFn: async () => {
const response = await axios.get(`/v1/subjects/${params.id}`);
return $Subject.parseAsync(response.data);
},
queryKey: ['subjects', `id-${params.id}`]
});
};
🤖 Prompt for AI Agents
In apps/web/src/hooks/useSubjectQuery.ts around lines 9 to 17, the axios.get
call incorrectly passes the entire params object as query parameters causing
duplicate id in the URL (e.g. /v1/subjects/123?id=123); remove the second
argument ({ params }) from axios.get so the request uses only the path
parameter, leaving the axios call as axios.get(`/v1/subjects/${params.id}`) and
keep the rest (parsing and queryKey) unchanged.


export function useSubjectQuery({ params }: { params: SubjectQueryParams }) {
return useSuspenseQuery(subjectQueryOptions({ params }));
}
22 changes: 22 additions & 0 deletions apps/web/src/route-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { Route as AppInstrumentsRenderIdRouteImport } from './routes/_app/instru
import { Route as AppDatahubSubjectIdTableRouteImport } from './routes/_app/datahub/$subjectId/table'
import { Route as AppDatahubSubjectIdGraphRouteImport } from './routes/_app/datahub/$subjectId/graph'
import { Route as AppDatahubSubjectIdAssignmentsRouteImport } from './routes/_app/datahub/$subjectId/assignments'
import { Route as AppDatahubSubjectIdRecordIdRouteImport } from './routes/_app/datahub/$subjectId/$recordId'
import { Route as AppAdminUsersCreateRouteImport } from './routes/_app/admin/users/create'
import { Route as AppAdminGroupsCreateRouteImport } from './routes/_app/admin/groups/create'

Expand Down Expand Up @@ -148,6 +149,12 @@ const AppDatahubSubjectIdAssignmentsRoute =
path: '/assignments',
getParentRoute: () => AppDatahubSubjectIdRouteRoute,
} as any)
const AppDatahubSubjectIdRecordIdRoute =
AppDatahubSubjectIdRecordIdRouteImport.update({
id: '/$recordId',
path: '/$recordId',
getParentRoute: () => AppDatahubSubjectIdRouteRoute,
} as any)
const AppAdminUsersCreateRoute = AppAdminUsersCreateRouteImport.update({
id: '/admin/users/create',
path: '/admin/users/create',
Expand Down Expand Up @@ -177,6 +184,7 @@ export interface FileRoutesByFullPath {
'/upload': typeof AppUploadIndexRoute
'/admin/groups/create': typeof AppAdminGroupsCreateRoute
'/admin/users/create': typeof AppAdminUsersCreateRoute
'/datahub/$subjectId/$recordId': typeof AppDatahubSubjectIdRecordIdRoute
'/datahub/$subjectId/assignments': typeof AppDatahubSubjectIdAssignmentsRoute
'/datahub/$subjectId/graph': typeof AppDatahubSubjectIdGraphRoute
'/datahub/$subjectId/table': typeof AppDatahubSubjectIdTableRoute
Expand All @@ -202,6 +210,7 @@ export interface FileRoutesByTo {
'/upload': typeof AppUploadIndexRoute
'/admin/groups/create': typeof AppAdminGroupsCreateRoute
'/admin/users/create': typeof AppAdminUsersCreateRoute
'/datahub/$subjectId/$recordId': typeof AppDatahubSubjectIdRecordIdRoute
'/datahub/$subjectId/assignments': typeof AppDatahubSubjectIdAssignmentsRoute
'/datahub/$subjectId/graph': typeof AppDatahubSubjectIdGraphRoute
'/datahub/$subjectId/table': typeof AppDatahubSubjectIdTableRoute
Expand Down Expand Up @@ -229,6 +238,7 @@ export interface FileRoutesById {
'/_app/upload/': typeof AppUploadIndexRoute
'/_app/admin/groups/create': typeof AppAdminGroupsCreateRoute
'/_app/admin/users/create': typeof AppAdminUsersCreateRoute
'/_app/datahub/$subjectId/$recordId': typeof AppDatahubSubjectIdRecordIdRoute
'/_app/datahub/$subjectId/assignments': typeof AppDatahubSubjectIdAssignmentsRoute
'/_app/datahub/$subjectId/graph': typeof AppDatahubSubjectIdGraphRoute
'/_app/datahub/$subjectId/table': typeof AppDatahubSubjectIdTableRoute
Expand Down Expand Up @@ -256,6 +266,7 @@ export interface FileRouteTypes {
| '/upload'
| '/admin/groups/create'
| '/admin/users/create'
| '/datahub/$subjectId/$recordId'
| '/datahub/$subjectId/assignments'
| '/datahub/$subjectId/graph'
| '/datahub/$subjectId/table'
Expand All @@ -281,6 +292,7 @@ export interface FileRouteTypes {
| '/upload'
| '/admin/groups/create'
| '/admin/users/create'
| '/datahub/$subjectId/$recordId'
| '/datahub/$subjectId/assignments'
| '/datahub/$subjectId/graph'
| '/datahub/$subjectId/table'
Expand All @@ -307,6 +319,7 @@ export interface FileRouteTypes {
| '/_app/upload/'
| '/_app/admin/groups/create'
| '/_app/admin/users/create'
| '/_app/datahub/$subjectId/$recordId'
| '/_app/datahub/$subjectId/assignments'
| '/_app/datahub/$subjectId/graph'
| '/_app/datahub/$subjectId/table'
Expand Down Expand Up @@ -477,6 +490,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppDatahubSubjectIdAssignmentsRouteImport
parentRoute: typeof AppDatahubSubjectIdRouteRoute
}
'/_app/datahub/$subjectId/$recordId': {
id: '/_app/datahub/$subjectId/$recordId'
path: '/$recordId'
fullPath: '/datahub/$subjectId/$recordId'
preLoaderRoute: typeof AppDatahubSubjectIdRecordIdRouteImport
parentRoute: typeof AppDatahubSubjectIdRouteRoute
}
'/_app/admin/users/create': {
id: '/_app/admin/users/create'
path: '/admin/users/create'
Expand All @@ -495,13 +515,15 @@ declare module '@tanstack/react-router' {
}

interface AppDatahubSubjectIdRouteRouteChildren {
AppDatahubSubjectIdRecordIdRoute: typeof AppDatahubSubjectIdRecordIdRoute
AppDatahubSubjectIdAssignmentsRoute: typeof AppDatahubSubjectIdAssignmentsRoute
AppDatahubSubjectIdGraphRoute: typeof AppDatahubSubjectIdGraphRoute
AppDatahubSubjectIdTableRoute: typeof AppDatahubSubjectIdTableRoute
}

const AppDatahubSubjectIdRouteRouteChildren: AppDatahubSubjectIdRouteRouteChildren =
{
AppDatahubSubjectIdRecordIdRoute: AppDatahubSubjectIdRecordIdRoute,
AppDatahubSubjectIdAssignmentsRoute: AppDatahubSubjectIdAssignmentsRoute,
AppDatahubSubjectIdGraphRoute: AppDatahubSubjectIdGraphRoute,
AppDatahubSubjectIdTableRoute: AppDatahubSubjectIdTableRoute,
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/routes/_app/datahub/$subjectId/$recordId.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { InstrumentSummary } from '@opendatacapture/react-core';
import { createFileRoute } from '@tanstack/react-router';

import { useInstrument } from '@/hooks/useInstrument';
import { instrumentRecordQueryOptions, useInstrumentRecordQuery } from '@/hooks/useInstrumentRecordQuery';
import { subjectQueryOptions, useSubjectQuery } from '@/hooks/useSubjectQuery';

const RouteComponent = () => {
const recordId = Route.useParams({ select: (params) => params.recordId });

const { data: instrumentRecord } = useInstrumentRecordQuery({ params: { id: recordId } });
const { data: subject } = useSubjectQuery({ params: { id: instrumentRecord.subjectId } });

const instrument = useInstrument(instrumentRecord.instrumentId);

if (!instrument) {
return null;
}

return (
<div className="container py-8">
<InstrumentSummary
displayAllMeasures
data={instrumentRecord.data}
instrument={instrument}
subject={subject}
timeCollected={instrumentRecord.createdAt.getTime()}
/>
</div>
);
};

export const Route = createFileRoute('/_app/datahub/$subjectId/$recordId')({
component: RouteComponent,
loader: async ({ context, params }) => {
const record = await context.queryClient.ensureQueryData(
instrumentRecordQueryOptions({ params: { id: params.recordId } })
);
await context.queryClient.ensureQueryData(subjectQueryOptions({ params: { id: record.subjectId } }));
}
});
26 changes: 13 additions & 13 deletions apps/web/src/routes/_app/datahub/$subjectId/assignments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { UnilingualInstrumentInfo } from '@opendatacapture/schemas/instrume
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';
Expand All @@ -40,26 +41,25 @@ const AssignmentSlider: React.FC<{
const instrument = useInstrument(assignment?.instrumentId ?? null);

return (
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<Sheet open={Boolean(isOpen && assignment && instrument)} onOpenChange={setIsOpen}>
<Sheet.Content className="flex h-full flex-col">
<Sheet.Header>
<Sheet.Title>{instrument?.details.title}</Sheet.Title>
<Sheet.Description>{t('datahub.assignments.assignmentSliderDesc')}</Sheet.Description>
</Sheet.Header>
<Sheet.Body className="grow">
{instrument && (
<div className="flex flex-col gap-3">
<Label asChild>
<a className="hover:underline" href={assignment!.url} rel="noreferrer" target="_blank">
{t('datahub.assignments.link')}
</a>
</Label>
<div className="flex gap-2">
<Input readOnly className="h-9" id="link" value={assignment!.url} />
<CopyButton size="sm" text={assignment!.url} variant="outline" />
</div>
<div className="flex flex-col gap-3">
<Label asChild>
<a className="hover:underline" href={assignment?.url} rel="noreferrer" target="_blank">
{t('datahub.assignments.link')}
</a>
</Label>
<div className="flex gap-2">
<Input readOnly className="h-9" id="link" value={assignment?.url} />
<CopyButton size="sm" text={assignment?.url ?? ''} variant="outline" />
</div>
)}
<QRCode url={assignment?.url ?? 'javascript:void(0)'} />
</div>
</Sheet.Body>
<Sheet.Footer>
<Button
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/routes/_app/datahub/$subjectId/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,15 @@ const RouteComponent = () => {
</Heading>
</PageHeader>
<div className="mb-5 flex">
<TabLink label={t('layout.tabs.table')} pathname={`${basePathname}/table`} testId="subject-table" />
<TabLink label={t('layout.tabs.graph')} pathname={`${basePathname}/graph`} testId="subject-graph" />
{config.setup.isGatewayEnabled && (
<TabLink
label={t('layout.tabs.assignments')}
pathname={`${basePathname}/assignments`}
testId="subject-assignment"
/>
)}
<TabLink label={t('layout.tabs.table')} pathname={`${basePathname}/table`} testId="subject-table" />
<TabLink label={t('layout.tabs.graph')} pathname={`${basePathname}/graph`} testId="subject-graph" />
</div>
<React.Suspense fallback={<LoadingFallback />}>
<Outlet />
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/routes/_app/datahub/$subjectId/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { TimeDropdown } from '@/components/TimeDropdown';
import { useInstrumentVisualization } from '@/hooks/useInstrumentVisualization';

const RouteComponent = () => {
const navigate = Route.useNavigate();
const params = Route.useParams();
const { dl, instrumentId, instrumentOptions, records, setInstrumentId, setMinDate } = useInstrumentVisualization({
params: { subjectId: params.subjectId }
Expand Down Expand Up @@ -60,6 +61,9 @@ const RouteComponent = () => {
data-testid="subject-table"
entriesPerPage={15}
minRows={15}
onEntryClick={(row) => {
void navigate({ params: { recordId: row.__id__ }, to: '/datahub/$subjectId/$recordId' });
}}
/>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/routes/_app/datahub/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ const RouteComponent = () => {
<MasterDataTable
data={data}
onSelect={(subject) => {
void navigate({ to: `./${subject.id}/assignments` });
void navigate({ to: `./${subject.id}/table` });
}}
/>
</div>
Expand Down
Loading