From 21695b3f34196b175cb4c99816e9c9d249621158 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Thu, 11 Jun 2026 16:08:19 -0400 Subject: [PATCH 1/9] feat(core): add FILE instrument kind and storage schemas Introduce the FILE instrument kind in runtime-core (file-type constants, FileInstrument types with per-group basename/type/count) and the matching zod schemas in @opendatacapture/schemas, including file location/metadata, presigned URL info, and the upload-complete payload. Add the `pending` flag and file metadata to instrument records, and extend instrument-utils guards/translation helpers for the new kind. Co-Authored-By: Claude Opus 4.8 --- packages/instrument-utils/src/guards.ts | 5 ++ packages/instrument-utils/src/translate.ts | 22 +++++++++ packages/runtime-core/package.json | 6 ++- packages/runtime-core/src/constants.ts | 22 +++++++++ packages/runtime-core/src/define.ts | 9 ++-- packages/runtime-core/src/i18n.ts | 2 +- packages/runtime-core/src/index.ts | 1 + .../runtime-core/src/types/instrument.base.ts | 2 +- .../runtime-core/src/types/instrument.core.ts | 10 +++- .../runtime-core/src/types/instrument.file.ts | 47 +++++++++++++++++++ .../src/types/instrument.interactive.ts | 6 +++ packages/schemas/package.json | 1 + .../instrument-records/instrument-records.ts | 11 +++++ .../schemas/src/instrument/instrument.base.ts | 4 +- .../schemas/src/instrument/instrument.core.ts | 13 +++-- .../schemas/src/instrument/instrument.file.ts | 35 ++++++++++++++ .../src/instrument/instrument.interactive.ts | 3 ++ packages/schemas/src/instrument/instrument.ts | 1 + packages/schemas/src/storage/storage.ts | 29 ++++++++++++ 19 files changed, 216 insertions(+), 13 deletions(-) create mode 100644 packages/runtime-core/src/constants.ts create mode 100644 packages/runtime-core/src/types/instrument.file.ts create mode 100644 packages/schemas/src/instrument/instrument.file.ts create mode 100644 packages/schemas/src/storage/storage.ts diff --git a/packages/instrument-utils/src/guards.ts b/packages/instrument-utils/src/guards.ts index 0facd3dcb..d5b197c70 100644 --- a/packages/instrument-utils/src/guards.ts +++ b/packages/instrument-utils/src/guards.ts @@ -3,6 +3,7 @@ import type { AnyMultilingualInstrument, AnyScalarInstrument, AnyUnilingualInstrument, + FileInstrument, FormInstrument, InteractiveInstrument, ScalarInstrumentInternal, @@ -30,6 +31,10 @@ export function isMultilingualInstrumentInfo(info: InstrumentInfo): info is Mult return Array.isArray(info.language); } +export function isFileInstrument(instrument: AnyInstrument): instrument is FileInstrument { + return instrument.kind === 'FILE'; +} + export function isFormInstrument(instrument: AnyInstrument): instrument is FormInstrument { return instrument.kind === 'FORM'; } diff --git a/packages/instrument-utils/src/translate.ts b/packages/instrument-utils/src/translate.ts index d9bf17791..298836c09 100644 --- a/packages/instrument-utils/src/translate.ts +++ b/packages/instrument-utils/src/translate.ts @@ -1,7 +1,9 @@ import type { AnyInstrument, + AnyMultilingualFileInstrument, AnyMultilingualFormInstrument, AnyMultilingualInteractiveInstrument, + AnyUnilingualFileInstrument, AnyUnilingualFormInstrument, AnyUnilingualInstrument, AnyUnilingualInteractiveInstrument, @@ -20,6 +22,7 @@ import { mapValues, wrap } from 'lodash-es'; import { match, P } from 'ts-pattern'; import { + isFileInstrument, isFormInstrument, isInteractiveInstrument, isMultilingualInstrument, @@ -288,6 +291,23 @@ function translateInteractive( }; } +function translateFile(instrument: AnyMultilingualFileInstrument, language: Language): AnyUnilingualFileInstrument { + return { + ...instrument, + clientDetails: translateClientDetails(instrument.clientDetails, language), + content: { + fileGroups: instrument.content.fileGroups.map((group) => ({ + ...group, + label: group.label[language] + })) + }, + details: translateDetails(instrument.details, language), + language, + measures: translateMeasures(instrument.measures, language), + tags: instrument.tags[language] + }; +} + function translateSeries(series: SeriesInstrument, language: Language): SeriesInstrument { return { ...series, @@ -350,6 +370,8 @@ export function translateInstrument(instrument: AnyInstrument, preferredLanguage return translateSeries(instrument, targetLanguage); } else if (isInteractiveInstrument(instrument)) { return translateInteractive(instrument, targetLanguage); + } else if (isFileInstrument(instrument)) { + return translateFile(instrument, targetLanguage); } throw new Error(`Unexpected instrument kind: ${(instrument as AnyInstrument).kind}`); } diff --git a/packages/runtime-core/package.json b/packages/runtime-core/package.json index f967c77da..d326f1214 100644 --- a/packages/runtime-core/package.json +++ b/packages/runtime-core/package.json @@ -9,11 +9,15 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./constants": { + "types": "./lib/constants.d.ts", + "import": "./lib/constants.js" + }, "./package.json": "./package.json" }, "scripts": { "build": "rm -rf dist lib && pnpm build:lib && pnpm build:dist", - "build:dist": "pnpm exec esbuild --bundle --format=esm --outfile=dist/index.js --platform=browser lib/index.js && api-extractor run -c config/api-extractor.json", + "build:dist": "pnpm exec esbuild --bundle --format=esm --target=es2020 --outfile=dist/index.js --platform=browser lib/index.js && api-extractor run -c config/api-extractor.json", "build:lib": "tsc -b tsconfig.build.json", "format": "prettier --write src", "lint": "tsc --noEmit && eslint --fix src" diff --git a/packages/runtime-core/src/constants.ts b/packages/runtime-core/src/constants.ts new file mode 100644 index 000000000..1d328d3ff --- /dev/null +++ b/packages/runtime-core/src/constants.ts @@ -0,0 +1,22 @@ +/** @public */ +export const FILE_TYPES = Object.freeze({ + binary: Object.freeze(['application/octet-stream'] as const), + documents: Object.freeze([ + 'application/pdf', + 'text/plain', + 'text/markdown', + 'text/html', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/rtf' + ] as const), + images: Object.freeze(['image/png', 'image/jpeg', 'image/tiff', 'image/gif', 'image/svg+xml', 'image/bmp'] as const), + spreadsheets: Object.freeze([ + 'text/csv', + 'text/tsv', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.oasis.opendocument.spreadsheet' + ] as const), + structured: Object.freeze(['application/json', 'application/xml'] as const) +} as const); diff --git a/packages/runtime-core/src/define.ts b/packages/runtime-core/src/define.ts index dfb46bf47..7bdbc1eb5 100644 --- a/packages/runtime-core/src/define.ts +++ b/packages/runtime-core/src/define.ts @@ -3,6 +3,7 @@ import type { ApprovedLicense } from '@opendatacapture/licenses'; import type { InstrumentKind, InstrumentLanguage, InstrumentValidationSchema } from './types/instrument.base.js'; +import type { FileInstrument } from './types/instrument.file.js'; import type { FormInstrument } from './types/instrument.form.js'; import type { InteractiveInstrument } from './types/instrument.interactive.js'; import type { SeriesInstrument } from './types/instrument.series.js'; @@ -23,8 +24,8 @@ type InternalLicensingRequirements = OpenDataCaptureContext extends { isRepo: tr /** @public */ // prettier-ignore export type DiscriminatedInstrument< - TKind extends InstrumentKind, - TLanguage extends InstrumentLanguage, + TKind extends InstrumentKind, + TLanguage extends InstrumentLanguage, TData > = [TKind] extends ['FORM'] ? TData extends FormInstrument.Data @@ -34,7 +35,9 @@ export type DiscriminatedInstrument< ? TData extends InteractiveInstrument.Data ? InteractiveInstrument : never - : never; + : [TKind] extends ['FILE'] + ? FileInstrument + : never; /** @public */ export type InstrumentDef< diff --git a/packages/runtime-core/src/i18n.ts b/packages/runtime-core/src/i18n.ts index 08bc0bd59..92c6da226 100644 --- a/packages/runtime-core/src/i18n.ts +++ b/packages/runtime-core/src/i18n.ts @@ -141,7 +141,7 @@ class SynchronizedTranslator @InitializedOnly changeLanguage(language: Language) { - window.top!.document.dispatchEvent(new CustomEvent('changeLanguage', { detail: language })); + window.parent.document.dispatchEvent(new CustomEvent('changeLanguage', { detail: language })); } override init(options: TranslatorInitOptions = {}) { diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index ae862dd0d..f9717e3e9 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -4,6 +4,7 @@ export * from './notifications.js'; export type * from './types/core.js'; export type * from './types/instrument.base.js'; export type * from './types/instrument.core.js'; +export type * from './types/instrument.file.js'; export type * from './types/instrument.form.js'; export type * from './types/instrument.interactive.js'; export type * from './types/instrument.series.js'; diff --git a/packages/runtime-core/src/types/instrument.base.ts b/packages/runtime-core/src/types/instrument.base.ts index 4a2d5c3f7..a56936c6a 100644 --- a/packages/runtime-core/src/types/instrument.base.ts +++ b/packages/runtime-core/src/types/instrument.base.ts @@ -10,7 +10,7 @@ import type { Language } from './core.js'; * The kind of an instrument. This serves as the discriminator key. * @public */ -type InstrumentKind = 'FORM' | 'INTERACTIVE' | 'SERIES'; +type InstrumentKind = 'FILE' | 'FORM' | 'INTERACTIVE' | 'SERIES'; /** * The language(s) of the instrument. For a unilingual instrument, diff --git a/packages/runtime-core/src/types/instrument.core.ts b/packages/runtime-core/src/types/instrument.core.ts index bc17f2604..8d07625db 100644 --- a/packages/runtime-core/src/types/instrument.core.ts +++ b/packages/runtime-core/src/types/instrument.core.ts @@ -1,5 +1,6 @@ import type { Language } from './core.js'; import type { InstrumentKind } from './instrument.base.js'; +import type { AnyMultilingualFileInstrument, AnyUnilingualFileInstrument, FileInstrument } from './instrument.file.js'; import type { AnyMultilingualFormInstrument, AnyUnilingualFormInstrument, FormInstrument } from './instrument.form.js'; import type { AnyMultilingualInteractiveInstrument, @@ -9,7 +10,7 @@ import type { import type { SeriesInstrument } from './instrument.series.js'; /** @internal */ -type AnyScalarInstrument = FormInstrument | InteractiveInstrument; +type AnyScalarInstrument = FileInstrument | FormInstrument | InteractiveInstrument; /** @internal */ type AnyInstrument = AnyScalarInstrument | SeriesInstrument; @@ -19,6 +20,7 @@ type SomeInstrument = Extract; @@ -27,7 +29,10 @@ type AnyUnilingualInstrument = type SomeUnilingualInstrument = Extract; /** @internal */ -type AnyUnilingualScalarInstrument = AnyUnilingualFormInstrument | AnyUnilingualInteractiveInstrument; +type AnyUnilingualScalarInstrument = + | AnyUnilingualFileInstrument + | AnyUnilingualFormInstrument + | AnyUnilingualInteractiveInstrument; /** @internal */ type SomeUnilingualScalarInstrument = Extract< @@ -37,6 +42,7 @@ type SomeUnilingualScalarInstrument = Extract< /** @internal */ type AnyMultilingualInstrument = + | AnyMultilingualFileInstrument | AnyMultilingualFormInstrument | AnyMultilingualInteractiveInstrument | SeriesInstrument; diff --git a/packages/runtime-core/src/types/instrument.file.ts b/packages/runtime-core/src/types/instrument.file.ts new file mode 100644 index 000000000..ab11fa979 --- /dev/null +++ b/packages/runtime-core/src/types/instrument.file.ts @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/no-namespace */ + +import type { Merge } from 'type-fest'; + +import type { FILE_TYPES } from '../constants.js'; +import type { Language } from './core.js'; +import type { InstrumentLanguage, InstrumentUIOption, ScalarInstrument } from './instrument.base.js'; + +/** @public */ +declare namespace FileInstrument { + export type Data = { + [key: string]: never; + }; + + export type FileType = (typeof FILE_TYPES)[keyof typeof FILE_TYPES][number]; + + export type FileGroup = { + basename: string; + count: { + max: number; + min: number; + }; + label: InstrumentUIOption; + type: FileType | null; + }; + + export type Content = { + fileGroups: FileGroup[]; + }; +} + +/** @public */ +declare type FileInstrument = Merge< + ScalarInstrument, + { + content: FileInstrument.Content; + kind: 'FILE'; + } +>; + +/** @internal */ +type AnyUnilingualFileInstrument = FileInstrument; + +/** @internal */ +type AnyMultilingualFileInstrument = FileInstrument; + +export type { AnyMultilingualFileInstrument, AnyUnilingualFileInstrument, FileInstrument }; diff --git a/packages/runtime-core/src/types/instrument.interactive.ts b/packages/runtime-core/src/types/instrument.interactive.ts index c37a92bc2..69e5d0717 100644 --- a/packages/runtime-core/src/types/instrument.interactive.ts +++ b/packages/runtime-core/src/types/instrument.interactive.ts @@ -27,6 +27,12 @@ declare type InteractiveInstrument< }; /** whether to enter fullscreen mode automatically when the instrument content is shown */ defaultFullscreen?: boolean; + /** whether to block users from changing languages during an instrument */ + enableLanguageLock?: boolean; + /** whether to provide an initial screen to select language before the instrument */ + enableLanguageSelect?: boolean; + /** whether to enable a button above the instrument to change languages */ + enableLanguageToggle?: boolean; html?: string; meta?: { [name: string]: string; diff --git a/packages/schemas/package.json b/packages/schemas/package.json index c04d5bba3..7b4c02122 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -14,6 +14,7 @@ "./instrument-records": "./src/instrument-records/instrument-records.ts", "./session": "./src/session/session.ts", "./setup": "./src/setup/setup.ts", + "./storage": "./src/storage/storage.ts", "./subject": "./src/subject/subject.ts", "./summary": "./src/summary/summary.ts", "./user": "./src/user/user.ts" diff --git a/packages/schemas/src/instrument-records/instrument-records.ts b/packages/schemas/src/instrument-records/instrument-records.ts index 599e533df..7f5822c63 100644 --- a/packages/schemas/src/instrument-records/instrument-records.ts +++ b/packages/schemas/src/instrument-records/instrument-records.ts @@ -3,6 +3,7 @@ import { z } from 'zod/v4'; import { $BaseModel, $Json } from '../core/core.js'; import { $InstrumentMeasureValue } from '../instrument/instrument.js'; +import { $FileMetadata } from '../storage/storage.js'; import type { SessionType } from '../session/session.js'; @@ -47,6 +48,7 @@ export const $InstrumentRecord = $BaseModel.extend({ date: z.coerce.date(), groupId: z.string().nullish(), instrumentId: z.string(), + pending: z.boolean().nullish(), sessionId: z.string(), subjectId: z.string() }); @@ -87,3 +89,12 @@ export type InstrumentRecordQueryParams = { minDate?: Date; subjectId?: string; }; + +export type $InstrumentRecordFile = z.infer; +export const $InstrumentRecordFile = $FileMetadata.omit({ location: true }).extend({ + exp: z.int(), + url: z.url() +}); + +export type $InstrumentRecordFiles = z.infer; +export const $InstrumentRecordFiles = z.record(z.string(), z.array($InstrumentRecordFile)); diff --git a/packages/schemas/src/instrument/instrument.base.ts b/packages/schemas/src/instrument/instrument.base.ts index 585e1905d..42cc0ab1d 100644 --- a/packages/schemas/src/instrument/instrument.base.ts +++ b/packages/schemas/src/instrument/instrument.base.ts @@ -28,7 +28,7 @@ const $AnyDynamicFunction: z.ZodType<(...args: any[]) => any> = z .any() .refine((arg) => typeof arg === 'function', 'must be function'); -const $InstrumentKind = z.enum(['FORM', 'INTERACTIVE', 'SERIES']) satisfies z.ZodType; +const $InstrumentKind = z.enum(['FILE', 'FORM', 'INTERACTIVE', 'SERIES']) satisfies z.ZodType; const $$InstrumentLanguage = (language?: TLanguage) => { let resolvedSchema: z.ZodTypeAny = z.never(); @@ -264,7 +264,7 @@ const $BaseInstrumentBundleContainer = z.object({ type ScalarInstrumentBundleContainer = z.infer; const $ScalarInstrumentBundleContainer = $BaseInstrumentBundleContainer.extend({ bundle: z.string(), - kind: z.enum(['FORM', 'INTERACTIVE']) + kind: z.enum(['FILE', 'FORM', 'INTERACTIVE']) }); type SeriesInstrumentBundleContainer = z.infer; diff --git a/packages/schemas/src/instrument/instrument.core.ts b/packages/schemas/src/instrument/instrument.core.ts index 5c7701707..da850b299 100644 --- a/packages/schemas/src/instrument/instrument.core.ts +++ b/packages/schemas/src/instrument/instrument.core.ts @@ -1,6 +1,7 @@ import type { AnyInstrument, AnyScalarInstrument, + FileInstrument, FormInstrument, InstrumentLanguage, InteractiveInstrument, @@ -8,6 +9,7 @@ import type { } from '@opendatacapture/runtime-core'; import { z } from 'zod/v4'; +import { $$FileInstrument } from './instrument.file.js'; import { $$FormInstrument } from './instrument.form.js'; import { $$InteractiveInstrument } from './instrument.interactive.js'; import { $$SeriesInstrument } from './instrument.series.js'; @@ -15,9 +17,12 @@ import { $$SeriesInstrument } from './instrument.series.js'; const $$AnyScalarInstrument = (language?: TLanguage) => { return z.discriminatedUnion('kind', [ $$FormInstrument(language), - $$InteractiveInstrument(language) + $$InteractiveInstrument(language), + $$FileInstrument(language) ]) satisfies z.ZodType< - FormInstrument | InteractiveInstrument + | FileInstrument + | FormInstrument + | InteractiveInstrument >; }; @@ -27,8 +32,10 @@ const $$AnyInstrument = (language?: TLangu return z.discriminatedUnion('kind', [ $$FormInstrument(language), $$InteractiveInstrument(language), - $$SeriesInstrument(language) + $$SeriesInstrument(language), + $$FileInstrument(language) ]) satisfies z.ZodType< + | FileInstrument | FormInstrument | InteractiveInstrument | SeriesInstrument diff --git a/packages/schemas/src/instrument/instrument.file.ts b/packages/schemas/src/instrument/instrument.file.ts new file mode 100644 index 000000000..dc5aeb0b7 --- /dev/null +++ b/packages/schemas/src/instrument/instrument.file.ts @@ -0,0 +1,35 @@ +import type { FileInstrument, InstrumentLanguage } from '@opendatacapture/runtime-core'; +import { FILE_TYPES } from '@opendatacapture/runtime-core/constants'; +import { z } from 'zod/v4'; + +import { $$InstrumentUIOption, $$ScalarInstrument } from './instrument.base.js'; + +type $FileType = z.infer; +const $FileType = z.enum( + [FILE_TYPES.binary, FILE_TYPES.documents, FILE_TYPES.images, FILE_TYPES.spreadsheets, FILE_TYPES.structured].flat() +) satisfies z.ZodType; + +const $$FileGroup = (language?: TLanguage) => { + return z.object({ + basename: z.string(), + count: z.object({ + max: z.int().positive(), + min: z.int().positive() + }), + label: $$InstrumentUIOption(z.string(), language), + type: $FileType.nullable() + }) satisfies z.ZodType; +}; + +const $$FileInstrument = (language?: TLanguage) => { + return $$ScalarInstrument(language).extend({ + content: z.object({ + fileGroups: z.array($$FileGroup(language)) + }), + kind: z.literal('FILE') + }) satisfies z.ZodType>; +}; + +const $FileInstrument = $$FileInstrument() satisfies z.ZodType; + +export { $$FileInstrument, $FileInstrument }; diff --git a/packages/schemas/src/instrument/instrument.interactive.ts b/packages/schemas/src/instrument/instrument.interactive.ts index 4c47b6944..cb65d52b8 100644 --- a/packages/schemas/src/instrument/instrument.interactive.ts +++ b/packages/schemas/src/instrument/instrument.interactive.ts @@ -14,6 +14,9 @@ const $$InteractiveInstrument = (language? .optional() .readonly(), defaultFullscreen: z.boolean().optional(), + enableLanguageLock: z.boolean().optional(), + enableLanguageSelect: z.boolean().optional(), + enableLanguageToggle: z.boolean().optional(), html: z.string().optional(), meta: z.record(z.string(), z.string()).optional(), render: $AnyDynamicFunction, diff --git a/packages/schemas/src/instrument/instrument.ts b/packages/schemas/src/instrument/instrument.ts index 6e490c794..9be33d82d 100644 --- a/packages/schemas/src/instrument/instrument.ts +++ b/packages/schemas/src/instrument/instrument.ts @@ -1,5 +1,6 @@ export * from './instrument.base.js'; export * from './instrument.core.js'; +export * from './instrument.file.js'; export * from './instrument.form.js'; export * from './instrument.interactive.js'; export * from './instrument.series.js'; diff --git a/packages/schemas/src/storage/storage.ts b/packages/schemas/src/storage/storage.ts new file mode 100644 index 000000000..19a062285 --- /dev/null +++ b/packages/schemas/src/storage/storage.ts @@ -0,0 +1,29 @@ +import { z } from 'zod/v4'; + +export type $FileLocation = z.infer; +export const $FileLocation = z.object({ + basename: z.string(), + index: z.int() +}); + +export type $FileMetadata = z.infer; +export const $FileMetadata = z.object({ + location: $FileLocation, + name: z.string().min(1), + size: z.int().nonnegative() +}); + +export type $PresignedUrlInfo = z.infer; +export const $PresignedUrlInfo = z.object({ + exp: z.int(), + location: $FileLocation, + url: z.url() +}); + +export type $PresignedUrls = z.infer; +export const $PresignedUrls = z.record(z.string(), z.array($PresignedUrlInfo)); + +export type $UploadCompleteData = z.infer; +export const $UploadCompleteData = z.object({ + uploads: z.record(z.string(), z.array($FileMetadata)) +}); From 9f2e3284967f021be4221a1f3b7e04845276e6d7 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Thu, 11 Jun 2026 16:08:34 -0400 Subject: [PATCH 2/9] feat(api): add object storage service and file upload endpoints Add a StorageService backed by an S3-compatible client (presigned upload and download URLs, bucket bootstrap on init) and a FilesService/controller exposing list, presigned upload-url, and upload-complete routes scoped to an instrument record. Records for FILE instruments are created in a `pending` state and excluded from results until uploads complete. Wire up the new InstrumentRecordFile auth subject and abilities, add the storage env vars, register the storage module, and seed file instruments in demo data. Co-Authored-By: Claude Opus 4.8 --- apps/api/package.json | 2 + apps/api/prisma/schema.prisma | 61 ++++-- .../auth/__tests__/ability.factory.test.ts | 37 ++++ .../src/auth/__tests__/ability.utils.test.ts | 21 +- apps/api/src/auth/ability.factory.ts | 3 + apps/api/src/auth/ability.utils.ts | 14 +- apps/api/src/auth/auth.types.ts | 14 +- apps/api/src/core/schemas/env.schema.ts | 8 +- apps/api/src/demo/demo.service.ts | 8 + .../files/files.controller.ts | 43 ++++ .../instrument-records/files/files.service.ts | 189 ++++++++++++++++++ .../instrument-records/files/files.types.ts | 6 + .../instrument-records.module.ts | 9 +- .../instrument-records.service.ts | 4 +- apps/api/src/main.ts | 2 + apps/api/src/storage/storage.module.ts | 28 +++ apps/api/src/storage/storage.service.ts | 88 ++++++++ 17 files changed, 502 insertions(+), 35 deletions(-) create mode 100644 apps/api/src/instrument-records/files/files.controller.ts create mode 100644 apps/api/src/instrument-records/files/files.service.ts create mode 100644 apps/api/src/instrument-records/files/files.types.ts create mode 100644 apps/api/src/storage/storage.module.ts create mode 100644 apps/api/src/storage/storage.service.ts diff --git a/apps/api/package.json b/apps/api/package.json index 2c04dda45..8acad7336 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,6 +15,8 @@ "test": "env-cmd -f ../../.env vitest" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1044.0", + "@aws-sdk/s3-request-presigner": "^3.1044.0", "@casl/ability": "^6.7.5", "@casl/prisma": "^1.5.1", "@douglasneuroinformatics/libcrypto": "catalog:", diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 74594c8fd..82af8248f 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -103,22 +103,23 @@ type GroupSettings { } model Group { - createdAt DateTime @default(now()) @db.Date - updatedAt DateTime @updatedAt @db.Date - id String @id @default(auto()) @map("_id") @db.ObjectId + createdAt DateTime @default(now()) @db.Date + updatedAt DateTime @updatedAt @db.Date + id String @id @default(auto()) @map("_id") @db.ObjectId accessibleInstrumentIds String[] - accessibleInstruments Instrument[] @relation(fields: [accessibleInstrumentIds], references: [id]) + accessibleInstruments Instrument[] @relation(fields: [accessibleInstrumentIds], references: [id]) assignments Assignment[] auditLogs AuditLog[] instrumentRecords InstrumentRecord[] - name String @unique + instrumentRecordFiles InstrumentRecordFile[] + name String @unique settings GroupSettings sessions Session[] - subjects Subject[] @relation(fields: [subjectIds], references: [id]) + subjects Subject[] @relation(fields: [subjectIds], references: [id]) subjectIds String[] type GroupType - userIds String[] @db.ObjectId - users User[] @relation(fields: [userIds], references: [id]) + userIds String[] @db.ObjectId + users User[] @relation(fields: [userIds], references: [id]) @@map("GroupModel") } @@ -126,30 +127,48 @@ model Group { /// Instrument Records model InstrumentRecord { - createdAt DateTime @default(now()) @db.Date - updatedAt DateTime @updatedAt @db.Date - id String @id @default(auto()) @map("_id") @db.ObjectId + createdAt DateTime @default(now()) @db.Date + updatedAt DateTime @updatedAt @db.Date + id String @id @default(auto()) @map("_id") @db.ObjectId /// [ComputedMeasures] computedMeasures Json? data Json? - date DateTime @db.Date - group Group? @relation(fields: [groupId], references: [id]) - groupId String? @db.ObjectId - subject Subject @relation(fields: [subjectId], references: [id]) + date DateTime @db.Date + files InstrumentRecordFile[] + group Group? @relation(fields: [groupId], references: [id]) + groupId String? @db.ObjectId + subject Subject @relation(fields: [subjectId], references: [id]) subjectId String - instrument Instrument @relation(fields: [instrumentId], references: [id]) + instrument Instrument @relation(fields: [instrumentId], references: [id]) instrumentId String - assignment Assignment? @relation(fields: [assignmentId], references: [id]) - assignmentId String? @unique - session Session @relation(fields: [sessionId], references: [id]) - sessionId String @db.ObjectId + assignment Assignment? @relation(fields: [assignmentId], references: [id]) + assignmentId String? @unique + session Session @relation(fields: [sessionId], references: [id]) + sessionId String @db.ObjectId + pending Boolean? @@map("InstrumentRecordModel") } -// Instruments +model InstrumentRecordFile { + basename String + createdAt DateTime @default(now()) @db.Date + group Group? @relation(fields: [groupId], references: [id]) + groupId String? @db.ObjectId + id String @id @default(auto()) @map("_id") @db.ObjectId + index Int + name String + record InstrumentRecord @relation(fields: [recordId], references: [id]) + recordId String @db.ObjectId + size Int + + @@map("InstrumentRecordFileModel") +} + +// Instruments enum InstrumentKind { + FILE FORM INTERACTIVE SERIES diff --git a/apps/api/src/auth/__tests__/ability.factory.test.ts b/apps/api/src/auth/__tests__/ability.factory.test.ts index 1c77e7595..ac9218129 100644 --- a/apps/api/src/auth/__tests__/ability.factory.test.ts +++ b/apps/api/src/auth/__tests__/ability.factory.test.ts @@ -66,4 +66,41 @@ describe('AbilityFactory', () => { expect(ability.can('read', subject('User', { id: 'user-1' }) as any)).toBe(true); expect(ability.can('read', subject('User', { id: 'user-2' }) as any)).toBe(false); }); + + it('should scope standard user instrument record file uploads to their groups', () => { + const payload = { + additionalPermissions: undefined, + basePermissionLevel: 'STANDARD', + firstName: 'Test', + groups: [{ id: 'group-1' }], + id: 'user-1', + lastName: 'User', + permissions: [] as any, + username: 'standard-user' + }; + + const ability = abilityFactory.createForPayload(payload as any); + + expect(ability.can('create', subject('InstrumentRecordFile', { groupId: 'group-1' }) as any)).toBe(true); + expect(ability.can('create', subject('InstrumentRecordFile', { groupId: 'group-2' }) as any)).toBe(false); + expect(ability.can('read', subject('InstrumentRecordFile', { groupId: 'group-1' }) as any)).toBe(false); + }); + + it('should scope group manager instrument record file uploads to their groups', () => { + const payload = { + additionalPermissions: undefined, + basePermissionLevel: 'GROUP_MANAGER', + firstName: 'Test', + groups: [{ id: 'group-1' }], + id: 'user-1', + lastName: 'User', + permissions: [] as any, + username: 'manager-user' + }; + + const ability = abilityFactory.createForPayload(payload as any); + + expect(ability.can('create', subject('InstrumentRecordFile', { groupId: 'group-1' }) as any)).toBe(true); + expect(ability.can('create', subject('InstrumentRecordFile', { groupId: 'group-2' }) as any)).toBe(false); + }); }); diff --git a/apps/api/src/auth/__tests__/ability.utils.test.ts b/apps/api/src/auth/__tests__/ability.utils.test.ts index 340aa3bfe..b016e9ba1 100644 --- a/apps/api/src/auth/__tests__/ability.utils.test.ts +++ b/apps/api/src/auth/__tests__/ability.utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { accessibleQuery } from '../ability.utils'; +import { accessibleQuery, createAppAbility, forcedAppSubject } from '../ability.utils'; const accessibleBy = vi.hoisted(() => vi.fn()); @@ -22,3 +22,22 @@ describe('accessibleQuery', () => { expect(accessibleBy).toHaveBeenCalledExactlyOnceWith(ability, 'manage'); }); }); + +describe('forcedAppSubject', () => { + it('should limit access by groupId when ability is scoped to a group', () => { + const ability = createAppAbility([ + { action: 'create', conditions: { groupId: 'group-1' }, subject: 'InstrumentRecordFile' } + ]); + + expect(ability.can('create', forcedAppSubject('InstrumentRecordFile', { groupId: 'group-1' }))).toBe(true); + expect(ability.can('create', forcedAppSubject('InstrumentRecordFile', { groupId: 'group-2' }))).toBe(false); + }); + + it('should deny access when no groupId is provided and ability requires one', () => { + const ability = createAppAbility([ + { action: 'create', conditions: { groupId: 'group-1' }, subject: 'InstrumentRecordFile' } + ]); + + expect(ability.can('create', forcedAppSubject('InstrumentRecordFile', {}))).toBe(false); + }); +}); diff --git a/apps/api/src/auth/ability.factory.ts b/apps/api/src/auth/ability.factory.ts index aae44dd0e..f9d1ebda9 100644 --- a/apps/api/src/auth/ability.factory.ts +++ b/apps/api/src/auth/ability.factory.ts @@ -28,7 +28,9 @@ export class AbilityFactory { ability.can('manage', 'Group', { id: { in: groupIds } }); ability.can('read', 'Instrument'); ability.can('create', 'InstrumentRecord'); + ability.can('create', 'InstrumentRecordFile', { groupId: { in: groupIds } }); ability.can('read', 'InstrumentRecord', { groupId: { in: groupIds } }); + ability.can('read', 'InstrumentRecordFile', { groupId: { in: groupIds } }); ability.can('create', 'Session'); ability.can('read', 'Session', { groupId: { in: groupIds } }); ability.can('create', 'Subject'); @@ -39,6 +41,7 @@ export class AbilityFactory { ability.can('read', 'Group', { id: { in: groupIds } }); ability.can('read', 'Instrument'); ability.can('create', 'InstrumentRecord'); + ability.can('create', 'InstrumentRecordFile', { groupId: { in: groupIds } }); ability.can('read', 'Session', { groupId: { in: groupIds } }); ability.can('create', 'Session'); ability.can('create', 'Subject'); diff --git a/apps/api/src/auth/ability.utils.ts b/apps/api/src/auth/ability.utils.ts index d3b061a5c..1e7888251 100644 --- a/apps/api/src/auth/ability.utils.ts +++ b/apps/api/src/auth/ability.utils.ts @@ -1,4 +1,4 @@ -import { detectSubjectType } from '@casl/ability'; +import { detectSubjectType, subject } from '@casl/ability'; import { createPrismaAbility } from '@casl/prisma'; import type { PrismaQuery } from '@casl/prisma'; import { createAccessibleByFactory } from '@casl/prisma/runtime'; @@ -6,7 +6,7 @@ import type { AppSubject, Prisma } from '@prisma/client'; import type { PrismaModelWhereInputMap } from '@/core/prisma'; -import type { AppAbilities, AppAbility, AppAction, Permission } from './auth.types'; +import type { AppAbilities, AppAbility, AppAction, AppSubjectModels, AppSubjectName, Permission } from './auth.types'; const accessibleBy = createAccessibleByFactory(); @@ -17,6 +17,16 @@ export function detectAppSubject(obj: { [key: string]: any }) { return detectSubjectType(obj) as AppSubject; } +export function forcedAppSubject>( + name: TSubjectName, + obj: Partial +) { + return subject(name, { + __modelName: name, + ...obj + } as unknown as AppSubjectModels[TSubjectName]); +} + export function createAppAbility(permissions: Permission[]): AppAbility { return createPrismaAbility(permissions, { detectSubjectType: detectAppSubject diff --git a/apps/api/src/auth/auth.types.ts b/apps/api/src/auth/auth.types.ts index f46a80b82..ca9d1d647 100644 --- a/apps/api/src/auth/auth.types.ts +++ b/apps/api/src/auth/auth.types.ts @@ -6,18 +6,20 @@ import type { DefaultSelection } from '@prisma/client/runtime/library'; type AppAction = 'create' | 'delete' | 'manage' | 'read' | 'update'; -type AppSubjects = - | 'all' - | Subjects<{ - [K in keyof Prisma.TypeMap['model']]: DefaultSelection; - }>; +type AppSubjectModels = { + [K in keyof Prisma.TypeMap['model']]: DefaultSelection; +}; + +type AppSubjects = 'all' | Subjects; type AppSubjectName = Extract; +type AppSubjectModel = Extract; + type AppAbilities = [AppAction, AppSubjects]; type AppAbility = PureAbility; type Permission = RawRuleOf>; -export type { AppAbilities, AppAbility, AppAction, AppSubjectName, Permission }; +export type { AppAbilities, AppAbility, AppAction, AppSubjectModel, AppSubjectModels, AppSubjectName, Permission }; diff --git a/apps/api/src/core/schemas/env.schema.ts b/apps/api/src/core/schemas/env.schema.ts index 5abaca180..0dbb93faa 100644 --- a/apps/api/src/core/schemas/env.schema.ts +++ b/apps/api/src/core/schemas/env.schema.ts @@ -13,7 +13,13 @@ export const $Env = $BaseEnv GATEWAY_ENABLED: $BooleanLike, GATEWAY_INTERNAL_NETWORK_URL: $UrlLike.optional(), GATEWAY_REFRESH_INTERVAL: $NumberLike.pipe(z.number().positive().int()), - GATEWAY_SITE_ADDRESS: $UrlLike.optional() + GATEWAY_SITE_ADDRESS: $UrlLike.optional(), + STORAGE_ACCESS_KEY: z.string().min(1), + STORAGE_BUCKET: z.string().min(1), + STORAGE_ENDPOINT: z.url(), + STORAGE_PUBLIC_ENDPOINT: z.url().optional(), + STORAGE_REGION: z.string().optional(), + STORAGE_SECRET_KEY: z.string().min(1) }) .transform((env, ctx) => { if (env.NODE_ENV === 'production') { diff --git a/apps/api/src/demo/demo.service.ts b/apps/api/src/demo/demo.service.ts index 854a6210f..96c4e52a3 100644 --- a/apps/api/src/demo/demo.service.ts +++ b/apps/api/src/demo/demo.service.ts @@ -3,6 +3,8 @@ import { InjectPrismaClient, LoggingService } from '@douglasneuroinformatics/lib import { faker } from '@faker-js/faker'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { DEMO_GROUPS, DEMO_USERS } from '@opendatacapture/demo'; +import arbitrarySingleFileInstrument from '@opendatacapture/instrument-library/file/ARBITRARY_SINGLE_FILE.js'; +import mriScanSessionInstrument from '@opendatacapture/instrument-library/file/MRI_SCAN_SESSION.js'; import enhancedDemographicsQuestionnaire from '@opendatacapture/instrument-library/forms/DNP_ENHANCED_DEMOGRAPHICS_QUESTIONNAIRE.js'; import generalConsentForm from '@opendatacapture/instrument-library/forms/DNP_GENERAL_CONSENT_FORM.js'; import happinessQuestionnaire from '@opendatacapture/instrument-library/forms/DNP_HAPPINESS_QUESTIONNAIRE.js'; @@ -72,6 +74,12 @@ export class DemoService { await this.instrumentsService.create({ bundle: happinessQuestionnaireWithConsent }); this.loggingService.debug('Done creating series instruments'); + await Promise.all([ + this.instrumentsService.create({ bundle: arbitrarySingleFileInstrument }), + this.instrumentsService.create({ bundle: mriScanSessionInstrument }) + ]); + this.loggingService.debug('Done creating file instruments'); + const groups: (Group & { dummyIdPrefix?: string })[] = []; for (const group of DEMO_GROUPS) { const { dummyIdPrefix, ...createGroupData } = group; diff --git a/apps/api/src/instrument-records/files/files.controller.ts b/apps/api/src/instrument-records/files/files.controller.ts new file mode 100644 index 000000000..80f8d3a27 --- /dev/null +++ b/apps/api/src/instrument-records/files/files.controller.ts @@ -0,0 +1,43 @@ +import { CurrentUser, ValidObjectIdPipe } from '@douglasneuroinformatics/libnest'; +import type { RequestUser } from '@douglasneuroinformatics/libnest'; +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import type { $InstrumentRecordFiles } from '@opendatacapture/schemas/instrument-records'; +// import type { $InstrumentRecordFiles } from '@opendatacapture/schemas/instrument-records'; +import { $PresignedUrls, $UploadCompleteData } from '@opendatacapture/schemas/storage'; + +import { RouteAccess } from '@/core/decorators/route-access.decorator'; + +import { FilesService } from './files.service'; + +@Controller('instrument-records/:recordId/files') +export class FilesController { + constructor(private readonly filesService: FilesService) {} + + @Get() + @RouteAccess({ action: 'read', subject: 'InstrumentRecordFile' }) + find( + @Param('recordId', ValidObjectIdPipe) recordId: string, + @CurrentUser() currentUser: RequestUser + ): Promise<$InstrumentRecordFiles> { + return this.filesService.find(recordId, currentUser); + } + + @Get('upload-urls') + @RouteAccess({ action: 'create', subject: 'InstrumentRecordFile' }) + getUploadUrls( + @Param('recordId', ValidObjectIdPipe) recordId: string, + @CurrentUser() currentUser: RequestUser + ): Promise<$PresignedUrls> { + return this.filesService.getPresignedUploadUrls(recordId, currentUser); + } + + @Post('upload-complete') + @RouteAccess({ action: 'create', subject: 'InstrumentRecordFile' }) + setUploadComplete( + @Param('recordId', ValidObjectIdPipe) recordId: string, + @Body() data: $UploadCompleteData, + @CurrentUser() currentUser: RequestUser + ): Promise { + return this.filesService.setUploadComplete(recordId, data, currentUser); + } +} diff --git a/apps/api/src/instrument-records/files/files.service.ts b/apps/api/src/instrument-records/files/files.service.ts new file mode 100644 index 000000000..ed7a80a20 --- /dev/null +++ b/apps/api/src/instrument-records/files/files.service.ts @@ -0,0 +1,189 @@ +import { InjectModel } from '@douglasneuroinformatics/libnest'; +import type { Model, RequestUser } from '@douglasneuroinformatics/libnest'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, + UnprocessableEntityException +} from '@nestjs/common'; +import type { FileInstrument } from '@opendatacapture/runtime-core'; +import type { $InstrumentRecordFile, $InstrumentRecordFiles } from '@opendatacapture/schemas/instrument-records'; +import type { $FileMetadata, $PresignedUrls, $UploadCompleteData } from '@opendatacapture/schemas/storage'; +import { range } from 'lodash-es'; + +import { accessibleQuery, forcedAppSubject } from '@/auth/ability.utils'; +import { InstrumentsService } from '@/instruments/instruments.service'; +import { StorageService } from '@/storage/storage.service'; + +import type { FileUploadAssociations } from './files.types'; + +@Injectable() +export class FilesService { + constructor( + @InjectModel('InstrumentRecord') private readonly instrumentRecordModel: Model<'InstrumentRecord'>, + private readonly instrumentsService: InstrumentsService, + private readonly storageService: StorageService + ) {} + + async find(recordId: string, currentUser: RequestUser): Promise<$InstrumentRecordFiles> { + const record = await this.instrumentRecordModel.findUnique({ + include: { + files: true + }, + where: { + AND: [ + accessibleQuery(currentUser.ability, 'read', 'InstrumentRecord'), + { + files: { + every: accessibleQuery(currentUser.ability, 'read', 'InstrumentRecordFile') + } + } + ], + id: recordId + } + }); + + if (!record) { + throw new NotFoundException(`Could not find record with ID '${recordId}'`); + } + + const instrument = await this.findFileInstrumentById(record.instrumentId); + + const files: { [basename: string]: $InstrumentRecordFile[] } = {}; + for (const fileGroup of instrument.content.fileGroups) { + const groupFiles = record.files + .filter((file) => file.basename === fileGroup.basename) + .toSorted((a, b) => a.index - b.index); + files[fileGroup.basename] = await Promise.all( + groupFiles.map(async (file) => { + const presigned = await this.storageService.getPresignedDownloadUrl({ + groupId: file.groupId, + location: { + basename: file.basename, + index: file.index + }, + recordId: file.recordId + }); + return { + exp: presigned.exp, + name: file.name, + size: file.size, + url: presigned.url + }; + }) + ); + } + + return files; + } + + async getPresignedUploadUrls(recordId: string, currentUser: RequestUser): Promise<$PresignedUrls> { + const { groupId, instrument } = await this.getAssociations(recordId, currentUser); + + const presignedUrls: $PresignedUrls = {}; + for (const fileGroup of instrument.content.fileGroups) { + presignedUrls[fileGroup.basename] = await Promise.all( + range(fileGroup.count.max).map((index) => { + return this.storageService.getPresignedUploadUrl({ + groupId, + location: { + basename: fileGroup.basename, + index + }, + recordId + }); + }) + ); + } + + return presignedUrls; + } + + async setUploadComplete(recordId: string, data: $UploadCompleteData, currentUser: RequestUser): Promise { + const { groupId, instrument } = await this.getAssociations(recordId, currentUser); + await this.instrumentRecordModel.update({ + data: { + files: { + create: this.validateFiles(data.uploads, instrument.content.fileGroups).map((file) => ({ + basename: file.location.basename, + group: groupId ? { connect: { id: groupId } } : undefined, + index: file.location.index, + name: file.name, + size: file.size + })) + }, + pending: false + }, + where: { + id: recordId + } + }); + } + + private async findFileInstrumentById(instrumentId: string): Promise { + const instrument = await this.instrumentsService.findById(instrumentId); + if (instrument.kind !== 'FILE') { + throw new UnprocessableEntityException('Cannot perform operation for non-file instrument'); + } + return instrument; + } + + private async getAssociations(recordId: string, currentUser: RequestUser): Promise { + const record = await this.instrumentRecordModel.findUnique({ + select: { + groupId: true, + instrumentId: true, + pending: true + }, + where: { + id: recordId + } + }); + + if (!record) { + throw new NotFoundException(`Could not find record with ID '${recordId}'`); + } else if (!record.pending) { + throw new ConflictException('Upload already completed'); + } + + const canUpload = currentUser.ability.can( + 'create', + forcedAppSubject('InstrumentRecordFile', { groupId: record.groupId }) + ); + + if (!canUpload) { + throw new ForbiddenException(`Cannot upload files for record with ID '${recordId}'`); + } + + const instrument = await this.findFileInstrumentById(record.instrumentId); + + return { + groupId: record.groupId, + instrument + }; + } + + private validateFiles( + uploads: { [id: string]: $FileMetadata[] }, + fileGroups: FileInstrument.FileGroup[] + ): $FileMetadata[] { + const validatedFiles: $FileMetadata[] = []; + for (const fileGroup of fileGroups) { + const uploadedFiles = uploads[fileGroup.basename]; + const actual = uploadedFiles?.length ?? 0; + const { max, min } = fileGroup.count; + if (actual < min || actual > max) { + const expected = min === max ? `${min}` : `between ${min} and ${max}`; + throw new BadRequestException( + `Invalid file count for file group '${fileGroup.basename}': expected ${expected} file(s), but got '${actual}'` + ); + } + for (const file of uploadedFiles!) { + validatedFiles.push(file); + } + } + return validatedFiles; + } +} diff --git a/apps/api/src/instrument-records/files/files.types.ts b/apps/api/src/instrument-records/files/files.types.ts new file mode 100644 index 000000000..81d832b1f --- /dev/null +++ b/apps/api/src/instrument-records/files/files.types.ts @@ -0,0 +1,6 @@ +import type { FileInstrument } from '@opendatacapture/runtime-core'; + +export type FileUploadAssociations = { + groupId: null | string; + instrument: FileInstrument; +}; diff --git a/apps/api/src/instrument-records/instrument-records.module.ts b/apps/api/src/instrument-records/instrument-records.module.ts index 65c86d058..1c365e703 100644 --- a/apps/api/src/instrument-records/instrument-records.module.ts +++ b/apps/api/src/instrument-records/instrument-records.module.ts @@ -3,17 +3,20 @@ import { Module } from '@nestjs/common'; import { GroupsModule } from '@/groups/groups.module'; import { InstrumentsModule } from '@/instruments/instruments.module'; import { SessionsModule } from '@/sessions/sessions.module'; +import { StorageModule } from '@/storage/storage.module'; import { SubjectsModule } from '@/subjects/subjects.module'; import { UsersModule } from '@/users/users.module'; +import { FilesController } from './files/files.controller'; +import { FilesService } from './files/files.service'; import { InstrumentMeasuresService } from './instrument-measures.service'; import { InstrumentRecordsController } from './instrument-records.controller'; import { InstrumentRecordsService } from './instrument-records.service'; @Module({ - controllers: [InstrumentRecordsController], + controllers: [FilesController, InstrumentRecordsController], exports: [InstrumentRecordsService], - imports: [GroupsModule, InstrumentsModule, SessionsModule, SubjectsModule, UsersModule], - providers: [InstrumentMeasuresService, InstrumentRecordsService] + imports: [GroupsModule, InstrumentsModule, SessionsModule, SubjectsModule, StorageModule, UsersModule], + providers: [FilesService, InstrumentMeasuresService, InstrumentRecordsService] }) export class InstrumentRecordsModule {} diff --git a/apps/api/src/instrument-records/instrument-records.service.ts b/apps/api/src/instrument-records/instrument-records.service.ts index e032395ca..c06ed5976 100644 --- a/apps/api/src/instrument-records/instrument-records.service.ts +++ b/apps/api/src/instrument-records/instrument-records.service.ts @@ -112,6 +112,7 @@ export class InstrumentRecordsService { id: instrumentId } }, + pending: instrument.kind === 'FILE', session: { connect: { id: sessionId @@ -230,7 +231,8 @@ export class InstrumentRecordsService { { instrumentId }, { instrumentId: { in: instrumentKindIds } }, accessibleQuery(ability, 'read', 'InstrumentRecord'), - { subjectId } + { subjectId }, + { NOT: { pending: true } } ] } }); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 3215261b5..2e45064a2 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -11,6 +11,7 @@ import { InstrumentRecordsModule } from './instrument-records/instrument-records import { InstrumentsModule } from './instruments/instruments.module'; import { SessionsModule } from './sessions/sessions.module'; import { SetupModule } from './setup/setup.module'; +import { StorageModule } from './storage/storage.module'; import { SubjectsModule } from './subjects/subjects.module'; import { SummaryModule } from './summary/summary.module'; import { UsersModule } from './users/users.module'; @@ -48,6 +49,7 @@ export default AppFactory.create({ }), SessionsModule, SetupModule, + StorageModule, SubjectsModule, SummaryModule, UsersModule, diff --git a/apps/api/src/storage/storage.module.ts b/apps/api/src/storage/storage.module.ts new file mode 100644 index 000000000..ddabf40cd --- /dev/null +++ b/apps/api/src/storage/storage.module.ts @@ -0,0 +1,28 @@ +import { S3Client } from '@aws-sdk/client-s3'; +import { ConfigService } from '@douglasneuroinformatics/libnest'; +import { Module } from '@nestjs/common'; + +import { StorageService } from './storage.service'; + +@Module({ + exports: [StorageService], + providers: [ + { + inject: [ConfigService], + provide: S3Client, + useFactory: (configService: ConfigService): S3Client => { + return new S3Client({ + credentials: { + accessKeyId: configService.get('STORAGE_ACCESS_KEY'), + secretAccessKey: configService.get('STORAGE_SECRET_KEY') + }, + endpoint: configService.get('STORAGE_ENDPOINT'), + forcePathStyle: true, + region: configService.get('STORAGE_REGION') + }); + } + }, + StorageService + ] +}) +export class StorageModule {} diff --git a/apps/api/src/storage/storage.service.ts b/apps/api/src/storage/storage.service.ts new file mode 100644 index 000000000..c59fd3e32 --- /dev/null +++ b/apps/api/src/storage/storage.service.ts @@ -0,0 +1,88 @@ +import { + CreateBucketCommand, + GetObjectCommand, + HeadBucketCommand, + PutObjectCommand, + S3Client +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { ConfigService } from '@douglasneuroinformatics/libnest'; +import { Injectable } from '@nestjs/common'; +import type { OnModuleInit } from '@nestjs/common'; +import type { $FileLocation, $PresignedUrlInfo } from '@opendatacapture/schemas/storage'; + +type FileSearchParams = { + groupId: null | string; + location: $FileLocation; + recordId: string; +}; + +export type StorageKey = `groups/${string}/records/${string}/files/${string}`; + +@Injectable() +export class StorageService implements OnModuleInit { + private readonly bucket: string; + private readonly publicStorageEndpoint: string; + private readonly storageEndpoint: string; + + constructor( + private readonly configService: ConfigService, + private readonly s3: S3Client + ) { + this.bucket = this.configService.get('STORAGE_BUCKET'); + this.storageEndpoint = this.configService.get('STORAGE_ENDPOINT'); + this.publicStorageEndpoint = this.configService.get('STORAGE_PUBLIC_ENDPOINT') ?? this.storageEndpoint; + } + + async getPresignedDownloadUrl(params: FileSearchParams): Promise<$PresignedUrlInfo> { + const key = this.getStorageKey(params); + + const command = new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + ResponseContentDisposition: `attachment; filename="${this.getFileKey(params.location)}"` + }); + + const url = await getSignedUrl(this.s3, command, { expiresIn: 60 * 15 }); + const publicUrl = this.transformUrlForPublicAccess(url); + const exp = Date.now() + 1000 * 60 * 15; // 15 minutes for downloads + return { exp, location: params.location, url: publicUrl }; + } + + async getPresignedUploadUrl(params: FileSearchParams): Promise<$PresignedUrlInfo> { + const key = this.getStorageKey(params); + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: key + }); + const url = await getSignedUrl(this.s3, command, { expiresIn: 60 * 5 }); + const publicUrl = this.transformUrlForPublicAccess(url); + const exp = Date.now() + 1000 * 60 * 5; // make sure computed after generation + return { exp, location: params.location, url: publicUrl }; + } + + async onModuleInit(): Promise { + if (this.configService.get('NODE_ENV') !== 'test') { + try { + await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket })); + } catch { + await this.s3.send(new CreateBucketCommand({ Bucket: this.bucket })); + } + } + } + + private getFileKey(location: $FileLocation) { + return `${location.basename}_${location.index}`; + } + + private getStorageKey({ groupId, location, recordId }: FileSearchParams): StorageKey { + return `groups/${groupId ?? '__ROOT__'}/records/${recordId}/files/${this.getFileKey(location)}`; + } + + private transformUrlForPublicAccess(url: string): string { + if (this.publicStorageEndpoint === this.storageEndpoint) { + return url; + } + return url.replace(this.storageEndpoint, this.publicStorageEndpoint); + } +} From 45540ed1b784d50369df4e4831e94ea972bc5a82 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Thu, 11 Jun 2026 16:08:48 -0400 Subject: [PATCH 3/9] feat(web): add file instrument upload and download UI Add the FileInstrumentContent components (dropzone, upload progress, error box, upload store) and a NavigationBlockerDialog to guard against leaving mid-upload, and render them through the instrument renderer. On the web app, wire presigned upload/download via useInstrumentRecordFilesQuery and the upload mutation, add a per-record file view under the datahub table route, and allow opting out of the default request timeout for long uploads. Co-Authored-By: Claude Opus 4.8 --- apps/web/package.json | 1 + apps/web/src/components/NavigationBlocker.tsx | 21 ++ .../src/hooks/useCreateSetupStateMutation.ts | 7 +- .../hooks/useInstrumentRecordFilesQuery.ts | 17 ++ .../useUploadInstrumentRecordsMutation.ts | 22 +- apps/web/src/route-tree.ts | 89 +++---- .../_app/datahub/$subjectId/$recordId.tsx | 41 ---- .../routes/_app/datahub/$subjectId/route.tsx | 2 +- .../datahub/$subjectId/table/$recordId.tsx | 153 ++++++++++++ .../$subjectId/{table.tsx => table/index.tsx} | 65 +++-- apps/web/src/routes/_app/datahub/index.tsx | 3 + .../routes/_app/instruments/render/$id.tsx | 63 ++++- apps/web/src/routes/_app/user.tsx | 16 +- apps/web/src/services/axios.ts | 20 +- apps/web/src/translations/datahub.json | 22 ++ packages/react-core/package.json | 6 + .../FileInstrumentContent/Dropzone.tsx | 183 ++++++++++++++ .../FileInstrumentContent/ErrorBox.tsx | 34 +++ .../FileInstrumentContent.tsx | 88 +++++++ .../UploadProgressBar.tsx | 38 +++ .../components/FileInstrumentContent/index.ts | 2 + .../components/FileInstrumentContent/store.ts | 111 +++++++++ .../components/FileInstrumentContent/types.ts | 65 +++++ .../components/FormContent/FormContent.tsx | 10 +- .../InstrumentIcon/InstrumentIcon.tsx | 4 +- .../InstrumentRenderer.stories.tsx | 68 +++++- .../InstrumentRenderer/InstrumentRenderer.tsx | 9 +- .../ScalarInstrumentRenderer.tsx | 32 ++- .../SeriesInstrumentContent.tsx | 15 +- .../SeriesInstrumentRenderer.tsx | 11 +- .../components/InstrumentRenderer/index.ts | 1 + .../components/InstrumentRenderer/types.ts | 41 ++++ .../InteractiveContent.stories.tsx | 2 +- .../InteractiveContent/InteractiveContent.tsx | 229 ++++++++++++++---- .../NavigationBlockerDialog.tsx | 45 ++++ .../NavigationBlockerDialog/index.ts | 1 + .../src/hooks/useInterpretedInstrument.ts | 16 +- packages/react-core/src/index.ts | 5 +- packages/react-core/src/types.ts | 7 - 39 files changed, 1338 insertions(+), 227 deletions(-) create mode 100644 apps/web/src/components/NavigationBlocker.tsx create mode 100644 apps/web/src/hooks/useInstrumentRecordFilesQuery.ts delete mode 100644 apps/web/src/routes/_app/datahub/$subjectId/$recordId.tsx create mode 100644 apps/web/src/routes/_app/datahub/$subjectId/table/$recordId.tsx rename apps/web/src/routes/_app/datahub/$subjectId/{table.tsx => table/index.tsx} (54%) create mode 100644 packages/react-core/src/components/FileInstrumentContent/Dropzone.tsx create mode 100644 packages/react-core/src/components/FileInstrumentContent/ErrorBox.tsx create mode 100644 packages/react-core/src/components/FileInstrumentContent/FileInstrumentContent.tsx create mode 100644 packages/react-core/src/components/FileInstrumentContent/UploadProgressBar.tsx create mode 100644 packages/react-core/src/components/FileInstrumentContent/index.ts create mode 100644 packages/react-core/src/components/FileInstrumentContent/store.ts create mode 100644 packages/react-core/src/components/FileInstrumentContent/types.ts create mode 100644 packages/react-core/src/components/InstrumentRenderer/types.ts create mode 100644 packages/react-core/src/components/NavigationBlockerDialog/NavigationBlockerDialog.tsx create mode 100644 packages/react-core/src/components/NavigationBlockerDialog/index.ts diff --git a/apps/web/package.json b/apps/web/package.json index f22fabd1b..f3bc55557 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "format:translations": "find src/translations -name '*.json' -exec pnpm exec sort-json {} \\;", "inject": "pnpm exec import-meta-env -x .env.public -p dist/index.html", "lint": "tsc && eslint --fix src", + "start": "NODE_ENV=production env-cmd -f ../../.env pnpm exec import-meta-env -x .env.public -p dist/index.html && python -m http.server -d dist 3000", "storybook": "NODE_OPTIONS='--import=tsx' env-cmd -f ../../.env storybook dev -p 6006", "test": "env-cmd -f ../../.env vitest" }, diff --git a/apps/web/src/components/NavigationBlocker.tsx b/apps/web/src/components/NavigationBlocker.tsx new file mode 100644 index 000000000..6045c203c --- /dev/null +++ b/apps/web/src/components/NavigationBlocker.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import { NavigationBlockerDialog } from '@opendatacapture/react-core'; +import type { NavigationBlockerProps } from '@opendatacapture/react-core'; +import { useBlocker } from '@tanstack/react-router'; + +export const NavigationBlocker: React.FC = ({ active, message }) => { + const blocker = useBlocker({ + enableBeforeUnload: true, + shouldBlockFn: () => active, + withResolver: true + }); + return ( + + ); +}; diff --git a/apps/web/src/hooks/useCreateSetupStateMutation.ts b/apps/web/src/hooks/useCreateSetupStateMutation.ts index d4e2a329c..115515df8 100644 --- a/apps/web/src/hooks/useCreateSetupStateMutation.ts +++ b/apps/web/src/hooks/useCreateSetupStateMutation.ts @@ -9,7 +9,12 @@ export function useCreateSetupStateMutation() { const queryClient = useQueryClient(); const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ - mutationFn: (data: InitAppOptions) => axios.post('/v1/setup', data), + mutationFn: (data: InitAppOptions) => + axios.post('/v1/setup', data, { + meta: { + disableDefaultTimeout: true + } + }), onSuccess: async () => { addNotification({ type: 'success' }); await queryClient.invalidateQueries({ queryKey: [SETUP_STATE_QUERY_KEY] }); diff --git a/apps/web/src/hooks/useInstrumentRecordFilesQuery.ts b/apps/web/src/hooks/useInstrumentRecordFilesQuery.ts new file mode 100644 index 000000000..97ea6ed5a --- /dev/null +++ b/apps/web/src/hooks/useInstrumentRecordFilesQuery.ts @@ -0,0 +1,17 @@ +import { $InstrumentRecordFiles } from '@opendatacapture/schemas/instrument-records'; +import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +export const instrumentRecordFilesQueryOptions = ({ params }: { params: { id: string } }) => { + return queryOptions({ + queryFn: async () => { + const response = await axios.get(`/v1/instrument-records/${params.id}/files`); + return $InstrumentRecordFiles.parse(response.data); + }, + queryKey: ['instrument-records', `id-${params.id}`, 'files'] + }); +}; + +export function useInstrumentRecordFilesQuery({ params }: { params: { id: string } }) { + return useSuspenseQuery(instrumentRecordFilesQueryOptions({ params })); +} diff --git a/apps/web/src/hooks/useUploadInstrumentRecordsMutation.ts b/apps/web/src/hooks/useUploadInstrumentRecordsMutation.ts index 9f37787ad..fc435e3b6 100644 --- a/apps/web/src/hooks/useUploadInstrumentRecordsMutation.ts +++ b/apps/web/src/hooks/useUploadInstrumentRecordsMutation.ts @@ -9,13 +9,21 @@ export function useUploadInstrumentRecordsMutation() { const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ mutationFn: async (data: UploadInstrumentRecordsData) => { - await axios.post('/v1/instrument-records/upload', { - ...data, - records: data.records.map((record) => ({ - ...record, - data: JSON.parse(JSON.stringify(record.data, replacer)) as Json - })) - } satisfies UploadInstrumentRecordsData); + await axios.post( + '/v1/instrument-records/upload', + { + ...data, + records: data.records.map((record) => ({ + ...record, + data: JSON.parse(JSON.stringify(record.data, replacer)) as Json + })) + } satisfies UploadInstrumentRecordsData, + { + meta: { + disableDefaultTimeout: true + } + } + ); }, onSuccess() { addNotification({ type: 'success' }); diff --git a/apps/web/src/route-tree.ts b/apps/web/src/route-tree.ts index 2ebe4eecf..feccde578 100644 --- a/apps/web/src/route-tree.ts +++ b/apps/web/src/route-tree.ts @@ -28,13 +28,13 @@ import { Route as AppDatahubSubjectIdRouteRouteImport } from './routes/_app/data import { Route as AppAdminUsersIndexRouteImport } from './routes/_app/admin/users/index' import { Route as AppAdminGroupsIndexRouteImport } from './routes/_app/admin/groups/index' import { Route as AppInstrumentsRenderIdRouteImport } from './routes/_app/instruments/render/$id' -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' import { Route as AppAdminAuditLogsRouteImport } from './routes/_app/admin/audit/logs' +import { Route as AppDatahubSubjectIdTableIndexRouteImport } from './routes/_app/datahub/$subjectId/table/index' +import { Route as AppDatahubSubjectIdTableRecordIdRouteImport } from './routes/_app/datahub/$subjectId/table/$recordId' const SetupRoute = SetupRouteImport.update({ id: '/setup', @@ -132,12 +132,6 @@ const AppInstrumentsRenderIdRoute = AppInstrumentsRenderIdRouteImport.update({ path: '/instruments/render/$id', getParentRoute: () => AppRouteRoute, } as any) -const AppDatahubSubjectIdTableRoute = - AppDatahubSubjectIdTableRouteImport.update({ - id: '/table', - path: '/table', - getParentRoute: () => AppDatahubSubjectIdRouteRoute, - } as any) const AppDatahubSubjectIdGraphRoute = AppDatahubSubjectIdGraphRouteImport.update({ id: '/graph', @@ -150,12 +144,6 @@ 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', @@ -171,6 +159,18 @@ const AppAdminAuditLogsRoute = AppAdminAuditLogsRouteImport.update({ path: '/admin/audit/logs', getParentRoute: () => AppRouteRoute, } as any) +const AppDatahubSubjectIdTableIndexRoute = + AppDatahubSubjectIdTableIndexRouteImport.update({ + id: '/table/', + path: '/table/', + getParentRoute: () => AppDatahubSubjectIdRouteRoute, + } as any) +const AppDatahubSubjectIdTableRecordIdRoute = + AppDatahubSubjectIdTableRecordIdRouteImport.update({ + id: '/table/$recordId', + path: '/table/$recordId', + getParentRoute: () => AppDatahubSubjectIdRouteRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof AppIndexRoute @@ -191,13 +191,13 @@ export interface FileRoutesByFullPath { '/admin/audit/logs': typeof AppAdminAuditLogsRoute '/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 '/instruments/render/$id': typeof AppInstrumentsRenderIdRoute '/admin/groups/': typeof AppAdminGroupsIndexRoute '/admin/users/': typeof AppAdminUsersIndexRoute + '/datahub/$subjectId/table/$recordId': typeof AppDatahubSubjectIdTableRecordIdRoute + '/datahub/$subjectId/table/': typeof AppDatahubSubjectIdTableIndexRoute } export interface FileRoutesByTo { '/setup': typeof SetupRoute @@ -218,13 +218,13 @@ export interface FileRoutesByTo { '/admin/audit/logs': typeof AppAdminAuditLogsRoute '/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 '/instruments/render/$id': typeof AppInstrumentsRenderIdRoute '/admin/groups': typeof AppAdminGroupsIndexRoute '/admin/users': typeof AppAdminUsersIndexRoute + '/datahub/$subjectId/table/$recordId': typeof AppDatahubSubjectIdTableRecordIdRoute + '/datahub/$subjectId/table': typeof AppDatahubSubjectIdTableIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -247,13 +247,13 @@ export interface FileRoutesById { '/_app/admin/audit/logs': typeof AppAdminAuditLogsRoute '/_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 '/_app/instruments/render/$id': typeof AppInstrumentsRenderIdRoute '/_app/admin/groups/': typeof AppAdminGroupsIndexRoute '/_app/admin/users/': typeof AppAdminUsersIndexRoute + '/_app/datahub/$subjectId/table/$recordId': typeof AppDatahubSubjectIdTableRecordIdRoute + '/_app/datahub/$subjectId/table/': typeof AppDatahubSubjectIdTableIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -276,13 +276,13 @@ export interface FileRouteTypes { | '/admin/audit/logs' | '/admin/groups/create' | '/admin/users/create' - | '/datahub/$subjectId/$recordId' | '/datahub/$subjectId/assignments' | '/datahub/$subjectId/graph' - | '/datahub/$subjectId/table' | '/instruments/render/$id' | '/admin/groups/' | '/admin/users/' + | '/datahub/$subjectId/table/$recordId' + | '/datahub/$subjectId/table/' fileRoutesByTo: FileRoutesByTo to: | '/setup' @@ -303,13 +303,13 @@ export interface FileRouteTypes { | '/admin/audit/logs' | '/admin/groups/create' | '/admin/users/create' - | '/datahub/$subjectId/$recordId' | '/datahub/$subjectId/assignments' | '/datahub/$subjectId/graph' - | '/datahub/$subjectId/table' | '/instruments/render/$id' | '/admin/groups' | '/admin/users' + | '/datahub/$subjectId/table/$recordId' + | '/datahub/$subjectId/table' id: | '__root__' | '/_app' @@ -331,13 +331,13 @@ export interface FileRouteTypes { | '/_app/admin/audit/logs' | '/_app/admin/groups/create' | '/_app/admin/users/create' - | '/_app/datahub/$subjectId/$recordId' | '/_app/datahub/$subjectId/assignments' | '/_app/datahub/$subjectId/graph' - | '/_app/datahub/$subjectId/table' | '/_app/instruments/render/$id' | '/_app/admin/groups/' | '/_app/admin/users/' + | '/_app/datahub/$subjectId/table/$recordId' + | '/_app/datahub/$subjectId/table/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -481,13 +481,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppInstrumentsRenderIdRouteImport parentRoute: typeof AppRouteRoute } - '/_app/datahub/$subjectId/table': { - id: '/_app/datahub/$subjectId/table' - path: '/table' - fullPath: '/datahub/$subjectId/table' - preLoaderRoute: typeof AppDatahubSubjectIdTableRouteImport - parentRoute: typeof AppDatahubSubjectIdRouteRoute - } '/_app/datahub/$subjectId/graph': { id: '/_app/datahub/$subjectId/graph' path: '/graph' @@ -502,13 +495,6 @@ 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' @@ -530,22 +516,37 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppAdminAuditLogsRouteImport parentRoute: typeof AppRouteRoute } + '/_app/datahub/$subjectId/table/': { + id: '/_app/datahub/$subjectId/table/' + path: '/table' + fullPath: '/datahub/$subjectId/table/' + preLoaderRoute: typeof AppDatahubSubjectIdTableIndexRouteImport + parentRoute: typeof AppDatahubSubjectIdRouteRoute + } + '/_app/datahub/$subjectId/table/$recordId': { + id: '/_app/datahub/$subjectId/table/$recordId' + path: '/table/$recordId' + fullPath: '/datahub/$subjectId/table/$recordId' + preLoaderRoute: typeof AppDatahubSubjectIdTableRecordIdRouteImport + parentRoute: typeof AppDatahubSubjectIdRouteRoute + } } } interface AppDatahubSubjectIdRouteRouteChildren { - AppDatahubSubjectIdRecordIdRoute: typeof AppDatahubSubjectIdRecordIdRoute AppDatahubSubjectIdAssignmentsRoute: typeof AppDatahubSubjectIdAssignmentsRoute AppDatahubSubjectIdGraphRoute: typeof AppDatahubSubjectIdGraphRoute - AppDatahubSubjectIdTableRoute: typeof AppDatahubSubjectIdTableRoute + AppDatahubSubjectIdTableRecordIdRoute: typeof AppDatahubSubjectIdTableRecordIdRoute + AppDatahubSubjectIdTableIndexRoute: typeof AppDatahubSubjectIdTableIndexRoute } const AppDatahubSubjectIdRouteRouteChildren: AppDatahubSubjectIdRouteRouteChildren = { - AppDatahubSubjectIdRecordIdRoute: AppDatahubSubjectIdRecordIdRoute, AppDatahubSubjectIdAssignmentsRoute: AppDatahubSubjectIdAssignmentsRoute, AppDatahubSubjectIdGraphRoute: AppDatahubSubjectIdGraphRoute, - AppDatahubSubjectIdTableRoute: AppDatahubSubjectIdTableRoute, + AppDatahubSubjectIdTableRecordIdRoute: + AppDatahubSubjectIdTableRecordIdRoute, + AppDatahubSubjectIdTableIndexRoute: AppDatahubSubjectIdTableIndexRoute, } const AppDatahubSubjectIdRouteRouteWithChildren = diff --git a/apps/web/src/routes/_app/datahub/$subjectId/$recordId.tsx b/apps/web/src/routes/_app/datahub/$subjectId/$recordId.tsx deleted file mode 100644 index 66a9b36de..000000000 --- a/apps/web/src/routes/_app/datahub/$subjectId/$recordId.tsx +++ /dev/null @@ -1,41 +0,0 @@ -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 ( -
- -
- ); -}; - -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 } })); - } -}); diff --git a/apps/web/src/routes/_app/datahub/$subjectId/route.tsx b/apps/web/src/routes/_app/datahub/$subjectId/route.tsx index f043f5cc5..69e0bf3e7 100644 --- a/apps/web/src/routes/_app/datahub/$subjectId/route.tsx +++ b/apps/web/src/routes/_app/datahub/$subjectId/route.tsx @@ -13,7 +13,7 @@ import { useAppStore } from '@/store'; const TabLink = ({ label, pathname, testId }: { label: string; pathname: string; testId?: string }) => { const location = useLocation(); - const isActive = location.pathname === pathname; + const isActive = location.pathname.startsWith(pathname); return ( { + const { resolvedLanguage, t } = useTranslation(); + const notifications = useNotificationsStore(); + const { data: files } = useInstrumentRecordFilesQuery({ params: { id: record.id } }); + + const handleDownload = async (file: { name: string; url: string }) => { + try { + const response = await fetch(file.url); + if (!response.ok) { + throw new Error(`Failed to download (${response.status})`); + } + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = file.name; + document.body.appendChild(anchor); + anchor.click(); + URL.revokeObjectURL(objectUrl); + anchor.remove(); + } catch (error) { + console.error(error); + notifications.addNotification({ + message: t({ + en: 'An unexpected error occurred', + fr: "Une erreur inattendue s'est produite" + }), + title: 'Error', + type: 'error' + }); + } + }; + + const dateCompleted = record.date.toLocaleString(resolvedLanguage, { + dateStyle: 'long', + timeStyle: 'long' + }); + + return ( +
+
+ {instrument.details.title} +

+ {t({ + en: `Completed on ${dateCompleted}`, + fr: `Remplie le ${dateCompleted}` + })} +

+
+ {record.pending ? ( +
+

{t({ en: 'Upload pending', fr: 'Téléversement en attente' })}

+
+ ) : ( +
+ {instrument.content.fileGroups.map((fileGroup) => { + const groupFiles = files[fileGroup.basename] ?? []; + return ( +
+ {fileGroup.label} + {groupFiles.length === 0 ? ( +

{t({ en: 'No files', fr: 'Aucun fichier' })}

+ ) : ( +
    + {groupFiles.map((file) => ( +
  • +
    +

    {file.name}

    +

    {formatByteSize(file.size)}

    +
    + +
  • + ))} +
+ )} +
+ ); + })} +
+ )} +
+ ); +}; + +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 ( +
+ {instrument.kind === 'FILE' ? ( + + ) : ( + + )} +
+ ); +}; + +export const Route = createFileRoute('/_app/datahub/$subjectId/table/$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 } })); + } +}); diff --git a/apps/web/src/routes/_app/datahub/$subjectId/table.tsx b/apps/web/src/routes/_app/datahub/$subjectId/table/index.tsx similarity index 54% rename from apps/web/src/routes/_app/datahub/$subjectId/table.tsx rename to apps/web/src/routes/_app/datahub/$subjectId/table/index.tsx index 79d7d5dac..e29b88cb0 100644 --- a/apps/web/src/routes/_app/datahub/$subjectId/table.tsx +++ b/apps/web/src/routes/_app/datahub/$subjectId/table/index.tsx @@ -1,11 +1,14 @@ +import { useMemo } from 'react'; + import { camelToSnakeCase, toBasicISOString } from '@douglasneuroinformatics/libjs'; -import { ActionDropdown, ClientTable } from '@douglasneuroinformatics/libui/components'; +import { ActionDropdown, DataTable, TanstackTable } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { createFileRoute } from '@tanstack/react-router'; import { SelectInstrument } from '@/components/SelectInstrument'; import { TimeDropdown } from '@/components/TimeDropdown'; import { useInstrumentVisualization } from '@/hooks/useInstrumentVisualization'; +import type { InstrumentVisualizationRecord } from '@/hooks/useInstrumentVisualization'; const RouteComponent = () => { const navigate = Route.useNavigate(); @@ -16,19 +19,27 @@ const RouteComponent = () => { const { t } = useTranslation(); - const fields: { field: string; label: string }[] = []; - for (const subItem in records[0]) { - if (!subItem.startsWith('__')) { - fields.push({ - field: subItem, - label: camelToSnakeCase(subItem).toUpperCase() - }); + const columns = useMemo[]>(() => { + const columns: TanstackTable.ColumnDef[] = []; + for (const subItem in records[0]) { + if (!subItem.startsWith('__')) { + columns.push({ + accessorKey: subItem, + cell: (ctx) => { + const value = ctx.getValue(); + return

{String(value)}

; + }, + header: camelToSnakeCase(subItem).toUpperCase(), + id: subItem + }); + } } - } + return columns; + }, [records[0]]); return (
-
+
@@ -47,28 +58,40 @@ const RouteComponent = () => {
- toBasicISOString(value), - label: 'DATE_COLLECTED' + accessorKey: '__date__', + cell: (ctx) => { + const value = ctx.getValue(); + if (value instanceof Date) { + return toBasicISOString(value); + } + return value; + }, + header: 'DATE_COLLECTED' }, - ...fields + ...columns ]} data={records} data-testid="subject-table" - entriesPerPage={15} - minRows={15} - onEntryClick={(row) => { - void navigate({ params: { recordId: row.__id__ }, to: '/datahub/$subjectId/$recordId' }); + rowActions={[ + { + label: t('common.view'), + onSelect: (row) => { + void navigate({ params: { recordId: row.__id__ }, to: './$recordId' }); + } + } + ]} + onRowDoubleClick={(row) => { + void navigate({ params: { recordId: row.__id__ }, to: './$recordId' }); }} />
); }; -export const Route = createFileRoute('/_app/datahub/$subjectId/table')({ +export const Route = createFileRoute('/_app/datahub/$subjectId/table/')({ component: RouteComponent }); diff --git a/apps/web/src/routes/_app/datahub/index.tsx b/apps/web/src/routes/_app/datahub/index.tsx index 7c418a3cb..0a18cb31f 100644 --- a/apps/web/src/routes/_app/datahub/index.tsx +++ b/apps/web/src/routes/_app/datahub/index.tsx @@ -213,6 +213,9 @@ const Toggles: React.FC<{ const getExportRecords = async () => { const response = await axios.get('/v1/instrument-records/export', { + meta: { + disableDefaultTimeout: true + }, params: { groupId: currentGroup?.id }, diff --git a/apps/web/src/routes/_app/instruments/render/$id.tsx b/apps/web/src/routes/_app/instruments/render/$id.tsx index a642d6f42..02329cc55 100644 --- a/apps/web/src/routes/_app/instruments/render/$id.tsx +++ b/apps/web/src/routes/_app/instruments/render/$id.tsx @@ -5,10 +5,12 @@ import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/ import { InstrumentRenderer } from '@opendatacapture/react-core'; import type { InstrumentSubmitHandler } from '@opendatacapture/react-core'; import type { UnilingualInstrumentInfo } from '@opendatacapture/schemas/instrument'; -import type { CreateInstrumentRecordData } from '@opendatacapture/schemas/instrument-records'; +import type { CreateInstrumentRecordData, InstrumentRecord } from '@opendatacapture/schemas/instrument-records'; +import { $FileMetadata, $PresignedUrls, $UploadCompleteData } from '@opendatacapture/schemas/storage'; import { createFileRoute, useLocation, useNavigate } from '@tanstack/react-router'; import axios from 'axios'; +import { NavigationBlocker } from '@/components/NavigationBlocker'; import { PageHeader } from '@/components/PageHeader'; import { useInstrumentBundle } from '@/hooks/useInstrumentBundle'; import { useAppStore } from '@/store'; @@ -35,16 +37,64 @@ const RouteComponent = () => { } }, [currentSession?.id]); - const handleSubmit: InstrumentSubmitHandler = async ({ data, instrumentId }) => { - await axios.post('/v1/instrument-records', { - data, + const handleSubmit: InstrumentSubmitHandler = async (result) => { + const createRecordResponse = await axios.post('/v1/instrument-records', { + data: result.data, date: currentSession!.date, groupId: currentGroup?.id, - instrumentId, + instrumentId: result.instrumentId, sessionId: currentSession!.id, subjectId: currentSession!.subject!.id } satisfies CreateInstrumentRecordData); - notifications.addNotification({ type: 'success' }); + if (result.kind !== 'FILE') { + notifications.addNotification({ type: 'success' }); + return; + } + const record = createRecordResponse.data; + const { onNext, onProgress, uploadMap } = result; + + const presignedUrlsResponse = await axios.get<$PresignedUrls>( + `/v1/instrument-records/${record.id}/files/upload-urls` + ); + + const presignedUrls = presignedUrlsResponse.data; + + for (const basename in presignedUrls) { + const filesToUpload = uploadMap[basename]; + if ((filesToUpload?.length ?? 0) > (presignedUrls[basename]?.length ?? 0)) { + throw new Error( + `Files to upload (${filesToUpload?.length}) for file group with basename '${basename}' exceeds available presigned URLs (${presignedUrls[basename]?.length})` + ); + } + } + + const uploads: { [id: string]: $FileMetadata[] } = {}; + for (const basename in presignedUrls) { + uploads[basename] = []; + const filesToUpload = uploadMap[basename]!; + for (let i = 0; i < filesToUpload.length; i++) { + const file = filesToUpload[i]!; + const { location, url } = presignedUrls[basename]![i]!; + await axios.put(url, file, { + headers: { + 'Content-Type': file.type + }, + meta: { + disableDefaultAuth: true, + disableDefaultTimeout: true + }, + onUploadProgress: (event) => { + onProgress(file, event); + } + }); + uploads[basename].push({ location, name: file.name, size: file.size }); + onNext(); + } + } + + await axios.post(`/v1/instrument-records/${record.id}/files/upload-complete`, { + uploads + } satisfies $UploadCompleteData); }; if (!instrumentBundleQuery.data) { @@ -61,6 +111,7 @@ const RouteComponent = () => {
{ content={[ { fields: { + email: { + kind: 'string', + label: t({ + en: 'Email', + fr: 'Courriel' + }), + variant: 'input' + }, password: { calculateStrength: (password) => { return estimatePasswordStrength(password).score; @@ -135,14 +143,6 @@ const RouteComponent = () => { label: t('common.confirmPassword'), variant: 'password' }, - email: { - kind: 'string', - label: t({ - en: 'Email', - fr: 'Courriel' - }), - variant: 'input' - }, phoneNumber: { kind: 'string', label: t({ diff --git a/apps/web/src/services/axios.ts b/apps/web/src/services/axios.ts index 7d772b723..1e65b7419 100644 --- a/apps/web/src/services/axios.ts +++ b/apps/web/src/services/axios.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks'; import { i18n } from '@douglasneuroinformatics/libui/i18n'; import axios, { isAxiosError } from 'axios'; @@ -5,6 +7,16 @@ import axios, { isAxiosError } from 'axios'; import { config } from '@/config'; import { useAppStore } from '@/store'; +declare module 'axios' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface AxiosRequestConfig { + meta?: { + disableDefaultAuth?: boolean; + disableDefaultTimeout?: boolean; + }; + } +} + axios.defaults.baseURL = config.setup.apiBaseUrl; axios.interceptors.request.use((config) => { @@ -13,11 +25,7 @@ axios.interceptors.request.use((config) => { config.headers.setAccept(['application/json', 'application/x-msgpack']); // Do not set timeout for setup (can be CPU intensive, especially on slow server) - if ( - config.url !== '/v1/setup' && - config.url !== '/v1/instrument-records/upload' && - config.url !== '/v1/instrument-records/export' - ) { + if (!config.meta?.disableDefaultTimeout) { config.timeout = 10000; // abort request after 10 seconds config.timeoutErrorMessage = i18n.t({ en: 'Network Error', @@ -25,7 +33,7 @@ axios.interceptors.request.use((config) => { }); } - if (accessToken) { + if (accessToken && !config.meta?.disableDefaultAuth) { config.headers.set('Authorization', `Bearer ${accessToken}`); } diff --git a/apps/web/src/translations/datahub.json b/apps/web/src/translations/datahub.json index 588f16108..09500ddf2 100644 --- a/apps/web/src/translations/datahub.json +++ b/apps/web/src/translations/datahub.json @@ -117,12 +117,34 @@ "fr": "Centre de données" } }, + "files": { + "dateCollected": { + "en": "Date Collected", + "fr": "Date de collecte" + }, + "download": { + "en": "Download", + "fr": "Télécharger" + }, + "empty": { + "en": "No files have been uploaded for this subject.", + "fr": "Aucun fichier n'a été téléversé pour ce client." + }, + "instrument": { + "en": "Instrument", + "fr": "Instrument" + } + }, "layout": { "tabs": { "assignments": { "en": "Assignments", "fr": "Assignations" }, + "files": { + "en": "Files", + "fr": "Fichiers" + }, "graph": { "en": "Graph", "fr": "Graphique" diff --git a/packages/react-core/package.json b/packages/react-core/package.json index 296913433..53d5084f6 100644 --- a/packages/react-core/package.json +++ b/packages/react-core/package.json @@ -15,12 +15,18 @@ "storybook": "storybook dev -p 6006" }, "peerDependencies": { + "@tanstack/react-router": "^1.127.3", "axios": "catalog:", "react": "workspace:react__19.x@*", "react-dom": "workspace:react-dom__19.x@*", "tailwindcss": "catalog:", "zod": "workspace:zod__3.x@*" }, + "peerDependenciesMeta": { + "@tanstack/react-router": { + "optional": true + } + }, "dependencies": { "@douglasneuroinformatics/libjs": "catalog:", "@douglasneuroinformatics/libui": "catalog:", diff --git a/packages/react-core/src/components/FileInstrumentContent/Dropzone.tsx b/packages/react-core/src/components/FileInstrumentContent/Dropzone.tsx new file mode 100644 index 000000000..87925241f --- /dev/null +++ b/packages/react-core/src/components/FileInstrumentContent/Dropzone.tsx @@ -0,0 +1,183 @@ +import { memo, useCallback, useMemo, useState } from 'react'; + +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { FileInstrument } from '@opendatacapture/runtime-core'; +import { BracesIcon, FileIcon, FileTextIcon, ImageIcon, SheetIcon, UploadIcon } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { useDropzone } from 'react-dropzone'; +import type { FileRejection } from 'react-dropzone'; + +import { ErrorBox } from './ErrorBox'; +import { useFileInstrumentContentStore } from './store'; + +const FILE_TYPE_DESCRIPTORS: { + [K in FileInstrument.FileType]: { extensions: string[]; icon: LucideIcon; label: string }; +} = { + 'application/json': { extensions: ['.json'], icon: BracesIcon, label: 'JSON' }, + 'application/msword': { extensions: ['.doc'], icon: FileTextIcon, label: 'Word' }, + 'application/octet-stream': { extensions: [], icon: FileIcon, label: 'Binary' }, + 'application/pdf': { extensions: ['.pdf'], icon: FileTextIcon, label: 'PDF' }, + 'application/rtf': { extensions: ['.rtf'], icon: FileTextIcon, label: 'RTF' }, + 'application/vnd.ms-excel': { extensions: ['.xls'], icon: SheetIcon, label: 'Excel' }, + 'application/vnd.oasis.opendocument.spreadsheet': { extensions: ['.ods'], icon: SheetIcon, label: 'ODS' }, + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { + extensions: ['.xlsx'], + icon: SheetIcon, + label: 'Excel' + }, + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': { + extensions: ['.docx'], + icon: FileTextIcon, + label: 'Word' + }, + 'application/xml': { extensions: ['.xml'], icon: BracesIcon, label: 'XML' }, + 'image/bmp': { extensions: ['.bmp'], icon: ImageIcon, label: 'BMP' }, + 'image/gif': { extensions: ['.gif'], icon: ImageIcon, label: 'GIF' }, + 'image/jpeg': { extensions: ['.jpg', '.jpeg'], icon: ImageIcon, label: 'JPEG' }, + 'image/png': { extensions: ['.png'], icon: ImageIcon, label: 'PNG' }, + 'image/svg+xml': { extensions: ['.svg'], icon: ImageIcon, label: 'SVG' }, + 'image/tiff': { extensions: ['.tif', '.tiff'], icon: ImageIcon, label: 'TIFF' }, + 'text/csv': { extensions: ['.csv'], icon: SheetIcon, label: 'CSV' }, + 'text/html': { extensions: ['.html', '.htm'], icon: FileTextIcon, label: 'HTML' }, + 'text/markdown': { extensions: ['.md', '.markdown'], icon: FileTextIcon, label: 'Markdown' }, + 'text/plain': { extensions: ['.txt'], icon: FileTextIcon, label: 'Text' }, + 'text/tsv': { extensions: ['.tsv'], icon: SheetIcon, label: 'TSV' } +}; + +export const Dropzone = memo<{ index: number }>(function Dropzone({ index }) { + const { basename, count, label, type } = useFileInstrumentContentStore((store) => { + return store.props.instrument.content.fileGroups[index]!; + }); + const files = useFileInstrumentContentStore((store) => store.uploadMap[basename]!); + const issues = useFileInstrumentContentStore((store) => store.errors[basename]); + const setFiles = useFileInstrumentContentStore((store) => store.actions.setFiles); + const { t } = useTranslation(); + + const [rejectedNames, setRejectedNames] = useState([]); + + const handleDrop = useCallback( + (acceptedFiles: File[], fileRejections: FileRejection[]) => { + setRejectedNames(fileRejections.map((rejection) => rejection.file.name)); + setFiles(basename, acceptedFiles); + }, + [basename] + ); + + const acceptConfig = useMemo(() => { + if (type === null || !FILE_TYPE_DESCRIPTORS[type].extensions.length) { + return undefined; + } + return { [type]: FILE_TYPE_DESCRIPTORS[type].extensions }; + }, [type]); + + const { getInputProps, getRootProps } = useDropzone({ + accept: acceptConfig, + maxFiles: count.max, + onDrop: handleDrop + }); + + const displayText = useMemo(() => { + const filenames = files.map((file) => file.name); + const fileCount = filenames.length; + + if (fileCount === 0) { + return t({ + en: 'Drag and Drop or Click to Upload', + fr: 'Glissez-déposez ou cliquez pour télécharger' + }); + } else if (fileCount <= 2) { + return filenames.join(', '); + } + + const displayed = filenames.slice(0, 2).join(', '); + const remaining = fileCount - 2; + + return t({ + en: `${displayed}, and ${remaining} more files...`, + fr: `${displayed} et ${remaining} autres fichiers...` + }); + }, [files, t]); + + const typeDescriptor = useMemo(() => { + if (type === null) { + return { + icon: FileIcon, + label: t({ en: 'Any file type', fr: 'Tout type de fichier' }) + }; + } + return FILE_TYPE_DESCRIPTORS[type]; + }, [type, t]); + + const allowanceText = useMemo(() => { + const { max, min } = count; + if (min === max) { + return t({ + en: min === 1 ? 'Exactly 1 File Required' : `Exactly ${min} Files Required`, + fr: min === 1 ? 'Exactement 1 fichier requis' : `Exactement ${min} fichiers requis` + }); + } + return t({ + en: `Between ${min} and ${max} Files Allowed`, + fr: `Entre ${min} et ${max} fichiers autorisés` + }); + }, [count.min, count.max, t]); + + const hasFiles = files.length > 0; + + return ( +
+
+
+ + {t({ en: 'File Group', fr: 'Groupe de fichiers' })} {index + 1} + +

{label}

+
+
+ + + {typeDescriptor.label} + +
+
+
+
+
+ +
+

+ {displayText} +

+

+ {hasFiles + ? t({ + en: files.length === 1 ? '1 file selected' : `${files.length} files selected`, + fr: files.length === 1 ? '1 fichier sélectionné' : `${files.length} fichiers sélectionnés` + }) + : allowanceText} +

+
+ +
+ {rejectedNames.length > 0 && ( + + t({ + en: `"${name}" was rejected — only ${typeDescriptor.label} files are accepted`, + fr: `« ${name} » a été rejeté — seuls les fichiers ${typeDescriptor.label} sont acceptés` + }) + )} + title={t({ en: 'Unsupported file type', fr: 'Type de fichier non pris en charge' })} + /> + )} + {issues && t(issue))} title={t({ en: 'Invalid Input' })} />} +
+ ); +}); diff --git a/packages/react-core/src/components/FileInstrumentContent/ErrorBox.tsx b/packages/react-core/src/components/FileInstrumentContent/ErrorBox.tsx new file mode 100644 index 000000000..664b10f7b --- /dev/null +++ b/packages/react-core/src/components/FileInstrumentContent/ErrorBox.tsx @@ -0,0 +1,34 @@ +import type React from 'react'; + +import { cn } from '@douglasneuroinformatics/libui/utils'; +import { AlertCircleIcon } from 'lucide-react'; + +export const ErrorBox: React.FC< + { className?: string; issues?: string[]; message?: never; title: string } | { className?: string; message: string } +> = ({ className, ...props }) => { + const { issues, title } = typeof props.message === 'string' ? { issues: null, title: props.message } : props; + return ( +
+
+ +

{title}

+
+ {issues && ( +
    + {issues.map((issue, index) => ( +
  • + + {issue} +
  • + ))} +
+ )} +
+ ); +}; diff --git a/packages/react-core/src/components/FileInstrumentContent/FileInstrumentContent.tsx b/packages/react-core/src/components/FileInstrumentContent/FileInstrumentContent.tsx new file mode 100644 index 000000000..9045b3b9c --- /dev/null +++ b/packages/react-core/src/components/FileInstrumentContent/FileInstrumentContent.tsx @@ -0,0 +1,88 @@ +import React, { Fragment, useEffect, useRef } from 'react'; + +import { Button } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { RefreshCwIcon } from 'lucide-react'; +import { match } from 'ts-pattern'; + +import { Dropzone } from './Dropzone'; +import { ErrorBox } from './ErrorBox'; +import { + createFileInstrumentContentStore, + FileInstrumentContentStoreContext, + useFileInstrumentContentStore +} from './store'; +import { UploadProgressBar } from './UploadProgressBar'; + +import type { FileInstrumentContentProps } from './types'; + +const _FileInstrumentContent: React.FC = ({ NavigationBlocker }) => { + const actions = useFileInstrumentContentStore((store) => store.actions); + const fileGroups = useFileInstrumentContentStore((store) => store.props.instrument.content.fileGroups); + const onSuccess = useFileInstrumentContentStore((store) => store.props.onSuccess); + const status = useFileInstrumentContentStore((store) => store.status); + + const { t } = useTranslation(); + + useEffect(() => { + if (status === 'SUBMITTED') { + onSuccess?.(); + } + }, [onSuccess, status]); + + return ( + +
+
+ {fileGroups.map((_, index) => ( + + ))} +
+
+ {match(status) + .with('PENDING', () => ) + .with('FAILED', () => ( + + )) + .otherwise(() => null)} + +
+
+ {NavigationBlocker && ( + + )} +
+ ); +}; + +export const FileInstrumentContent: React.FC = (props) => { + const storeRef = useRef(createFileInstrumentContentStore(props)); + return ( + + <_FileInstrumentContent {...props} /> + + ); +}; diff --git a/packages/react-core/src/components/FileInstrumentContent/UploadProgressBar.tsx b/packages/react-core/src/components/FileInstrumentContent/UploadProgressBar.tsx new file mode 100644 index 000000000..6ff5d255f --- /dev/null +++ b/packages/react-core/src/components/FileInstrumentContent/UploadProgressBar.tsx @@ -0,0 +1,38 @@ +import { memo, useEffect } from 'react'; + +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { clamp } from 'lodash-es'; +import { motion, useSpring, useTransform } from 'motion/react'; + +import { useFileInstrumentContentStore } from './store'; + +export const UploadProgressBar = memo(function UploadProgressBar() { + const { loadedFiles, totalFiles, totalProgress } = useFileInstrumentContentStore((store) => store.uploadState!); + + const spring = useSpring(0, { damping: 20, restDelta: 0.001, stiffness: 50 }); + const percentage = useTransform(spring, (latest: number) => `${clamp(latest, 0, 100).toFixed(1)}%`); + + const { t } = useTranslation(); + + useEffect(() => { + if (!totalProgress) { + spring.jump(0); + } else { + spring.set(totalProgress); + } + }, [totalProgress]); + + return ( +
+
+ + {t({ en: `Uploading files... (${loadedFiles}/${totalFiles} complete)` })} + + {percentage} +
+
+ +
+
+ ); +}); diff --git a/packages/react-core/src/components/FileInstrumentContent/index.ts b/packages/react-core/src/components/FileInstrumentContent/index.ts new file mode 100644 index 000000000..eacb53604 --- /dev/null +++ b/packages/react-core/src/components/FileInstrumentContent/index.ts @@ -0,0 +1,2 @@ +export * from './FileInstrumentContent'; +export type * from './types'; diff --git a/packages/react-core/src/components/FileInstrumentContent/store.ts b/packages/react-core/src/components/FileInstrumentContent/store.ts new file mode 100644 index 000000000..e7281b013 --- /dev/null +++ b/packages/react-core/src/components/FileInstrumentContent/store.ts @@ -0,0 +1,111 @@ +import { createContext, useContext } from 'react'; + +import { createStore, useStore } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; + +import type { FileInstrumentContentProps, FileInstrumentContentStore, UploadProgressEvent } from './types'; + +export const FileInstrumentContentStoreContext = createContext<{ + store: ReturnType; +}>(null!); + +export function createFileInstrumentContentStore(props: FileInstrumentContentProps) { + return createStore( + immer((set, get) => ({ + actions: { + setFiles(id, files) { + set((state) => { + state.uploadMap[id] = files; + }); + }, + submit: async () => { + const { props, uploadMap } = get(); + const errors: FileInstrumentContentStore.Errors = {}; + props.instrument.content.fileGroups.forEach(({ basename, count }) => { + const actual = uploadMap[basename]!.length; + if (actual < count.min || actual > count.max) { + const required = count.min === count.max ? `${count.min}` : `between ${count.min} and ${count.max}`; + const requiredFr = count.min === count.max ? `${count.min}` : `entre ${count.min} et ${count.max}`; + const isPluralRequired = count.min !== count.max || count.min !== 1; + errors[basename] ??= []; + errors[basename].push({ + en: `You uploaded ${actual} file${actual === 1 ? '' : 's'}, but ${required} ${isPluralRequired ? 'are' : 'is'} required`, + fr: `Vous avez téléchargé ${actual} fichier${actual === 1 ? '' : 's'}, mais ${requiredFr} ${isPluralRequired ? 'sont' : 'est'} requis` + }); + } + }); + + if (Object.keys(errors).length) { + set((state) => { + state.errors = errors; + }); + return; + } + + const allFiles = Object.values(uploadMap).flat(); + const loaded = new Map(allFiles.map((file) => [file, 0])); + + set((state) => { + state.errors = {}; + state.status = 'PENDING'; + state.uploadState = { + loadedFiles: 0, + loadedSize: 0, + totalFiles: allFiles.length, + totalProgress: 0, + totalSize: allFiles.reduce((sum, file) => sum + file.size, 0) + }; + }); + + const onProgress = (file: File, event: UploadProgressEvent) => { + set((state) => { + const diff = event.loaded - loaded.get(file)!; + state.uploadState!.loadedSize += diff; + state.uploadState!.totalProgress = (state.uploadState!.loadedSize / state.uploadState!.totalSize) * 100; + loaded.set(file, event.loaded); + }); + }; + + const onNext = () => { + set((state) => { + state.uploadState!.loadedFiles += 1; + }); + }; + + const [result] = await Promise.allSettled([ + props.onSubmit({ data: {}, kind: 'FILE', onNext, onProgress, uploadMap }), + new Promise((resolve) => setTimeout(resolve, 300)) + ]); + + if (result.status === 'rejected') { + console.error(result.reason); + set((state) => { + state.status = 'FAILED'; + state.uploadState = null; + }); + } else { + // make sure animation goes to 100% for aesthetics + set((state) => { + state.uploadState!.totalProgress = 100; + }); + await new Promise((resolve) => setTimeout(resolve, 500)); + set((state) => { + state.status = 'SUBMITTED'; + state.uploadState = null; + }); + } + } + }, + errors: {}, + props, + status: 'READY', + uploadMap: Object.fromEntries(props.instrument.content.fileGroups.map((file) => [file.basename, []])), + uploadState: null + })) + ); +} + +export function useFileInstrumentContentStore(selector: (state: FileInstrumentContentStore) => T) { + const context = useContext(FileInstrumentContentStoreContext); + return useStore(context.store, selector); +} diff --git a/packages/react-core/src/components/FileInstrumentContent/types.ts b/packages/react-core/src/components/FileInstrumentContent/types.ts new file mode 100644 index 000000000..68f3c0b05 --- /dev/null +++ b/packages/react-core/src/components/FileInstrumentContent/types.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-namespace */ + +import type { + AnyUnilingualFileInstrument, + FileInstrument, + InstrumentKind, + Language +} from '@opendatacapture/runtime-core'; +import type { AxiosProgressEvent } from 'axios'; +import type { Promisable } from 'type-fest'; + +import type { NavigationBlockerComponent } from '../NavigationBlockerDialog'; + +export type UploadMap = { + [basename: string]: File[]; +}; + +export type UploadProgressEvent = Pick; + +export namespace FileInstrumentContentStore { + export type Errors = { + [basename: string]: { + [L in Language]: string; + }[]; + }; + + type UploadState = { + loadedFiles: number; + loadedSize: number; + totalFiles: number; + totalProgress: number; + totalSize: number; + }; + + type Status = 'FAILED' | 'PENDING' | 'READY' | 'SUBMITTED'; + + export type StoreType = { + actions: { + setFiles: (id: string, files: File[]) => void; + submit: () => Promise; + }; + errors: Errors; + readonly props: FileInstrumentContentProps; + status: Status; + uploadMap: UploadMap; + uploadState: null | UploadState; + }; +} + +export type FileInstrumentContentStore = FileInstrumentContentStore.StoreType; + +export type FileInstrumentContentSubmitResult = { + data: FileInstrument.Data; + kind: Extract; + onNext: () => void; + onProgress: (file: File, event: UploadProgressEvent) => void; + uploadMap: UploadMap; +}; + +export type FileInstrumentContentProps = { + instrument: AnyUnilingualFileInstrument & { id: string }; + NavigationBlocker?: NavigationBlockerComponent; + onSubmit: (result: FileInstrumentContentSubmitResult) => Promisable; + onSuccess?: () => Promisable; +}; diff --git a/packages/react-core/src/components/FormContent/FormContent.tsx b/packages/react-core/src/components/FormContent/FormContent.tsx index d76d20250..1f0d0616c 100644 --- a/packages/react-core/src/components/FormContent/FormContent.tsx +++ b/packages/react-core/src/components/FormContent/FormContent.tsx @@ -1,12 +1,14 @@ import { Button, Dialog, Form, Heading } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; -import type { AnyUnilingualFormInstrument, FormInstrument } from '@opendatacapture/runtime-core'; +import type { AnyUnilingualFormInstrument, FormInstrument, InstrumentKind } from '@opendatacapture/runtime-core'; import { InfoIcon } from 'lucide-react'; import type { Promisable } from 'type-fest'; +export type FormContentSubmitResult = { data: FormInstrument.Data; kind: Extract }; + export type FormContentProps = { instrument: AnyUnilingualFormInstrument; - onSubmit: (data: FormInstrument.Data) => Promisable; + onSubmit: (result: FormContentSubmitResult) => Promisable; }; export const FormContent = ({ instrument, onSubmit }: FormContentProps) => { @@ -22,7 +24,7 @@ export const FormContent = ({ instrument, onSubmit }: FormContentProps) => { - + {t({ @@ -43,7 +45,7 @@ export const FormContent = ({ instrument, onSubmit }: FormContentProps) => { data-testid="form-content" initialValues={instrument.initialValues} validationSchema={instrument.validationSchema} - onSubmit={(data) => void onSubmit(data)} + onSubmit={(data) => void onSubmit({ data, kind: 'FORM' })} />
); diff --git a/packages/react-core/src/components/InstrumentIcon/InstrumentIcon.tsx b/packages/react-core/src/components/InstrumentIcon/InstrumentIcon.tsx index be8236262..955e6049e 100644 --- a/packages/react-core/src/components/InstrumentIcon/InstrumentIcon.tsx +++ b/packages/react-core/src/components/InstrumentIcon/InstrumentIcon.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { InstrumentKind } from '@opendatacapture/runtime-core'; -import { ClipboardCheckIcon, FileQuestionIcon, ListChecksIcon, MonitorCheckIcon } from 'lucide-react'; +import { ClipboardCheckIcon, FileQuestionIcon, FileTextIcon, ListChecksIcon, MonitorCheckIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; export type InstrumentIconProps = React.ComponentPropsWithoutRef & { @@ -10,6 +10,8 @@ export type InstrumentIconProps = React.ComponentPropsWithoutRef & { export const InstrumentIcon = ({ kind, ...props }: InstrumentIconProps) => { switch (kind) { + case 'FILE': + return ; case 'FORM': return ; case 'INTERACTIVE': diff --git a/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.stories.tsx b/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.stories.tsx index 64947f9c6..428619e72 100644 --- a/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.stories.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.stories.tsx @@ -1,9 +1,11 @@ import { Card } from '@douglasneuroinformatics/libui/components'; +import { bilingualFileInstrument } from '@opendatacapture/instrument-stubs/file'; import { bilingualFormInstrument, unilingualFormInstrument } from '@opendatacapture/instrument-stubs/forms'; -import { interactiveInstrument } from '@opendatacapture/instrument-stubs/interactive'; +import { bilingualInteractiveInstrument, interactiveInstrument } from '@opendatacapture/instrument-stubs/interactive'; import { seriesInstrument } from '@opendatacapture/instrument-stubs/series'; import type { ScalarInstrumentBundleContainer } from '@opendatacapture/schemas/instrument'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { createMemoryHistory, createRootRoute, createRouter, RouterProvider } from '@tanstack/react-router'; import { InstrumentRenderer } from './InstrumentRenderer'; @@ -21,6 +23,18 @@ const unilingualInteractiveTarget: ScalarInstrumentBundleContainer = { kind: 'INTERACTIVE' }; +const bilingualInteractiveTarget: ScalarInstrumentBundleContainer = { + bundle: bilingualInteractiveInstrument.bundle, + id: crypto.randomUUID(), + kind: 'INTERACTIVE' +}; + +const bilingualFileTarget: ScalarInstrumentBundleContainer = { + bundle: bilingualFileInstrument.bundle, + id: crypto.randomUUID(), + kind: 'FILE' +}; + export default { component: InstrumentRenderer, decorators: [ @@ -53,12 +67,64 @@ export const BilingualForm: Story = { } }; +export const BilingualFile: Story = { + args: { + onSubmit: async (result) => { + if (result.kind !== 'FILE') { + throw new Error(); + } + const { onNext, onProgress, uploadMap } = result; + for (const file of Object.values(uploadMap).flat()) { + const totalBytes = file.size; + let loadedBytes = 0; + await new Promise((resolve) => { + const interval = setInterval(() => { + if (loadedBytes >= totalBytes) { + clearInterval(interval); + return resolve(); + } + const chunkSize = Math.floor(Math.random() * 65536) + 32768; + loadedBytes = Math.min(loadedBytes + chunkSize, totalBytes); + onProgress(file, { + loaded: loadedBytes, + progress: loadedBytes / totalBytes, + total: totalBytes + }); + }, 100); + }); + onNext(); + } + }, + target: bilingualFileTarget + }, + decorators: [ + (Story) => { + return ( + + ); + } + ] +}; + export const UnilingualInteractive: Story = { args: { target: unilingualInteractiveTarget } }; +export const BilingualInteractive: Story = { + args: { + target: bilingualInteractiveTarget + } +}; + export const InteractiveWithError: Story = { args: { target: { diff --git a/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.tsx b/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.tsx index 2cc2e49a9..06a7ecd4e 100644 --- a/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/InstrumentRenderer.tsx @@ -3,19 +3,22 @@ import type { InstrumentBundleContainer } from '@opendatacapture/schemas/instrum import { ScalarInstrumentRenderer } from './ScalarInstrumentRenderer'; import { SeriesInstrumentRenderer } from './SeriesInstrumentRenderer'; -import type { InstrumentSubmitHandler, SubjectDisplayInfo } from '../../types'; +import type { SubjectDisplayInfo } from '../../types'; +import type { NavigationBlockerComponent } from '../NavigationBlockerDialog'; +import type { InstrumentSubmitHandler } from './types'; export type InstrumentRendererProps = { className?: string; initialSeriesIndex?: number; + NavigationBlocker?: NavigationBlockerComponent; onSubmit: InstrumentSubmitHandler; subject?: SubjectDisplayInfo; target: InstrumentBundleContainer; }; -export const InstrumentRenderer = ({ target, ...props }: InstrumentRendererProps) => { +export const InstrumentRenderer = ({ NavigationBlocker, target, ...props }: InstrumentRendererProps) => { if (target.kind === 'SERIES') { return ; } - return ; + return ; }; diff --git a/packages/react-core/src/components/InstrumentRenderer/ScalarInstrumentRenderer.tsx b/packages/react-core/src/components/InstrumentRenderer/ScalarInstrumentRenderer.tsx index 73c81d595..5ba71f360 100644 --- a/packages/react-core/src/components/InstrumentRenderer/ScalarInstrumentRenderer.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/ScalarInstrumentRenderer.tsx @@ -4,11 +4,12 @@ import { replacer } from '@douglasneuroinformatics/libjs'; import { Spinner } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { InterpretOptions } from '@opendatacapture/instrument-interpreter'; -import type { Json } from '@opendatacapture/schemas/core'; +import type { AnyScalarInstrument } from '@opendatacapture/runtime-core'; import type { ScalarInstrumentBundleContainer } from '@opendatacapture/schemas/instrument'; import { match } from 'ts-pattern'; import { useInterpretedInstrument } from '../../hooks/useInterpretedInstrument'; +import { FileInstrumentContent } from '../FileInstrumentContent'; import { FormContent } from '../FormContent'; import { InstrumentOverview } from '../InstrumentOverview'; import { InstrumentSummary } from '../InstrumentSummary'; @@ -16,13 +17,16 @@ import { InteractiveContent } from '../InteractiveContent'; import { ContentPlaceholder } from './ContentPlaceholder'; import { InstrumentRendererContainer } from './InstrumentRendererContainer'; -import type { InstrumentSubmitHandler, SubjectDisplayInfo } from '../../types'; +import type { SubjectDisplayInfo } from '../../types'; +import type { NavigationBlockerComponent } from '../NavigationBlockerDialog'; +import type { AnyContentResult, InstrumentSubmitHandler } from './types'; export type ScalarInstrumentRendererProps = { className?: string; + NavigationBlocker?: NavigationBlockerComponent; /** @deprecated */ onCompileError?: (error: Error) => void; - onSubmit: InstrumentSubmitHandler; + onSubmit: InstrumentSubmitHandler; /** @deprecated */ options?: InterpretOptions; subject?: SubjectDisplayInfo; @@ -31,6 +35,7 @@ export type ScalarInstrumentRendererProps = { export const ScalarInstrumentRenderer = ({ className, + NavigationBlocker, onCompileError, onSubmit, options, @@ -42,9 +47,11 @@ export const ScalarInstrumentRenderer = ({ const [index, setIndex] = useState<0 | 1 | 2>(0); const { t } = useTranslation(); - const handleSubmit = async (data: unknown) => { - await onSubmit({ - data: JSON.parse(JSON.stringify(data, replacer)) as Json, + const handleSubmit = async ({ data, ...result }: AnyContentResult) => { + await onSubmit?.({ + ...result, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + data: JSON.parse(JSON.stringify(data, replacer)), instrumentId: target.id }); setIndex(2); @@ -82,9 +89,22 @@ export const ScalarInstrumentRenderer = ({ )) + .with({ index: 1, instrument: { kind: 'FILE' } }, ({ instrument }) => { + return ( + + ); + }) .with({ index: 2 }, () => ( )) diff --git a/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentContent.tsx b/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentContent.tsx index 637955c69..a47b96e43 100644 --- a/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentContent.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentContent.tsx @@ -3,15 +3,18 @@ import { useEffect, useRef, useState } from 'react'; import { Button, Heading } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { createPortal } from 'react-dom'; +import type { Simplify } from 'type-fest'; import type { ScalarInstrumentRendererProps } from './ScalarInstrumentRenderer'; -export type SeriesInstrumentContentProps = ScalarInstrumentRendererProps & { - status: { - completedInstruments: number; - totalInstruments: number; - }; -}; +export type SeriesInstrumentContentProps = Simplify< + ScalarInstrumentRendererProps & { + status: { + completedInstruments: number; + totalInstruments: number; + }; + } +>; export const SeriesInstrumentContent = ({ status }: SeriesInstrumentContentProps) => { const { t } = useTranslation(); diff --git a/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx b/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx index ca3e5339a..5baa7b152 100644 --- a/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx @@ -16,12 +16,15 @@ import { InteractiveContent } from '../InteractiveContent'; import { ContentPlaceholder } from './ContentPlaceholder'; import { InstrumentRendererContainer } from './InstrumentRendererContainer'; -import type { InstrumentSubmitHandler, SubjectDisplayInfo } from '../../types'; +import type { SubjectDisplayInfo } from '../../types'; +import type { FormContentSubmitResult } from '../FormContent'; +import type { InteractiveContentSubmitResult } from '../InteractiveContent'; +import type { InstrumentSubmitHandler } from './types'; export type SeriesInstrumentRendererProps = { className?: string; initialSeriesIndex?: number; - onSubmit: InstrumentSubmitHandler; + onSubmit: InstrumentSubmitHandler<'SERIES'>; subject?: SubjectDisplayInfo; target: SeriesInstrumentBundleContainer; }; @@ -58,8 +61,8 @@ export const SeriesInstrumentRenderer = ({ ? (getSeriesInstrumentParams(rootState.instrument.content).skipProgress ?? false) : false; - const handleSubmit = async (data: unknown) => { - await onSubmit({ + const handleSubmit = async ({ data }: FormContentSubmitResult | InteractiveContentSubmitResult) => { + await onSubmit?.({ data: JSON.parse(JSON.stringify(data, replacer)) as Json, index, instrumentId: scalarId!, diff --git a/packages/react-core/src/components/InstrumentRenderer/index.ts b/packages/react-core/src/components/InstrumentRenderer/index.ts index 302267f0f..81aab02f7 100644 --- a/packages/react-core/src/components/InstrumentRenderer/index.ts +++ b/packages/react-core/src/components/InstrumentRenderer/index.ts @@ -1,3 +1,4 @@ export * from './InstrumentRenderer'; export * from './ScalarInstrumentRenderer'; export * from './SeriesInstrumentRenderer'; +export type * from './types'; diff --git a/packages/react-core/src/components/InstrumentRenderer/types.ts b/packages/react-core/src/components/InstrumentRenderer/types.ts new file mode 100644 index 000000000..8f20dc279 --- /dev/null +++ b/packages/react-core/src/components/InstrumentRenderer/types.ts @@ -0,0 +1,41 @@ +/* eslint-disable @typescript-eslint/no-namespace */ + +import type { InstrumentKind, Json } from '@opendatacapture/runtime-core'; +import type { Promisable, Simplify } from 'type-fest'; + +import type { FileInstrumentContentSubmitResult } from '../FileInstrumentContent'; +import type { FormContentSubmitResult } from '../FormContent'; +import type { InteractiveContentSubmitResult } from '../InteractiveContent'; + +export type AnyContentResult = + | FileInstrumentContentSubmitResult + | FormContentSubmitResult + | InteractiveContentSubmitResult; + +export namespace InstrumentSubmitHandler { + type ContextMixin = Simplify< + TResult & { + data: Json; + instrumentId: string; + } + >; + + type FileContext = ContextMixin; + + type FormContext = ContextMixin; + + type InteractiveContext = ContextMixin; + + type SeriesContext = ContextMixin<{ + index: number; + kind: Extract; + }>; + + export type ScalarContext = FileContext | FormContext | InteractiveContext; + + export type AnyContext = ScalarContext | SeriesContext; +} + +export type InstrumentSubmitHandler = ( + context: Extract +) => Promisable; diff --git a/packages/react-core/src/components/InteractiveContent/InteractiveContent.stories.tsx b/packages/react-core/src/components/InteractiveContent/InteractiveContent.stories.tsx index 728631656..bae7af097 100644 --- a/packages/react-core/src/components/InteractiveContent/InteractiveContent.stories.tsx +++ b/packages/react-core/src/components/InteractiveContent/InteractiveContent.stories.tsx @@ -21,7 +21,7 @@ export default { export const Default: Story = { args: { bundle: interactiveInstrument.bundle, - onSubmit(data) { + onSubmit({ data }) { alert(JSON.stringify(data, null, 2)); } } diff --git a/packages/react-core/src/components/InteractiveContent/InteractiveContent.tsx b/packages/react-core/src/components/InteractiveContent/InteractiveContent.tsx index 5b4206dcf..9a01f9a9a 100644 --- a/packages/react-core/src/components/InteractiveContent/InteractiveContent.tsx +++ b/packages/react-core/src/components/InteractiveContent/InteractiveContent.tsx @@ -1,40 +1,80 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Button, Separator } from '@douglasneuroinformatics/libui/components'; +import { Button, Dialog, DropdownMenu, Separator } from '@douglasneuroinformatics/libui/components'; import { useNotificationsStore, useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { Theme } from '@douglasneuroinformatics/libui/hooks'; -import type { Language, RuntimeNotification } from '@opendatacapture/runtime-core'; +import type { + InstrumentKind, + InteractiveInstrument, + Language, + RuntimeNotification +} from '@opendatacapture/runtime-core'; import { $Json } from '@opendatacapture/schemas/core'; import type { Json } from '@opendatacapture/schemas/core'; -import { FullscreenIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react'; -import type { Promisable } from 'type-fest'; +import { ChevronRightIcon, FullscreenIcon, LanguagesIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react'; +import type { Promisable, Simplify } from 'type-fest'; -export type InteractiveContentProps = { - bundle: string; - defaultFullscreen?: boolean; - onSubmit: (data: Json) => Promisable; +const ALL_LANGUAGES: { [K in Language]: { [P in Language]: string } } = { + en: { + en: 'English', + fr: 'Anglais' + }, + fr: { + en: 'French', + fr: 'Français' + } }; -export const InteractiveContent = React.memo(function InteractiveContent({ +export type InteractiveContentSubmitResult = { + data: Json; + kind: Extract; +}; + +export type InteractiveContentProps = Simplify< + Pick< + InteractiveInstrument['content'], + 'defaultFullscreen' | 'enableLanguageLock' | 'enableLanguageSelect' | 'enableLanguageToggle' + > & { + bundle: string; + onSubmit: (result: InteractiveContentSubmitResult) => Promisable; + supportedLanguages?: Language[]; + } +>; + +export const _InteractiveContent = React.memo(function _InteractiveContent({ bundle, defaultFullscreen, - onSubmit + enableLanguageLock, + enableLanguageSelect, + enableLanguageToggle, + onSubmit, + supportedLanguages = [] }) { const addNotification = useNotificationsStore((store) => store.addNotification); - const { changeLanguage, resolvedLanguage } = useTranslation(); + const { changeLanguage, resolvedLanguage, t } = useTranslation(); const [_, updateTheme] = useTheme(); const [scale, setScale] = useState(100); + const [hasSelectedLanguage, setHasSelectedLanguage] = useState( + !enableLanguageSelect || supportedLanguages.length <= 1 + ); + const [lockDialogOpen, setLockDialogOpen] = useState(false); const iFrameRef = useRef(null); + const isLocked = Boolean(enableLanguageLock) && hasSelectedLanguage; + const handleChangeLanguageEvent = useCallback( (event: CustomEvent) => { - if (event.detail === 'en' || event.detail === 'fr') { - void changeLanguage(event.detail); - } else { + if (event.detail !== 'en' && event.detail !== 'fr') { console.error(`Cannot change language: invalid language '${event.detail}', expected 'en' or 'fr'`); + return; } + if (isLocked) { + setLockDialogOpen(true); + return; + } + void changeLanguage(event.detail); }, - [updateTheme] + [changeLanguage, isLocked] ); const handleChangeThemeEvent = useCallback( @@ -52,7 +92,7 @@ export const InteractiveContent = React.memo(function I (event: CustomEvent) => { void (async function () { const data = await $Json.parseAsync(event.detail); - await onSubmit(data); + await onSubmit({ data, kind: 'INTERACTIVE' }); })(); }, [onSubmit] @@ -67,10 +107,10 @@ export const InteractiveContent = React.memo(function I }; useEffect(() => { - if (defaultFullscreen) { + if (defaultFullscreen && hasSelectedLanguage) { void iFrameRef.current?.requestFullscreen(); } - }, []); + }, [hasSelectedLanguage]); useEffect(() => { document.addEventListener('changeLanguage', handleChangeLanguageEvent, false); @@ -97,41 +137,124 @@ export const InteractiveContent = React.memo(function I return (
-
- {scale}% - - - - -
-
-