diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2ee29d4cd..d62130117 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -457,6 +457,7 @@ model SetupState { activeLanguages Language[] @default([en, fr]) branding BrandingConfig? defaultAssignmentDurationDays Int? + isBulkRemoteAssignmentsEnabled Boolean? isDemo Boolean isExperimentalFeaturesEnabled Boolean? isSetup Boolean diff --git a/apps/api/src/setup/__tests__/setup.service.spec.ts b/apps/api/src/setup/__tests__/setup.service.spec.ts index a30c5b07f..17092203d 100644 --- a/apps/api/src/setup/__tests__/setup.service.spec.ts +++ b/apps/api/src/setup/__tests__/setup.service.spec.ts @@ -2,6 +2,7 @@ import { ConfigService, getModelToken, LoggingService, PRISMA_CLIENT_TOKEN } fro import type { Model } from '@douglasneuroinformatics/libnest'; import { MockFactory } from '@douglasneuroinformatics/libnest/testing'; import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing'; +import { ForbiddenException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -12,6 +13,7 @@ import { UsersService } from '@/users/users.service'; import { SetupService } from '../setup.service'; describe('SetupService', () => { + let configService: MockedInstance; let setupService: SetupService; let setupStateModel: MockedInstance>; @@ -31,7 +33,8 @@ describe('SetupService', () => { }).compile(); setupService = moduleRef.get(SetupService); setupStateModel = moduleRef.get(getModelToken('SetupState')); - moduleRef.get>(ConfigService).get.mockReturnValue(false); + configService = moduleRef.get>(ConfigService); + configService.get.mockReturnValue(false); }); describe('updateState', () => { @@ -57,6 +60,69 @@ describe('SetupService', () => { }); }); + describe('isBulkRemoteAssignmentsEnabled', () => { + it('should persist isBulkRemoteAssignmentsEnabled', async () => { + configService.get.mockReturnValue(true); + setupStateModel.findFirst.mockResolvedValue({ id: 'setup-1', isSetup: true }); + await setupService.updateState({ isBulkRemoteAssignmentsEnabled: true }); + expect(setupStateModel.update.mock.lastCall?.[0]).toMatchObject({ + data: { isBulkRemoteAssignmentsEnabled: true }, + where: { id: 'setup-1' } + }); + }); + + it('should refuse to enable it without the gateway, which is what serves an assignment', async () => { + setupStateModel.findFirst.mockResolvedValue({ id: 'setup-1', isSetup: true }); + await expect(setupService.updateState({ isBulkRemoteAssignmentsEnabled: true })).rejects.toThrow( + ForbiddenException + ); + expect(setupStateModel.update).not.toHaveBeenCalled(); + }); + + it('should allow disabling it without the gateway, so an instance is never stuck with it on', async () => { + setupStateModel.findFirst.mockResolvedValue({ id: 'setup-1', isSetup: true }); + await setupService.updateState({ isBulkRemoteAssignmentsEnabled: false }); + expect(setupStateModel.update.mock.lastCall?.[0]).toMatchObject({ + data: { isBulkRemoteAssignmentsEnabled: false } + }); + }); + + it('should return true when saved as true', async () => { + configService.get.mockReturnValue(true); + setupStateModel.findFirst.mockResolvedValue({ + isBulkRemoteAssignmentsEnabled: true, + isDemo: false, + isSetup: true + }); + await expect(setupService.getState()).resolves.toMatchObject({ isBulkRemoteAssignmentsEnabled: true }); + }); + + it('should default to true when the field is absent, so a new instance has it enabled', async () => { + configService.get.mockReturnValue(true); + setupStateModel.findFirst.mockResolvedValue({ isDemo: false, isSetup: true }); + await expect(setupService.getState()).resolves.toMatchObject({ isBulkRemoteAssignmentsEnabled: true }); + }); + + it('should return false when explicitly disabled', async () => { + configService.get.mockReturnValue(true); + setupStateModel.findFirst.mockResolvedValue({ + isBulkRemoteAssignmentsEnabled: false, + isDemo: false, + isSetup: true + }); + await expect(setupService.getState()).resolves.toMatchObject({ isBulkRemoteAssignmentsEnabled: false }); + }); + + it('should report it disabled without the gateway, even for a document saved while there was one', async () => { + setupStateModel.findFirst.mockResolvedValue({ + isBulkRemoteAssignmentsEnabled: true, + isDemo: false, + isSetup: true + }); + await expect(setupService.getState()).resolves.toMatchObject({ isBulkRemoteAssignmentsEnabled: false }); + }); + }); + describe('activeLanguages', () => { it('should persist the languages an admin selected', async () => { setupStateModel.findFirst.mockResolvedValue({ id: 'setup-1', isSetup: true }); diff --git a/apps/api/src/setup/dto/update-setup-state.dto.ts b/apps/api/src/setup/dto/update-setup-state.dto.ts index 0c9a055ce..f3a93e569 100644 --- a/apps/api/src/setup/dto/update-setup-state.dto.ts +++ b/apps/api/src/setup/dto/update-setup-state.dto.ts @@ -15,6 +15,9 @@ export class UpdateSetupStateDto implements UpdateSetupStateData { @ApiProperty({ required: false }) defaultAssignmentDurationDays?: null | number; + @ApiProperty({ required: false }) + isBulkRemoteAssignmentsEnabled?: boolean; + @ApiProperty({ required: false }) isExperimentalFeaturesEnabled?: boolean; } diff --git a/apps/api/src/setup/setup.service.ts b/apps/api/src/setup/setup.service.ts index 4b3d29f4f..9d5c5f88c 100644 --- a/apps/api/src/setup/setup.service.ts +++ b/apps/api/src/setup/setup.service.ts @@ -57,13 +57,18 @@ export class SetupService { // covers a document written before this setting existed, and this covers an instance with no // setup document at all. const [fallbackLanguage, ...otherLanguages] = savedOptions?.activeLanguages ?? []; + const isGatewayEnabled = this.configService.get('GATEWAY_ENABLED'); return { activeLanguages: fallbackLanguage ? [fallbackLanguage, ...otherLanguages] : DEFAULT_ACTIVE_LANGUAGES, branding: branding.success ? branding.data : null, defaultAssignmentDurationDays: savedOptions?.defaultAssignmentDurationDays ?? null, + // Derived from the gateway rather than reported as stored: a bulk assignment is only a link + // the gateway serves, so an instance that later dropped the gateway must report this off + // however the document was written while it had one. + isBulkRemoteAssignmentsEnabled: isGatewayEnabled && savedOptions?.isBulkRemoteAssignmentsEnabled !== false, isDemo: Boolean(savedOptions?.isDemo), isExperimentalFeaturesEnabled: Boolean(savedOptions?.isExperimentalFeaturesEnabled), - isGatewayEnabled: this.configService.get('GATEWAY_ENABLED'), + isGatewayEnabled, // Non-secret flag so the client can hide email UI when mail is off. The SMTP // configuration itself is never exposed here (this is a public route). isMailEnabled: isMailEnabled(savedOptions?.mailConfig), @@ -99,6 +104,9 @@ export class SetupService { if (!setupState?.isSetup) { throw new ServiceUnavailableException('Cannot update state before setup'); } + if (rest.isBulkRemoteAssignmentsEnabled && !this.configService.get('GATEWAY_ENABLED')) { + throw new ForbiddenException('Cannot enable bulk remote assignments while the gateway is disabled'); + } const normalizedBranding = branding ? { resourceLinks: [], sectionsOrder: [], ...branding } : branding; await this.setupStateModel.update({ data: { diff --git a/apps/web/src/__tests__/bulk-step-layout.test.tsx b/apps/web/src/__tests__/bulk-step-layout.test.tsx new file mode 100644 index 000000000..ce5eb3e95 --- /dev/null +++ b/apps/web/src/__tests__/bulk-step-layout.test.tsx @@ -0,0 +1,54 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StepLayout } from '@/components/BulkRemoteAssignmentWizard/StepLayout'; + +import '@/services/i18n'; + +const body =
; + +beforeEach(() => { + // There are no vitest setup files in this repo, so RTL never auto-unmounts between tests. + cleanup(); +}); + +describe('StepLayout', () => { + it('should mark the current step and let only a completed step be revisited', () => { + const onStepChange = vi.fn(); + render( + + {body} + + ); + expect(screen.getByTestId('bulk-breadcrumb-INSTRUMENTS').getAttribute('aria-current')).toBe('step'); + expect(screen.getByTestId('bulk-breadcrumb-REVIEW').disabled).toBe(true); + fireEvent.click(screen.getByTestId('bulk-breadcrumb-SUBJECTS')); + expect(onStepChange).toHaveBeenCalledWith('SOURCE'); + }); + + it('should omit the progress band on the completion screen', () => { + render( + + {body} + + ); + expect(screen.queryByTestId('bulk-breadcrumbs')).toBeNull(); + expect(screen.getByTestId('step-body')).toBeTruthy(); + }); + + it('should render the footer only when a step supplies actions', () => { + const footer = + + + + } + step={null} + title={t({ + en: `${wizard.assignments.length} Assignments Created`, + fr: `${wizard.assignments.length} tâches créées` + })} + > + + {t('datahub.index.table.subject')} + {t({ en: 'Link', fr: 'Lien' })} + + + } + > + {wizard.assignments.map((assignment) => ( + + + {removeSubjectIdScope(assignment.subjectId).slice(0, subjectIdDisplayLength)} + + + {assignment.url} + + + + + + ))} + + + )} +
+ ); +}; diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/ErrorList.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/ErrorList.tsx new file mode 100644 index 000000000..56c96d532 --- /dev/null +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/ErrorList.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; + +import type { BulkParseError } from '@/utils/bulk-assignments'; + +/** + * Every failure the wizard shows goes through here. Messages arrive already sanitized - the parser + * never puts a value from the user's file into one - so this only has to render them. + */ +export const ErrorList = ({ errors }: { errors: BulkParseError[] }) => { + const { t } = useTranslation(); + if (errors.length === 0) { + return null; + } + return ( +
+

+ {t({ + en: 'Nothing has been created. Fix the following and try again:', + fr: 'Rien n’a été créé. Corrigez ce qui suit et réessayez :' + })} +

+
    + {errors.map((error, index) => ( +
  • + {error.row !== undefined && ( + {t({ en: `Row ${error.row}: `, fr: `Ligne ${error.row} : ` })} + )} + {error.message} + {error.items && error.items.length > 0 && ( + // Capped and scrollable: a refusal can name hundreds of subjects, and the actions + // below it must stay reachable without hunting for them. +
      + {error.items.map((item, itemIndex) => ( +
    • {item}
    • + ))} +
    + )} +
  • + ))} +
+
+ ); +}; diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/MapStep.stories.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/MapStep.stories.tsx new file mode 100644 index 000000000..6e956a0f8 --- /dev/null +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/MapStep.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { withAppFrame } from '@/testing/withAppFrame'; +import type { BulkParseResult } from '@/utils/bulk-assignments'; + +import { MapStep } from './MapStep'; + +type Story = StoryObj; + +const noop = () => undefined; + +const idRows = [ + { first_name: 'Alice', subject_id: '9d5962039' }, + { first_name: 'Bob', subject_id: '9b310f6b7' }, + { first_name: 'Carla', subject_id: '4e8a1c2d0' } +]; + +const byId: BulkParseResult = { + headers: ['subject_id', 'first_name'], + mapping: { subject_id: 'subjectId' }, + mode: 'ID', + preview: idRows, + rows: idRows +}; + +const piiRows = [ + { dob: '1979-08-12', first: 'Alice', last: 'Martin', sex: 'FEMALE' }, + { dob: '1972-02-17', first: 'Bob', last: 'Tremblay', sex: 'MALE' } +]; + +const byPersonalInformation: BulkParseResult = { + headers: ['first', 'last', 'dob', 'sex'], + mapping: { dob: 'dateOfBirth', first: 'firstName', last: 'lastName', sex: 'sex' }, + mode: 'PII', + preview: piiRows, + rows: piiRows +}; + +const unrecognisedRows = idRows.map(({ first_name, subject_id }) => ({ given: first_name, participant: subject_id })); + +export default { + args: { + groupName: 'Depression Clinic', + onBack: noop, + onResolved: noop, + onStepChange: noop, + parsed: byId + }, + component: MapStep, + decorators: [withAppFrame('Remote Assignments')], + parameters: { + layout: 'fullscreen' + } +} as Meta; + +/** A file with an identifier column, recognised on parse. */ +export const SubjectIdColumn: Story = {}; + +/** A file identifying subjects by name, date of birth and sex, so identifiers are derived in the browser. */ +export const PersonalInformation: Story = { + args: { + parsed: byPersonalInformation + } +}; + +/** No column was recognised, so the user has to map one before continuing. */ +export const UnmappedColumns: Story = { + args: { + parsed: { + ...byId, + headers: ['participant', 'given'], + mapping: {}, + preview: unrecognisedRows, + rows: unrecognisedRows + } + } +}; diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/MapStep.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/MapStep.tsx new file mode 100644 index 000000000..e32159c8e --- /dev/null +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/MapStep.tsx @@ -0,0 +1,187 @@ +import React, { useState } from 'react'; + +import { Badge, Button, Select, Table } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { BULK_ASSIGNMENT_MAX_SUBJECTS } from '@opendatacapture/schemas/assignment'; + +import { BulkParseFailure, detectMode, resolveSubjectIds } from '@/utils/bulk-assignments'; +import type { BulkParseError, BulkParseResult, BulkSourceMode, CanonicalField } from '@/utils/bulk-assignments'; + +import { ErrorList } from './ErrorList'; +import { StepLayout } from './StepLayout'; +import { WizardTable } from './WizardTable'; + +import type { WizardStep } from './types'; + +const CANONICAL_FIELDS: CanonicalField[] = ['subjectId', 'firstName', 'lastName', 'dateOfBirth', 'sex']; + +const FIELD_LABELS: { [K in CanonicalField]: { en: string; fr: string } } = { + dateOfBirth: { en: 'Date of Birth', fr: 'Date de naissance' }, + firstName: { en: 'First Name', fr: 'Prénom' }, + lastName: { en: 'Last Name', fr: 'Nom' }, + sex: { en: 'Sex at Birth', fr: 'Sexe à la naissance' }, + subjectId: { en: 'Subject ID', fr: 'Identifiant du sujet' } +} as const; + +const NOT_USED = '__not_used__'; + +type MapStepProps = { + groupName: string; + onBack: () => void; + onResolved: (subjectIds: string[]) => void; + onStepChange: (step: WizardStep) => void; + parsed: BulkParseResult; +}; + +export const MapStep = ({ groupName, onBack, onResolved, onStepChange, parsed }: MapStepProps) => { + const { t } = useTranslation(); + const [errors, setErrors] = useState([]); + const [mapping, setMapping] = useState>(parsed.mapping); + + const mode: BulkSourceMode | null = detectMode(mapping); + + const claimed = new Set(Object.values(mapping).filter(Boolean)); + + const setHeaderField = (header: string, value: string) => { + setMapping((prev) => { + const next = { ...prev }; + if (value === NOT_USED) { + delete next[header]; + } else { + next[header] = value as CanonicalField; + } + return next; + }); + }; + + const resolve = async () => { + setErrors([]); + if (!mode) { + setErrors([ + { + message: t({ + en: 'Map a subject ID column, or a complete set of first name, last name, date of birth and sex.', + fr: "Associez une colonne d'identifiant, ou un ensemble complet de prénom, nom, date de naissance et sexe." + }) + } + ]); + return; + } + const resolvedParsed: BulkParseResult = { ...parsed, mapping, mode }; + try { + onResolved(await resolveSubjectIds(resolvedParsed, { groupName, maxSubjects: BULK_ASSIGNMENT_MAX_SUBJECTS })); + } catch (err) { + if (err instanceof BulkParseFailure) { + setErrors(err.errors); + return; + } + throw err; + } + }; + + return ( + + {mode === 'ID' + ? t({ en: 'Subject ID', fr: 'Identifiant du sujet' }) + : t({ en: 'Personal Information', fr: 'Renseignements personnels' })} + + ) : null + } + description={t({ + en: `Check that the columns were read correctly. ${parsed.rows.length} rows found.`, + fr: `Vérifiez que les colonnes ont été lues correctement. ${parsed.rows.length} lignes trouvées.` + })} + footer={ + + + + + } + step="SUBJECTS" + title={t({ en: 'Confirm the Columns', fr: 'Confirmer les colonnes' })} + onStepChange={onStepChange} + > +
+ + + {mode === 'PII' && ( +

+ {t({ + en: 'Subject identifiers are derived in your browser. The personal information in this file is never sent.', + fr: 'Les identifiants sont dérivés dans votre navigateur. Les renseignements personnels de ce fichier ne sont jamais envoyés.' + })} +

+ )} + +
+

{t({ en: 'Column Mapping', fr: 'Correspondance des colonnes' })}

+ + {t({ en: 'Column in Your File', fr: 'Colonne de votre fichier' })} + {t({ en: 'Read As', fr: 'Interprétée comme' })} + + } + > + {parsed.headers.map((header) => { + const field = mapping[header]; + return ( + + {header} + + + + + ); + })} + +
+ +
+

+ {t({ + en: `Preview of the first ${parsed.preview.length} rows`, + fr: `Aperçu des ${parsed.preview.length} premières lignes` + })} +

+ ( + {header} + ))} + > + {parsed.preview.map((row, index) => ( + + {parsed.headers.map((header) => ( + {row[header]} + ))} + + ))} + +
+
+
+ ); +}; diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.stories.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.stories.tsx new file mode 100644 index 000000000..5b47c7870 --- /dev/null +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { withAppFrame } from '@/testing/withAppFrame'; + +import { ReviewStep } from './ReviewStep'; + +type Story = StoryObj; + +const noop = () => undefined; + +const inThirtyDays = new Date(Date.now() + 30 * 86_400_000).toISOString().split('T')[0]!; + +export default { + args: { + describeSubject: (subjectId: string) => subjectId, + failure: null, + isSubmitting: false, + onBack: noop, + onStepChange: noop, + onSubmit: noop, + subjectCount: 3, + timepoints: [ + { expiresAt: inThirtyDays, instrumentId: 'instrument-1', instrumentTitle: 'Happiness Questionnaire' }, + { expiresAt: inThirtyDays, instrumentId: 'instrument-2', instrumentTitle: 'General Consent Form' } + ], + transportError: false + }, + component: ReviewStep, + decorators: [withAppFrame('Remote Assignments')], + parameters: { + layout: 'fullscreen' + } +} as Meta; + +/** The preflight passed, so the batch can be created. */ +export const Ready: Story = {}; + +/** Some subjects already hold an assignment for one of the instruments; the user may waive it. */ +export const Conflict: Story = { + args: { + failure: { + code: 'BULK_ASSIGNMENT_REFUSED', + issues: [ + { + conflicts: [ + { instrumentId: 'instrument-1', subjectId: '9d5962039' }, + { instrumentId: 'instrument-1', subjectId: '9b310f6b7' } + ], + kind: 'CONFLICT' + } + ] + } + } +}; + +/** The request never completed, so nothing was created and the user can try again. */ +export const TransportError: Story = { + args: { + transportError: true + } +}; diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx new file mode 100644 index 000000000..6077e9d3b --- /dev/null +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx @@ -0,0 +1,146 @@ +import React, { useState } from 'react'; + +import { Button, Checkbox } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { BulkAssignmentFailure, BulkAssignmentIssue } from '@opendatacapture/schemas/assignment'; + +import type { BulkParseError } from '@/utils/bulk-assignments'; + +import { ErrorList } from './ErrorList'; +import { StepLayout } from './StepLayout'; +import { TimepointTable } from './TimepointTable'; + +import type { DraftTimepoint, WizardStep } from './types'; + +type ReviewStepProps = { + /** Renders a subject id as something the user recognises, ideally their own uploaded row. */ + describeSubject: (subjectId: string) => string; + failure: BulkAssignmentFailure | null; + isSubmitting: boolean; + onBack: () => void; + onStepChange: (step: WizardStep) => void; + onSubmit: (options: { allowDuplicates: boolean }) => void; + subjectCount: number; + timepoints: DraftTimepoint[]; + transportError: boolean; +}; + +/** + * Render a refusal as sentences a clinician can act on. The API reports ids rather than names - it + * has never been told the names - so a count plus the ids is all there is to show, and all that + * should be shown. + */ +const useIssueMessages = (describeSubject: (subjectId: string) => string) => { + const { t } = useTranslation(); + return (issues: BulkAssignmentIssue[]): BulkParseError[] => + issues.map((issue) => { + switch (issue.kind) { + case 'CONFLICT': + return { + items: issue.conflicts.map(({ subjectId }) => describeSubject(subjectId)), + message: t({ + en: `${issue.conflicts.length} subject(s) already have an outstanding assignment for one of these instruments:`, + fr: `${issue.conflicts.length} sujet(s) ont déjà une tâche en cours pour l’un de ces instruments :` + }) + }; + case 'INSTRUMENT_UNAVAILABLE': + return { + items: issue.instrumentIds, + message: t({ + en: `This group cannot assign ${issue.instrumentIds.length} of the selected instrument(s):`, + fr: `Ce groupe ne peut pas attribuer ${issue.instrumentIds.length} des instruments sélectionnés :` + }) + }; + case 'SUBJECT_UNAVAILABLE': + return { + items: issue.subjectIds.map((subjectId) => describeSubject(subjectId)), + message: t({ + en: `${issue.subjectIds.length} subject(s) are not available in this group:`, + fr: `${issue.subjectIds.length} sujet(s) ne sont pas disponibles dans ce groupe :` + }) + }; + } + }); +}; + +export const ReviewStep = ({ + describeSubject, + failure, + isSubmitting, + onBack, + onStepChange, + onSubmit, + subjectCount, + timepoints, + transportError +}: ReviewStepProps) => { + const { t } = useTranslation(); + const [allowDuplicates, setAllowDuplicates] = useState(false); + const toMessages = useIssueMessages(describeSubject); + + const hasConflict = failure?.issues.some(({ kind }) => kind === 'CONFLICT') ?? false; + const hasBlockingIssue = + failure?.issues.some(({ kind }) => kind === 'SUBJECT_UNAVAILABLE' || kind === 'INSTRUMENT_UNAVAILABLE') ?? false; + const messages = failure ? toMessages(failure.issues) : []; + if (transportError) { + messages.push({ + message: t({ + en: 'The request could not be completed. Nothing has been created.', + fr: 'La requête n’a pas pu être complétée. Rien n’a été créé.' + }) + }); + } + + return ( + + + + + } + step="REVIEW" + title={t({ en: 'Review and Create', fr: 'Réviser et créer' })} + onStepChange={onStepChange} + > +
+ + +

+ {t({ + en: `${subjectCount} subjects × ${timepoints.length} instruments = ${subjectCount * timepoints.length} assignments`, + fr: `${subjectCount} sujets × ${timepoints.length} instruments = ${subjectCount * timepoints.length} tâches` + })} +

+ + + {hasConflict && ( + + )} +
+
+ ); +}; diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx new file mode 100644 index 000000000..adcc900d1 --- /dev/null +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx @@ -0,0 +1,347 @@ +import React, { useState } from 'react'; + +import { toBasicISOString } from '@douglasneuroinformatics/libjs'; +import { Button, Checkbox, DataTable, FileDropzone, Tabs, TextArea } from '@douglasneuroinformatics/libui/components'; +import type { TanstackTable } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { cn } from '@douglasneuroinformatics/libui/utils'; +import { BULK_ASSIGNMENT_MAX_SUBJECTS } from '@opendatacapture/schemas/assignment'; +import type { Subject } from '@opendatacapture/schemas/subject'; +import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; +import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react'; + +import { + ACCEPTED_FILE_EXTENSIONS, + assertFileSize, + BulkParseFailure, + isWorkbookFile, + parseDelimitedText, + parseWorkbook +} from '@/utils/bulk-assignments'; +import type { BulkParseError, BulkParseResult } from '@/utils/bulk-assignments'; + +import { ErrorList } from './ErrorList'; +import { StepLayout } from './StepLayout'; + +import type { WizardStep } from './types'; + +type SourceMode = 'FILE' | 'PASTE' | 'SELECT'; + +type PickerRow = { + dateOfBirth: string; + id: string; + sex: string; + subject: string; +}; + +/** + * A clickable column label. `DataTableHead` renders whatever the column supplies, so the sort + * affordance lives here rather than coming from the table. + */ +const SortableHeader = ({ column, label }: { column: TanstackTable.Column; label: string }) => { + const sorted = column.getIsSorted(); + const Icon = sorted === 'asc' ? ChevronUpIcon : sorted === 'desc' ? ChevronDownIcon : ChevronsUpDownIcon; + return ( + + ); +}; + +type SourceStepProps = { + onParsed: (parsed: BulkParseResult) => void; + /** Selection is held by the wizard, so stepping away and back does not discard it. */ + onSelectedChange: (subjectIds: string[]) => void; + onStepChange: (step: WizardStep) => void; + onSubjectsSelected: (subjectIds: string[]) => void; + selectedIds: string[]; + /** Group setting controlling how much of an identifier is shown, as elsewhere in the app. */ + subjectIdDisplayLength: number; + subjects: Subject[]; +}; + +export const SourceStep = ({ + onParsed, + onSelectedChange, + onStepChange, + onSubjectsSelected, + selectedIds, + subjectIdDisplayLength, + subjects +}: SourceStepProps) => { + const { t } = useTranslation(); + // One source at a time: offering all three at once left it unclear which the wizard would act on, + // and a file dropped while text was pasted had no defined precedence. + const [mode, setMode] = useState('SELECT'); + const [errors, setErrors] = useState([]); + const [pasted, setPasted] = useState(''); + const selected = new Set(selectedIds); + + const run = async (parse: () => BulkParseResult | Promise) => { + setErrors([]); + try { + onParsed(await parse()); + } catch (err) { + if (err instanceof BulkParseFailure) { + setErrors(err.errors); + return; + } + throw err; + } + }; + + const handleFile = (file: File) => + void run(async () => { + assertFileSize(file); + // The workbook parser is the only path that pulls in `xlsx`, and it does so dynamically. + return isWorkbookFile(file) ? parseWorkbook(file) : parseDelimitedText(await file.text()); + }); + + const toggle = (id: string) => { + const next = new Set(selected); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + onSelectedChange([...next]); + }; + + const modes: { label: string; value: SourceMode }[] = [ + { label: t({ en: 'Select Subjects', fr: 'Sélectionner des sujets' }), value: 'SELECT' }, + { label: t({ en: 'Upload a File', fr: 'Téléverser un fichier' }), value: 'FILE' }, + { label: t({ en: 'Paste Data', fr: 'Coller des données' }), value: 'PASTE' } + ]; + + // Every subject in the group is listed, not only those with a chosen identifier: a hash-identified + // subject is just as assignable, and excluding them left most of a group invisible. Identifiers are + // rendered the way the rest of the app renders them - group scope removed, truncated to the group's + // display length - with date of birth and sex alongside, since a digest identifies nobody on sight. + const rows: PickerRow[] = subjects.map((subject) => ({ + dateOfBirth: subject.dateOfBirth ? toBasicISOString(subject.dateOfBirth) : t({ en: 'NULL', fr: 'NUL' }), + id: subject.id, + sex: subject.sex ?? '', + subject: removeSubjectIdScope(subject.id).slice(0, subjectIdDisplayLength) + })); + + const toggleFiltered = (filteredRows: PickerRow[]) => { + const allSelected = filteredRows.length > 0 && filteredRows.every((row) => selected.has(row.id)); + const next = new Set(selected); + for (const row of filteredRows) { + if (allSelected) { + next.delete(row.id); + } else { + next.add(row.id); + } + } + onSelectedChange([...next]); + }; + + // A dropped file advances on its own, so only the other two modes have an action to offer. + const footers: { [K in SourceMode]?: React.ReactNode } = { + PASTE: ( + + ), + SELECT: ( + + {selected.size > BULK_ASSIGNMENT_MAX_SUBJECTS && ( +

+ {t({ + en: `Selection is limited to ${BULK_ASSIGNMENT_MAX_SUBJECTS} subjects`, + fr: `La sélection est limitée à ${BULK_ASSIGNMENT_MAX_SUBJECTS} sujets` + })} +

+ )} + +
+ ) + }; + + return ( + +
+ + + { + setErrors([]); + setMode(value as SourceMode); + }} + > + {/* Underlined rather than libui's filled pill: the pill is one page tint on another, and + the rule keeps to the card's own vocabulary. */} + + {modes.map((option) => ( + + {option.label} + + ))} + + + + {mode === 'SELECT' && ( +
+

+ {t({ + en: 'Choose existing subjects in this group. Search, sort or filter to narrow the list.', + fr: 'Choisissez des sujets existants de ce groupe. Cherchez, triez ou filtrez pour réduire la liste.' + })} +

+ {rows.length === 0 ? ( +

+ {t({ en: 'This group has no subjects.', fr: 'Ce groupe n’a aucun sujet.' })} +

+ ) : ( +
+ ( + toggle(row.original.id)} + /> + ), + enableSorting: false, + header: ({ table }) => { + const filteredRows = table.getFilteredRowModel().rows.map((row) => row.original); + const allFilteredSelected = + filteredRows.length > 0 && filteredRows.every((row) => selected.has(row.id)); + return ( + toggleFiltered(filteredRows)} + /> + ); + }, + id: 'select' + }, + { + accessorKey: 'subject', + header: ({ column }) => ( + + ), + id: 'subject' + }, + { + accessorKey: 'dateOfBirth', + header: ({ column }) => ( + + ), + id: 'dateOfBirth' + }, + { + accessorKey: 'sex', + cell: ({ getValue }) => { + const value = getValue(); + if (value === 'FEMALE') { + return t('core.identificationData.sex.female'); + } + if (value === 'MALE') { + return t('core.identificationData.sex.male'); + } + return t({ en: 'NULL', fr: 'NUL' }); + }, + header: ({ column }) => ( + + ), + id: 'sex' + } + ]} + data={rows} + // The table paints its rows and ground in the page colour; inside the card they + // take the card's, so the grid does not read as one more box. + rootStyle={{ '--color-background': 'var(--color-card)' }} + onRowClick={(row) => toggle(row.id)} + /> +
+ )} +
+ )} + + {mode === 'FILE' && ( +
+

+ {t({ + en: 'CSV, TSV or Excel. Include a subject ID column, or first name, last name, date of birth and sex.', + fr: 'CSV, TSV ou Excel. Incluez une colonne d’identifiant, ou prénom, nom, date de naissance et sexe.' + })} +

+ +
+ )} + + {mode === 'PASTE' && ( +
+

+ {t({ + en: 'Comma, tab or semicolon separated, with a header row. Include a subject ID column, or first name, last name, date of birth and sex.', + fr: 'Séparé par des virgules, tabulations ou points-virgules, avec une ligne d’en-tête. Incluez une colonne d’identifiant, ou prénom, nom, date de naissance et sexe.' + })} +

+