diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 0828561668..263f7c82df 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -77,6 +77,7 @@ model Assignment { instrument Instrument @relation(fields: [instrumentId], references: [id]) instrumentId String instrumentRecord InstrumentRecord? + redirectUrl String? status AssignmentStatus subject Subject @relation(fields: [subjectId], references: [id]) subjectId String diff --git a/apps/api/src/assignments/__tests__/assignments.service.test.ts b/apps/api/src/assignments/__tests__/assignments.service.test.ts index 64c8153d3b..8bd4c1f279 100644 --- a/apps/api/src/assignments/__tests__/assignments.service.test.ts +++ b/apps/api/src/assignments/__tests__/assignments.service.test.ts @@ -298,6 +298,16 @@ describe('AssignmentsService', () => { expect(assignmentModel.create.mock.lastCall?.[0].data.group).toStrictEqual({ connect: { id: GROUP_ID } }); }); + it('should store the redirect URL, so the gateway can send the subject there once they finish', async () => { + await assignmentsService.create({ ...data(), redirectUrl: 'https://example.com/done' }, permissiveUser()); + expect(assignmentModel.create.mock.lastCall?.[0].data.redirectUrl).toBe('https://example.com/done'); + }); + + it('should store no redirect URL when none is given, leaving the subject on the completion screen', async () => { + await assignmentsService.create(data(), permissiveUser()); + expect(assignmentModel.create.mock.lastCall?.[0].data.redirectUrl).toBeUndefined(); + }); + it('should allow a grouped assignment to a user granted only assignment creation in that group, which is less than managing it', async () => { const grantee = userAt('STANDARD', GROUP_ID, [{ action: 'create', groupId: GROUP_ID, subject: 'Assignment' }]); await assignmentsService.create(data(), grantee); @@ -363,6 +373,14 @@ describe('AssignmentsService', () => { expect(assignmentModel.create).toHaveBeenCalledTimes(4); }); + it('should store the redirect URL on every assignment the batch creates', async () => { + await assignmentsService.createBulk(request({ redirectUrl: 'https://example.com/done' }), permissiveUser()); + expect(assignmentModel.create.mock.calls.map(([args]) => args.data.redirectUrl)).toEqual([ + 'https://example.com/done', + 'https://example.com/done' + ]); + }); + it('should send the whole batch to the gateway in a single call, so one bundle is fetched per instrument', async () => { await assignmentsService.createBulk(request(), permissiveUser()); expect(gatewayService.createRemoteAssignments).toHaveBeenCalledTimes(1); diff --git a/apps/api/src/assignments/assignments.service.ts b/apps/api/src/assignments/assignments.service.ts index d661eb27ba..a6e6e491b0 100644 --- a/apps/api/src/assignments/assignments.service.ts +++ b/apps/api/src/assignments/assignments.service.ts @@ -75,7 +75,7 @@ export class AssignmentsService { } async create( - { expiresAt, groupId, instrumentId, subjectId }: $CreateAssignmentData, + { expiresAt, groupId, instrumentId, redirectUrl, subjectId }: $CreateAssignmentData, currentUser: RequestUser ): Promise { if (groupId) { @@ -91,7 +91,13 @@ export class AssignmentsService { issues: [{ instrumentIds: [instrumentId], kind: 'INSTRUMENT_UNAVAILABLE' }] } satisfies BulkAssignmentFailure); } - const { assignment, publicKey } = await this.stageAssignment({ expiresAt, groupId, instrumentId, subjectId }); + const { assignment, publicKey } = await this.stageAssignment({ + expiresAt, + groupId, + instrumentId, + redirectUrl, + subjectId + }); try { await this.gatewayService.createRemoteAssignment(assignment, publicKey); } catch (err) { @@ -123,7 +129,11 @@ export class AssignmentsService { }), BULK_KEYPAIR_CONCURRENCY )) { - staged.push(...(await Promise.all(batch.map((row) => this.stageAssignment({ ...row, groupId }))))); + staged.push( + ...(await Promise.all( + batch.map((row) => this.stageAssignment({ ...row, groupId, redirectUrl: data.redirectUrl })) + )) + ); } await this.gatewayService.createRemoteAssignments(staged); } catch (err) { @@ -347,11 +357,13 @@ export class AssignmentsService { expiresAt, groupId, instrumentId, + redirectUrl, subjectId }: { expiresAt: Date; groupId?: null | string; instrumentId: string; + redirectUrl?: string; subjectId: string; }): Promise<{ assignment: Assignment; publicKey: webcrypto.CryptoKey }> { const { privateKey, publicKey } = await HybridCrypto.generateKeyPair(); @@ -366,6 +378,7 @@ export class AssignmentsService { group: groupId ? { connect: { id: groupId } } : undefined, id, instrument: { connect: { id: instrumentId } }, + redirectUrl, status: 'OUTSTANDING', subject: { connect: { id: subjectId } }, url: `${this.assignmentBaseUrl}/assignments/${id}` diff --git a/apps/api/test/suites/05-assignment-redirect.suite.ts b/apps/api/test/suites/05-assignment-redirect.suite.ts new file mode 100644 index 0000000000..13cce1746a --- /dev/null +++ b/apps/api/test/suites/05-assignment-redirect.suite.ts @@ -0,0 +1,49 @@ +import { beforeAll, expect, it } from 'vitest'; + +import { defineSuite } from '../helpers'; + +export default defineSuite('assignment redirect URL', function () { + let adminHeaders: { authorization: string }; + + beforeAll(async () => { + const login = await this.app.inject({ + method: 'POST', + payload: { password: 'DataCapture2025_Test', username: 'admin' }, + url: '/v1/auth/login' + }); + expect(login.statusCode).toBe(200); + adminHeaders = { authorization: `Bearer ${login.json<{ accessToken: string }>().accessToken}` }; + }); + + it('should refuse an assignment that would send its subject on over plain http', async () => { + const response = await this.app.inject({ + headers: adminHeaders, + method: 'POST', + payload: { + expiresAt: new Date(Date.now() + 86_400_000), + instrumentId: 'instrument-1', + redirectUrl: 'http://example.com/done', + subjectId: 'subject-1' + }, + url: '/v1/assignments' + }); + expect(response.statusCode).toBe(400); + expect(response.body).toContain('redirectUrl'); + }); + + it('should refuse a bulk operation that would send its subjects on over plain http', async () => { + const response = await this.app.inject({ + headers: adminHeaders, + method: 'POST', + payload: { + groupId: 'group-1', + redirectUrl: 'http://example.com/done', + subjectIds: ['subject-1'], + timepoints: [{ expiresAt: new Date(Date.now() + 86_400_000), instrumentId: 'instrument-1' }] + }, + url: '/v1/assignments/bulk' + }); + expect(response.statusCode).toBe(400); + expect(response.body).toContain('redirectUrl'); + }); +}); diff --git a/apps/gateway/AGENTS.md b/apps/gateway/AGENTS.md index c2a8ecc893..f68a157a03 100644 --- a/apps/gateway/AGENTS.md +++ b/apps/gateway/AGENTS.md @@ -82,6 +82,13 @@ state `apps/api` owns — so persisting either would only add a second copy that `db:push` before `dev`, `lint`, `test:e2e` and — via the `@opendatacapture/gateway#build` key in `turbo.json` — before `build`, so a gateway build needs `GATEWAY_DATABASE_URL` set. +**A deployed gateway applies this schema on every start.** `scripts/run.sh` runs `prisma db push` +before the server, with a Prisma CLI the `Dockerfile` installs at the version the lockfile resolved. +That creates the database on an empty volume and brings an existing one up to the image's schema, +so a new column must be optional or have a default. `db push` refuses a change that would lose +data, and the container then fails to start: removing or retyping a column needs a plan for the +rows already on deployed volumes. + ## Build and dev `pnpm build` is three passes into `dist/`: Vite client build (`dist/client`), Vite SSR build diff --git a/apps/gateway/Dockerfile b/apps/gateway/Dockerfile index 60a2953e97..e8590b023f 100644 --- a/apps/gateway/Dockerfile +++ b/apps/gateway/Dockerfile @@ -21,12 +21,17 @@ COPY tsconfig.json vitest.config.ts ./ COPY --from=pruner /app/out/ . RUN pnpm install --frozen-lockfile RUN turbo build --filter=@opendatacapture/gateway -RUN turbo run db:push --filter=@opendatacapture/gateway +RUN node -p "require('./apps/gateway/node_modules/prisma/package.json').version" > prisma-version # RUN SERVER FROM base AS runner +ENV CHECKPOINT_DISABLE=1 +# run.sh applies the schema on every start, so the runner needs the Prisma CLI the lockfile resolved. +# npm gives it a flat node_modules, which copying pnpm's symlinked one out of the installer would not. +COPY --from=installer /app/prisma-version prisma-version +RUN npm install --prefix /app/prisma-cli --no-audit --no-fund "prisma@$(cat prisma-version)" +COPY apps/gateway/prisma/schema.prisma /app/prisma/schema.prisma COPY apps/gateway/scripts/run.sh run.sh -COPY --from=installer /app/sqlite/gateway.db /app/gateway.tmpl.db COPY --from=installer /app/apps/gateway/dist/ /app/dist/ COPY --from=installer /app/apps/gateway/public/ /app/public/ COPY --from=installer /app/apps/gateway/dist/runtime/ /runtime/ diff --git a/apps/gateway/prisma/schema.prisma b/apps/gateway/prisma/schema.prisma index 8ebf4bcb15..b28b1aa3de 100644 --- a/apps/gateway/prisma/schema.prisma +++ b/apps/gateway/prisma/schema.prisma @@ -21,6 +21,7 @@ model RemoteAssignmentModel { rawPublicKey Bytes symmetricKey String? encryptedData String? + redirectUrl String? targetStringified String status String subjectId String diff --git a/apps/gateway/scripts/run.sh b/apps/gateway/scripts/run.sh index a025e57061..2ecfa06ac6 100755 --- a/apps/gateway/scripts/run.sh +++ b/apps/gateway/scripts/run.sh @@ -2,11 +2,8 @@ set -eu -DATABASE_FILE=${GATEWAY_DATABASE_URL#file:} -EMPTY_DATABASE_FILE="/app/gateway.tmpl.db" - -if [ ! -s "$DATABASE_FILE" ]; then - cp "$EMPTY_DATABASE_FILE" "$DATABASE_FILE" -fi +# Creates the database on first start and brings an existing one up to the schema this image was +# built with. `db push` refuses a change that would lose data, which stops the container here. +/app/prisma-cli/node_modules/.bin/prisma db push --skip-generate --schema /app/prisma/schema.prisma exec node ./dist/main.js diff --git a/apps/gateway/src/Root.tsx b/apps/gateway/src/Root.tsx index 8154e01d0c..11b0722048 100644 --- a/apps/gateway/src/Root.tsx +++ b/apps/gateway/src/Root.tsx @@ -11,6 +11,7 @@ import type { InstrumentBundleContainer } from '@opendatacapture/schemas/instrum import axios from 'axios'; import CapWidget from './components/Cap'; +import CompletionRedirect from './components/CompletionRedirect'; import Landing from './components/Landing'; import './services/axios'; @@ -24,6 +25,7 @@ type BaseRootProps = { type AssignmentProps = { id: string; initialSeriesIndex?: number; + redirectUrl?: string; target: InstrumentBundleContainer; token: string; }; @@ -34,10 +36,11 @@ type LandingRootProps = BaseRootProps & { kind: 'landing' }; type RootProps = AssignmentRootProps | LandingRootProps; -const Assignment = ({ id, initialSeriesIndex, target, token }: AssignmentProps) => { +const Assignment = ({ id, initialSeriesIndex, redirectUrl, target, token }: AssignmentProps) => { const notifications = useNotificationsStore(); const [verified, setVerified] = useState(false); + const [isComplete, setIsComplete] = useState(false); // Solving the Cap challenge redeems a short-lived token; exchange it immediately for a // server-side verification flag so the form is not bound by the token's 20-minute lifetime. @@ -77,18 +80,24 @@ const Assignment = ({ id, initialSeriesIndex, target, token }: AssignmentProps) } }); notifications.addNotification({ type: 'success' }); + if (updateData.status === 'COMPLETE') { + setIsComplete(true); + } }; return ( - void handleSolve(token)} />} - className="min-h-full w-full" - disableBegin={!verified} - initialSeriesIndex={initialSeriesIndex} - target={target} - onSubmit={handleSubmit} - /> + <> + void handleSolve(token)} />} + className="min-h-full w-full" + disableBegin={!verified} + initialSeriesIndex={initialSeriesIndex} + target={target} + onSubmit={handleSubmit} + /> + {isComplete && redirectUrl && } + ); }; diff --git a/apps/gateway/src/__tests__/Root.test.tsx b/apps/gateway/src/__tests__/Root.test.tsx index 9d3a8942a7..dd4c0e602c 100644 --- a/apps/gateway/src/__tests__/Root.test.tsx +++ b/apps/gateway/src/__tests__/Root.test.tsx @@ -52,19 +52,22 @@ function seriesResult(complete: boolean): InstrumentSubmitHandler.AnyContext { }; } -function renderAssignment(target: InstrumentBundleContainer = formTarget) { +function renderAssignment(target: InstrumentBundleContainer = formTarget, redirectUrl?: string) { return render( ); } +const REDIRECT_URL = 'https://example.com/done'; + async function solveChallenge() { await act(async () => { document.querySelector('cap-widget')!.dispatchEvent(new CustomEvent('solve', { detail: { token: 'cap-token' } })); @@ -180,4 +183,40 @@ describe('Root', () => { expect(patch).not.toHaveBeenCalled(); expect(notifications()).toMatchObject([{ message: 'Internal Server Error', type: 'error' }]); }); + + it('should offer to send the subject on to the redirect URL once the assignment is complete', async () => { + renderAssignment(formTarget, REDIRECT_URL); + await solveChallenge(); + await submit(formResult); + expect(screen.getByTestId('completion-redirect-link').getAttribute('href')).toBe(REDIRECT_URL); + }); + + it('should not send the subject on while items of a series remain', async () => { + renderAssignment(seriesTarget, REDIRECT_URL); + await solveChallenge(); + await submit(seriesResult(false)); + expect(screen.queryByTestId('completion-redirect')).toBeNull(); + }); + + it('should send the subject on once the last item of a series is submitted', async () => { + renderAssignment(seriesTarget, REDIRECT_URL); + await solveChallenge(); + await submit(seriesResult(true)); + expect(screen.getByTestId('completion-redirect')).toBeTruthy(); + }); + + it('should leave the subject on the completion screen when the assignment has no redirect URL', async () => { + renderAssignment(formTarget); + await solveChallenge(); + await submit(formResult); + expect(screen.queryByTestId('completion-redirect')).toBeNull(); + }); + + it('should not send the subject on when the gateway did not record the submission', async () => { + patch.mockRejectedValue(new Error('Network Error')); + renderAssignment(formTarget, REDIRECT_URL); + await solveChallenge(); + await expect(submit(formResult)).rejects.toThrow('Network Error'); + expect(screen.queryByTestId('completion-redirect')).toBeNull(); + }); }); diff --git a/apps/gateway/src/components/CompletionRedirect.tsx b/apps/gateway/src/components/CompletionRedirect.tsx new file mode 100644 index 0000000000..706d4f731f --- /dev/null +++ b/apps/gateway/src/components/CompletionRedirect.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; + +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; + +/** Long enough to read the completion screen, short enough not to be mistaken for a dead end. */ +const REDIRECT_DELAY_SECONDS = 5; + +type CompletionRedirectProps = { + url: string; +}; + +/** + * Sends the subject on to `url` after a short countdown. `replace` rather than `assign`, so going + * back does not return to the completed assignment, which the gateway no longer serves. + */ +const CompletionRedirect = ({ url }: CompletionRedirectProps) => { + const { t } = useTranslation(); + const [secondsLeft, setSecondsLeft] = useState(REDIRECT_DELAY_SECONDS); + + useEffect(() => { + if (secondsLeft === 0) { + window.location.replace(url); + return; + } + const timeout = setTimeout(() => setSecondsLeft(secondsLeft - 1), 1000); + return () => clearTimeout(timeout); + }, [secondsLeft, url]); + + return ( +

+ {t({ + en: `Redirecting in ${secondsLeft}…`, + es: `Redirigiendo en ${secondsLeft}…`, + fr: `Redirection dans ${secondsLeft}…` + })}{' '} + { + event.preventDefault(); + window.location.replace(url); + }} + > + {t({ en: 'Continue now', es: 'Continuar ahora', fr: 'Continuer maintenant' })} + +

+ ); +}; + +export default CompletionRedirect; diff --git a/apps/gateway/src/components/__tests__/CompletionRedirect.test.tsx b/apps/gateway/src/components/__tests__/CompletionRedirect.test.tsx new file mode 100644 index 0000000000..582560cef1 --- /dev/null +++ b/apps/gateway/src/components/__tests__/CompletionRedirect.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import type { MockInstance } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import CompletionRedirect from '../CompletionRedirect'; + +import '@/services/i18n'; + +const URL = 'https://example.com/done'; + +describe('CompletionRedirect', () => { + let replace: MockInstance; + + beforeEach(() => { + vi.useFakeTimers(); + replace = vi.spyOn(window.location, 'replace').mockImplementation(() => undefined); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('should count down the seconds before the subject is sent on', () => { + render(); + expect(screen.getByTestId('completion-redirect').textContent).toContain('Redirecting in 5…'); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(screen.getByTestId('completion-redirect').textContent).toContain('Redirecting in 4…'); + }); + + it('should send the subject on once the countdown ends, replacing the assignment in their history', () => { + render(); + for (let second = 0; second < 5; second++) { + act(() => { + vi.advanceTimersByTime(1000); + }); + } + expect(replace).toHaveBeenCalledExactlyOnceWith(URL); + }); + + it('should not send the subject on before the countdown ends', () => { + render(); + act(() => { + vi.advanceTimersByTime(4000); + }); + expect(replace).not.toHaveBeenCalled(); + }); + + it('should send the subject on at once when they choose to continue', () => { + render(); + fireEvent.click(screen.getByRole('link', { name: 'Continue now' })); + expect(replace).toHaveBeenCalledExactlyOnceWith(URL); + }); + + it('should stop counting once it is gone, so a subject who left is not navigated later', () => { + const { unmount } = render(); + unmount(); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(replace).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/gateway/src/routers/__tests__/api.router.test.ts b/apps/gateway/src/routers/__tests__/api.router.test.ts index 087f2bfdce..e5112964eb 100644 --- a/apps/gateway/src/routers/__tests__/api.router.test.ts +++ b/apps/gateway/src/routers/__tests__/api.router.test.ts @@ -134,6 +134,30 @@ describe('apiRouter', () => { expect(data).toMatchObject({ id: 'assignment-1', rawPublicKey: Buffer.from([1, 2, 3]) }); expect(JSON.parse(data.targetStringified)).toEqual(instrumentContainer); }); + + it('should store the redirect URL, so the page served for the assignment can send the subject on', async () => { + const { instrumentId: _instrumentId, ...remoteAssignment } = assignment; + const response = await request('POST', '/assignments', { + ...remoteAssignment, + instrumentContainer, + redirectUrl: 'https://example.com/done' + }); + expect(response.status).toBe(201); + expect(prisma.remoteAssignmentModel.create.mock.lastCall![0].data).toMatchObject({ + redirectUrl: 'https://example.com/done' + }); + }); + + it('should refuse a redirect URL that is not https, whatever sent it', async () => { + const { instrumentId: _instrumentId, ...remoteAssignment } = assignment; + const response = await request('POST', '/assignments', { + ...remoteAssignment, + instrumentContainer, + redirectUrl: 'javascript:alert(1)' + }); + expect(response.status).toBe(400); + expect(prisma.remoteAssignmentModel.create).not.toHaveBeenCalled(); + }); }); describe('POST /assignments/bulk', () => { diff --git a/apps/gateway/src/routers/__tests__/root.router.test.ts b/apps/gateway/src/routers/__tests__/root.router.test.ts index 897c5d63db..2894297f1e 100644 --- a/apps/gateway/src/routers/__tests__/root.router.test.ts +++ b/apps/gateway/src/routers/__tests__/root.router.test.ts @@ -24,11 +24,17 @@ const target = { bundle: 'export default {}', id: 'instrument-1', kind: 'FORM' } let baseUrl: string; let server: Server; -function storeAssignment(stored: { completedAt?: Date; encryptedData?: null | string; targetStringified?: string }) { +function storeAssignment(stored: { + completedAt?: Date; + encryptedData?: null | string; + redirectUrl?: null | string; + targetStringified?: string; +}) { prisma.remoteAssignmentModel.findFirst.mockResolvedValueOnce({ completedAt: null, encryptedData: null, id: 'assignment-1', + redirectUrl: null, targetStringified: JSON.stringify(target), ...stored }); @@ -105,6 +111,13 @@ describe('rootRouter', () => { }); }); + it('should hand the page the redirect URL the assignment was created with, so it can send the subject on', async () => { + storeAssignment({ redirectUrl: 'https://example.com/done' }); + expect(await getRootProps('/assignments/assignment-1')).toMatchObject({ + redirectUrl: 'https://example.com/done' + }); + }); + it('should resume a series after the items already submitted, so a returning patient does not repeat them', async () => { storeAssignment({ encryptedData: '$item-1$item-2' }); expect(await getRootProps('/assignments/assignment-1')).toMatchObject({ initialSeriesIndex: 2 }); diff --git a/apps/gateway/src/routers/root.router.ts b/apps/gateway/src/routers/root.router.ts index 2c5ff3d6ee..10d38e56cd 100644 --- a/apps/gateway/src/routers/root.router.ts +++ b/apps/gateway/src/routers/root.router.ts @@ -51,6 +51,7 @@ router.get( initialSeriesIndex, kind: 'assignment', language: resolveLanguage(req.query.lang), + redirectUrl: assignment.redirectUrl ?? undefined, target: targetParseResult.data, token } satisfies RootProps); diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx index 87f77ca668..cdd076935b 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx @@ -171,11 +171,13 @@ export const BulkRemoteAssignmentWizard = ({ defaultExpiresAt={defaultExpiresAt} instruments={instruments} isLoading={wizard.isPreflighting} + redirectUrl={wizard.redirectUrl} subjectCount={wizard.subjectIds.length} timepoints={wizard.timepoints} onBack={() => wizard.goTo('SOURCE')} onChange={wizard.setTimepoints} onConfirm={wizard.confirmTimepoints} + onRedirectUrlChange={wizard.setRedirectUrl} onStepChange={wizard.goTo} /> )} @@ -185,6 +187,7 @@ export const BulkRemoteAssignmentWizard = ({ describeSubject={wizard.describeSubject} failure={wizard.failure} isSubmitting={wizard.isSubmitting} + redirectUrl={wizard.redirectUrl} subjectCount={wizard.subjectIds.length} timepoints={wizard.timepoints} transportError={wizard.transportError} diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.stories.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.stories.tsx index 5b47c7870d..6b9455da79 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.stories.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.stories.tsx @@ -18,6 +18,7 @@ export default { onBack: noop, onStepChange: noop, onSubmit: noop, + redirectUrl: '', subjectCount: 3, timepoints: [ { expiresAt: inThirtyDays, instrumentId: 'instrument-1', instrumentTitle: 'Happiness Questionnaire' }, diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx index 363a8583a1..f018e7551b 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx @@ -21,6 +21,8 @@ type ReviewStepProps = { onBack: () => void; onStepChange: (step: WizardStep) => void; onSubmit: (options: { allowDuplicates: boolean; skipUnavailable: boolean }) => void; + /** Applied to every assignment in the batch; blank for none. */ + redirectUrl: string; subjectCount: number; timepoints: DraftTimepoint[]; transportError: boolean; @@ -75,6 +77,7 @@ export const ReviewStep = ({ onBack, onStepChange, onSubmit, + redirectUrl, subjectCount, timepoints, transportError @@ -131,6 +134,19 @@ export const ReviewStep = ({
+ {redirectUrl && ( +

+ {t( + { + en: 'Once they finish, subjects are sent to {}', + es: 'Al terminar, los sujetos se envían a {}', + fr: 'Une fois terminé, les sujets sont redirigés vers {}' + }, + { args: [redirectUrl] } + )} +

+ )} + {hasConflict && ( diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.stories.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.stories.tsx index 498ab216cf..bf78f2ac3f 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.stories.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.stories.tsx @@ -21,7 +21,9 @@ export default { onBack: noop, onChange: noop, onConfirm: noop, + onRedirectUrlChange: noop, onStepChange: noop, + redirectUrl: '', subjectCount: 3, timepoints: [] }, diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.tsx index 6dbc55951b..ea1a973e0c 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.tsx @@ -5,6 +5,8 @@ import { Button, Input, Select } from '@douglasneuroinformatics/libui/components import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { PlusIcon } from 'lucide-react'; +import { findRedirectUrlError } from '@/utils/validation'; + import { StepLayout } from './StepLayout'; import { TimepointTable } from './TimepointTable'; @@ -20,7 +22,10 @@ type TimepointsStepProps = { onBack: () => void; onChange: (timepoints: DraftTimepoint[]) => void; onConfirm: () => void; + onRedirectUrlChange: (redirectUrl: string) => void; onStepChange: (step: WizardStep) => void; + /** Applied to every assignment in the batch; blank for none. */ + redirectUrl: string; subjectCount: number; /** Held by the wizard, so returning from review does not discard the list. */ timepoints: DraftTimepoint[]; @@ -37,13 +42,18 @@ export const TimepointsStep = ({ onBack, onChange, onConfirm, + onRedirectUrlChange, onStepChange, + redirectUrl, subjectCount, timepoints }: TimepointsStepProps) => { const { t } = useTranslation(); const [instrumentId, setInstrumentId] = useState(''); const [expiresAt, setExpiresAt] = useState(defaultExpiresAt); + // Shown once the user leaves the field, so a URL is not called invalid while it is still being typed. + const [isRedirectUrlTouched, setIsRedirectUrlTouched] = useState(false); + const redirectUrlError = redirectUrl === '' ? null : findRedirectUrlError(redirectUrl); // An instrument already in the list cannot be added again: two live assignments for the same // subject and instrument is exactly the conflict the API refuses. @@ -77,7 +87,7 @@ export const TimepointsStep = ({
+ +
+ +

+ {t({ + en: 'Where to send each subject once they finish. Must start with https://.', + es: 'Adónde enviar a cada sujeto cuando termine. Debe comenzar con https://.', + fr: 'Où envoyer chaque sujet une fois terminé. Doit commencer par https://.' + })} +

+ setIsRedirectUrlTouched(true)} + onChange={(event) => onRedirectUrlChange(event.target.value)} + /> + {isRedirectUrlTouched && redirectUrlError && ( +

+ {redirectUrlError} +

+ )} +
); diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/BulkRemoteAssignmentWizard.test.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/BulkRemoteAssignmentWizard.test.tsx index 510beb806c..9c11ca8e1e 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/BulkRemoteAssignmentWizard.test.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/BulkRemoteAssignmentWizard.test.tsx @@ -93,11 +93,13 @@ const mockWizard = (step: WizardStep, overrides: Partial = {}): Wizard = isSubmitting: false, parsed: null, pastedText: '', + redirectUrl: '', resolveMapping: vi.fn(), resultRows: vi.fn(() => []), selectSubjects: vi.fn(), setEmails: vi.fn(), setPastedText: vi.fn(), + setRedirectUrl: vi.fn(), setSelectedSubjectIds: vi.fn(), setSourceMode: vi.fn(), setTimepoints: vi.fn(), diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/ReviewStep.test.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/ReviewStep.test.tsx index 91a0d10ec9..78a0ee33a3 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/ReviewStep.test.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/ReviewStep.test.tsx @@ -36,6 +36,7 @@ const renderReviewStep = (props: Partial describeSubject={(subjectId) => `described ${subjectId}`} failure={null} isSubmitting={false} + redirectUrl="" subjectCount={3} timepoints={timepoints} transportError={false} @@ -175,4 +176,14 @@ describe('ReviewStep', () => { fireEvent.click(screen.getByTestId('bulk-breadcrumb-SUBJECTS')); expect(onStepChange).toHaveBeenCalledWith('SOURCE'); }); + + it('should show where the batch sends its subjects once they finish', () => { + renderReviewStep({ redirectUrl: 'https://example.com/done' }); + expect(screen.getByTestId('bulk-review-redirect-url').textContent).toContain('https://example.com/done'); + }); + + it('should say nothing about a redirect when the batch has none', () => { + renderReviewStep(); + expect(screen.queryByTestId('bulk-review-redirect-url')).toBeNull(); + }); }); diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/TimepointsStep.test.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/TimepointsStep.test.tsx index e9cdb88934..1f0b6d7aa1 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/TimepointsStep.test.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/TimepointsStep.test.tsx @@ -28,12 +28,14 @@ const renderStep = (props: Partial> onBack: vi.fn(), onChange: vi.fn(), onConfirm: vi.fn(), + onRedirectUrlChange: vi.fn(), onStepChange: vi.fn() }; const result = render( { ); @@ -190,4 +194,33 @@ describe('TimepointsStep', () => { fireEvent.click(screen.getByTestId('bulk-breadcrumb-SUBJECTS')); expect(onStepChange).toHaveBeenCalledWith('SOURCE'); }); + + it('should report each edit of the redirect URL to the wizard, which holds it for the whole batch', () => { + const { onRedirectUrlChange } = renderStep(); + fireEvent.change(screen.getByTestId('bulk-redirect-url-input'), { target: { value: 'https://example.com' } }); + expect(onRedirectUrlChange).toHaveBeenCalledWith('https://example.com'); + }); + + it('should hold back review while the redirect URL is not https', () => { + renderStep({ redirectUrl: 'http://example.com', timepoints: [happinessTimepoint] }); + expect(reviewButton().disabled).toBe(true); + }); + + it('should allow review with an https redirect URL', () => { + renderStep({ redirectUrl: 'https://example.com', timepoints: [happinessTimepoint] }); + expect(reviewButton().disabled).toBe(false); + }); + + it('should explain what is wrong with the redirect URL once the user leaves the field, not while they type', () => { + renderStep({ redirectUrl: 'http://example.com' }); + expect(screen.queryByTestId('bulk-redirect-url-error')).toBeNull(); + fireEvent.blur(screen.getByTestId('bulk-redirect-url-input')); + expect(screen.getByTestId('bulk-redirect-url-error').textContent).toContain('https://'); + }); + + it('should not call a blank redirect URL invalid, since the batch need not redirect', () => { + renderStep(); + fireEvent.blur(screen.getByTestId('bulk-redirect-url-input')); + expect(screen.queryByTestId('bulk-redirect-url-error')).toBeNull(); + }); }); diff --git a/apps/web/src/hooks/__tests__/useBulkAssignmentWizard.test.ts b/apps/web/src/hooks/__tests__/useBulkAssignmentWizard.test.ts index 60db6618d0..544ca6875e 100644 --- a/apps/web/src/hooks/__tests__/useBulkAssignmentWizard.test.ts +++ b/apps/web/src/hooks/__tests__/useBulkAssignmentWizard.test.ts @@ -100,6 +100,13 @@ describe('useBulkAssignmentWizard', () => { expect(result.current.parsed).toBeNull(); }); + it('should clear the redirect URL when starting a new batch', () => { + const { result } = renderWizard(); + act(() => result.current.setRedirectUrl('https://example.com/done')); + act(() => result.current.startOver()); + expect(result.current.redirectUrl).toBe(''); + }); + it('should keep the source tab while stepping back, so Back from the mapping step returns to the upload or paste', () => { const { result } = renderWizard(); expect(result.current.sourceMode).toBe('SELECT'); @@ -248,6 +255,27 @@ describe('useBulkAssignmentWizard', () => { expect(result.current.step).not.toBe('DONE'); }); + it('should send the redirect URL with the batch, for both preflight and create', () => { + mocks.createMutate.mockImplementation(() => undefined); + const { result } = renderWizard(); + act(() => result.current.selectSubjects(['alice'], {})); + act(() => result.current.setTimepoints([TIMEPOINT])); + act(() => result.current.setRedirectUrl('https://example.com/done')); + act(() => result.current.confirmTimepoints()); + act(() => result.current.submit({ allowDuplicates: false, skipUnavailable: false })); + expect(mocks.preflightMutate.mock.lastCall?.[0]).toMatchObject({ redirectUrl: 'https://example.com/done' }); + expect(mocks.createMutate.mock.lastCall?.[0]).toMatchObject({ redirectUrl: 'https://example.com/done' }); + }); + + it('should send no redirect URL when the field is blank', () => { + mocks.createMutate.mockImplementation(() => undefined); + const { result } = renderWizard(); + act(() => result.current.selectSubjects(['alice'], {})); + act(() => result.current.setTimepoints([TIMEPOINT])); + act(() => result.current.submit({ allowDuplicates: false, skipUnavailable: false })); + expect(mocks.createMutate.mock.lastCall?.[0].redirectUrl).toBeUndefined(); + }); + it('should expire each timepoint at the end of its chosen day, not at midnight before it', () => { mocks.createMutate.mockImplementation(() => undefined); const { result } = renderWizard(); diff --git a/apps/web/src/hooks/useBulkAssignmentWizard.ts b/apps/web/src/hooks/useBulkAssignmentWizard.ts index c8c66d844e..28be169cc8 100644 --- a/apps/web/src/hooks/useBulkAssignmentWizard.ts +++ b/apps/web/src/hooks/useBulkAssignmentWizard.ts @@ -17,6 +17,7 @@ import { } from '@/hooks/useBulkAssignments'; import type { BulkParseResult, CanonicalField, SubjectEmails } from '@/utils/bulk-assignments'; import { buildResultRows, toResultTsv, unavailableSubjectIds } from '@/utils/bulk-assignments'; +import { omittedIfBlank } from '@/utils/validation'; type InstrumentOption = { id: string; title: string }; @@ -43,6 +44,7 @@ export function useBulkAssignmentWizard({ const [parsed, setParsed] = useState(null); const [subjectIds, setSubjectIds] = useState([]); const [timepoints, setTimepoints] = useState([]); + const [redirectUrl, setRedirectUrl] = useState(''); const [assignments, setAssignments] = useState([]); const [sourceRows, setSourceRows] = useState(); const [columnMapping, setColumnMapping] = useState>({}); @@ -65,6 +67,7 @@ export function useBulkAssignmentWizard({ const toPayload = (allowDuplicates: boolean, ids: string[] = subjectIds) => ({ allowDuplicates, groupId, + redirectUrl: omittedIfBlank(redirectUrl), subjectIds: ids, timepoints: timepoints.map(({ expiresAt, instrumentId }) => ({ expiresAt: new Date(`${expiresAt}T23:59:59.999Z`), @@ -153,6 +156,8 @@ export function useBulkAssignmentWizard({ isSubmitting: createMutation.isPending, parsed, pastedText, + /** Where every assignment in the batch sends its subject once they finish; blank for nowhere. */ + redirectUrl, /** Subject ids resolved from the mapped columns, in the row order they were supplied. */ resolveMapping: (ids: string[], rows: BulkParseResult['rows'], mapping: BulkParseResult['mapping']) => { setSubjectIds(ids); @@ -184,6 +189,7 @@ export function useBulkAssignmentWizard({ }, setEmails, setPastedText, + setRedirectUrl, setSelectedSubjectIds: setSubjectIds, setSourceMode, setTimepoints, @@ -197,6 +203,7 @@ export function useBulkAssignmentWizard({ setParsed(null); setSubjectIds([]); setTimepoints([]); + setRedirectUrl(''); setAssignments([]); setSourceRows(undefined); setColumnMapping({}); diff --git a/apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx b/apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx index 5aedcffe5d..8eac32b005 100644 --- a/apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx +++ b/apps/web/src/providers/__tests__/WalkthroughProvider.test.tsx @@ -303,7 +303,7 @@ describe('WalkthroughProvider', () => { it('should mark the walkthrough complete and close when the user asks not to see it again', () => { renderProvider(); - fireEvent.click(screen.getByRole('button', { name: "Don't show again" })); + fireEvent.click(screen.getByRole('button', { name: "Don't Show Again" })); expect(mocks.store.setIsWalkthroughComplete).toHaveBeenCalledWith(true); expect(mocks.store.setIsWalkthroughOpen).toHaveBeenCalledWith(false); }); @@ -326,7 +326,7 @@ describe('WalkthroughProvider', () => { it('should hide the option not to show again on the last step, where done already completes it', () => { renderProvider(); clickNext(STEP_COUNT_WITHOUT_GATEWAY - 1); - expect(screen.queryByRole('button', { name: "Don't show again" })).toBeNull(); + expect(screen.queryByRole('button', { name: "Don't Show Again" })).toBeNull(); }); it('should mark the walkthrough complete and close when the user finishes the last step', () => { diff --git a/apps/web/src/routes/_app/session/__tests__/remote-assignment.test.tsx b/apps/web/src/routes/_app/session/__tests__/remote-assignment.test.tsx index 944030c3a3..e0585e011e 100644 --- a/apps/web/src/routes/_app/session/__tests__/remote-assignment.test.tsx +++ b/apps/web/src/routes/_app/session/__tests__/remote-assignment.test.tsx @@ -205,6 +205,36 @@ describe('Route', () => { }); }); + it('should refuse a redirect URL that is not https, without creating the assignment', async () => { + selectInstrument(); + fireEvent.change(screen.getByLabelText('Redirect URL (Optional)'), { target: { value: 'http://example.com' } }); + submitForm(); + await waitFor(() => expect(screen.getByTestId('error-message-text')).toBeTruthy()); + expect(mocks.mutateAsync).not.toHaveBeenCalled(); + }); + + it('should create the assignment with the redirect URL given', async () => { + selectInstrument(); + fireEvent.change(screen.getByLabelText('Redirect URL (Optional)'), { + target: { value: 'https://example.com/done' } + }); + submitForm(); + await waitFor(() => expect(mocks.mutateAsync).toHaveBeenCalled()); + expect(mocks.mutateAsync).toHaveBeenCalledWith({ + data: expect.objectContaining({ redirectUrl: 'https://example.com/done' }) + }); + }); + + it('should create the assignment with no redirect URL when the field is cleared', async () => { + selectInstrument(); + const input = screen.getByLabelText('Redirect URL (Optional)'); + fireEvent.change(input, { target: { value: 'https://example.com/done' } }); + fireEvent.change(input, { target: { value: '' } }); + submitForm(); + await waitFor(() => expect(mocks.mutateAsync).toHaveBeenCalled()); + expect(mocks.mutateAsync.mock.lastCall?.[0].data.redirectUrl).toBeUndefined(); + }); + it('should create the assignment without a group when none is selected', async () => { mocks.store.currentGroup = null; await createAssignment(); diff --git a/apps/web/src/routes/_app/session/remote-assignment.tsx b/apps/web/src/routes/_app/session/remote-assignment.tsx index 4db7d4b327..4e738d54b9 100644 --- a/apps/web/src/routes/_app/session/remote-assignment.tsx +++ b/apps/web/src/routes/_app/session/remote-assignment.tsx @@ -20,6 +20,7 @@ import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; import { useAppStore } from '@/store'; import { selectAdministrableInstruments } from '@/utils/administrable-instruments'; import { getDefaultAssignmentExpiry } from '@/utils/assignment-duration'; +import { $RedirectUrl, omittedIfBlank } from '@/utils/validation'; /** Slide-over panel shown after an assignment is created, displaying the URL, copy button, and QR code */ const AssignmentResultSlider: React.FC<{ @@ -172,6 +173,20 @@ const RouteComponent = () => { es: 'Vence el', fr: "Date d'expiration" }) + }, + redirectUrl: { + description: t({ + en: 'Where to send the subject once they finish. Must start with https://.', + es: 'Adónde enviar al sujeto cuando termine. Debe comenzar con https://.', + fr: 'Où envoyer le client une fois terminé. Doit commencer par https://.' + }), + kind: 'string', + label: t({ + en: 'Redirect URL (Optional)', + es: 'URL de redirección (opcional)', + fr: 'URL de redirection (facultatif)' + }), + variant: 'input' } }} initialValues={{ @@ -185,15 +200,17 @@ const RouteComponent = () => { es: 'La fecha de vencimiento debe ser futura', fr: "La date d'expiration doit être dans le futur" }) - }) - }) satisfies z.ZodType> + }), + redirectUrl: $RedirectUrl.optional() + }) satisfies z.ZodType> } - onSubmit={async ({ expiresAt }) => { + onSubmit={async ({ expiresAt, redirectUrl }) => { const created = await createAssignmentMutation.mutateAsync({ data: { expiresAt, groupId: currentGroup?.id, instrumentId: selectedInstrument!.id, + redirectUrl: omittedIfBlank(redirectUrl), subjectId: currentSession.subjectId } }); diff --git a/apps/web/src/utils/__tests__/validation.test.ts b/apps/web/src/utils/__tests__/validation.test.ts index 40ff2b0819..6ffbdf81be 100644 --- a/apps/web/src/utils/__tests__/validation.test.ts +++ b/apps/web/src/utils/__tests__/validation.test.ts @@ -7,7 +7,9 @@ import { $Email, $OptionalPassword, $PhoneNumber, + $RedirectUrl, clearedIfBlank, + findRedirectUrlError, omittedIfBlank, omittedIfUnchanged, requiresGroup, @@ -136,6 +138,26 @@ describe('$Email', () => { }); }); +describe('findRedirectUrlError', () => { + it('should find nothing wrong with an https URL', () => { + expect(findRedirectUrlError('https://example.com/done')).toBeNull(); + }); + + it('should explain that a redirect URL must be https', () => { + expect(findRedirectUrlError('http://example.com/done')).toContain('https://'); + }); +}); + +describe('$RedirectUrl', () => { + it('should accept a blank field, since an assignment need not redirect', () => { + expect($RedirectUrl.safeParse('').success).toBe(true); + }); + + it('should reject a filled field that is not an https URL', () => { + expect($RedirectUrl.safeParse('javascript:alert(1)').success).toBe(false); + }); +}); + describe('requiresGroup', () => { it('should not require a group for an admin, who is never scoped to one', () => { expect(requiresGroup({ basePermissionLevel: 'ADMIN' })).toBe(false); diff --git a/apps/web/src/utils/validation.ts b/apps/web/src/utils/validation.ts index 7145a9d2eb..75184a5fea 100644 --- a/apps/web/src/utils/validation.ts +++ b/apps/web/src/utils/validation.ts @@ -1,5 +1,6 @@ import type { ZodErrorLike } from '@douglasneuroinformatics/libjs'; import type { TranslateFunction, TranslationKey } from '@douglasneuroinformatics/libui/i18n'; +import { $AssignmentRedirectUrl } from '@opendatacapture/schemas/assignment'; import { findPhoneNumberError, MIN_PHONE_DIGITS } from '@opendatacapture/schemas/user'; import type { BasePermissionLevel, PhoneNumberErrorCode } from '@opendatacapture/schemas/user'; import { z } from 'zod/v4'; @@ -81,6 +82,18 @@ function $PhoneNumber(t: TranslateFunction, storedValue?: null | }); } +/** + * Why `value` cannot be where an assignment sends its subject once they finish, or null if it can. + * The message is the shared schema's own, which the app's zod error map localizes as it parses. + */ +function findRedirectUrlError(value: string) { + const result = $AssignmentRedirectUrl.safeParse(value); + return result.success ? null : result.error.issues[0]!.message; +} + +/** An assignment's optional redirect URL, as a form field that may be left blank. */ +const $RedirectUrl = blankOr(findRedirectUrlError); + /** * Whether this user must belong to at least one group, shared by the create and update forms so the * two cannot drift apart again. @@ -113,7 +126,9 @@ export { $Email, $OptionalPassword, $PhoneNumber, + $RedirectUrl, clearedIfBlank, + findRedirectUrlError, omittedIfBlank, omittedIfUnchanged, requiresGroup, diff --git a/docs/en/5-reference/5.5-gateway.md b/docs/en/5-reference/5.5-gateway.md index 29a642ea20..4b906da861 100644 --- a/docs/en/5-reference/5.5-gateway.md +++ b/docs/en/5-reference/5.5-gateway.md @@ -8,3 +8,7 @@ sidebar: In addition to the core platform, an additional service (the "Gateway") is available for external data collection. The architecture of this platform is engineered for compliance purposes. In our implementation, the core platform is deployed within the firewall of the Douglas. The Gateway, on the other hand, is deployed on a droplet accessible on the open internet. When a clinical assigns an instrument to a subject, the Gateway receives a blob of data, including the subject ID, instrument structure, and a public key. The Gateway uses this data to generate a one-time link for the subject to complete the instrument. Once completed, this data is be encrypted using the public key and stored in the Gateway Database; the data cannot be encrypted without the private key, which remains within the Douglas firewall. Periodically, the Core API requests data from the Gateway, at which point it is decrypted and inserted into the Core Database and removed from the Gateway Database. + +An assignment can be given a redirect URL when it is created, which must start with `https://`. Once the subject completes the assignment, the Gateway shows its completion screen for a few seconds and then sends them to that URL, for example back to the study portal they came from; a link on the completion screen lets them go straight away. A bulk operation applies one redirect URL to every assignment it creates. Without one, the subject stays on the completion screen. + +The Gateway Database is a SQLite file kept in the `sqlite` volume. Each time the Gateway container starts, it brings that file up to date with the version being started, so upgrading the Gateway needs no manual database step. diff --git a/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx b/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx index 3acc4ab1de..5a16dfcb87 100644 --- a/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/SeriesInstrumentRenderer.tsx @@ -16,6 +16,7 @@ import { InstrumentOverview } from '../InstrumentOverview'; import { InteractiveContent } from '../InteractiveContent'; import { ContentPlaceholder } from './ContentPlaceholder'; import { InstrumentRendererContainer } from './InstrumentRendererContainer'; +import { SeriesProgressBar } from './SeriesProgressBar'; import { validateSubmission } from './validateSubmission'; import type { LocalizedText } from '../../types'; @@ -146,7 +147,7 @@ export const SeriesInstrumentRenderer = ({ /> )) .with({ status: 'DONE' }, ({ instrument }) => { - return match({ index, instrument, isInstrumentInProgress }) + const content = match({ index, instrument, isInstrumentInProgress }) .with({ index: 0 }, () => ( -

- {t({ - en: `Instruments Completed: ${currentItemIndex}/${target.items.length}`, - es: `Instrumentos completados: ${currentItemIndex}/${target.items.length}`, - fr: `Nombre d'instruments complétés : ${currentItemIndex}/${target.items.length}` - })} -

)) .exhaustive(); + // Rendered outside `content` so it stays mounted between items and animates each advance. + return ( + <> + {index === 1 && ( + + )} + {content} + + ); }) .exhaustive()} diff --git a/packages/react-core/src/components/InstrumentRenderer/SeriesProgressBar.tsx b/packages/react-core/src/components/InstrumentRenderer/SeriesProgressBar.tsx new file mode 100644 index 0000000000..a5377046f1 --- /dev/null +++ b/packages/react-core/src/components/InstrumentRenderer/SeriesProgressBar.tsx @@ -0,0 +1,46 @@ +import { useEffect } from 'react'; + +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { motion, useSpring, useTransform } from 'motion/react'; + +type SeriesProgressBarProps = { + completed: number; + total: number; +}; + +export const SeriesProgressBar = ({ completed, total }: SeriesProgressBarProps) => { + const { t } = useTranslation(); + const fill = (completed / total) * 100; + + // Seeded with the current fill so a resumed series starts where the subject left off. + const spring = useSpring(fill, { damping: 20, restDelta: 0.001, stiffness: 50 }); + const width = useTransform(spring, (latest: number) => `${latest}%`); + + useEffect(() => { + spring.set(fill); + }, [fill]); + + const position = Math.min(completed + 1, total); + + return ( +
+

+ {t({ + en: `Instrument ${position} of ${total}`, + es: `Instrumento ${position} de ${total}`, + fr: `Instrument ${position} sur ${total}` + })} +

+
+ +
+
+ ); +}; diff --git a/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesInstrumentRenderer.test.tsx b/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesInstrumentRenderer.test.tsx index 05a27dce05..5136ef58b2 100644 --- a/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesInstrumentRenderer.test.tsx +++ b/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesInstrumentRenderer.test.tsx @@ -154,6 +154,10 @@ async function beginSeries({ skipProgress }: { skipProgress: boolean }) { return { onSubmit }; } +function getProgressBar() { + return screen.getByRole('progressbar', { name: 'Series progress' }); +} + async function answerAndSubmit(value: string, onSubmit: ReturnType) { const submissionCount = onSubmit.mock.calls.length; fireEvent.change(getAnswerInput(), { target: { value } }); @@ -322,7 +326,34 @@ describe('SeriesInstrumentRenderer', () => { /> ); fireEvent.click(await screen.findByRole('button', { name: 'Begin' })); - expect(await screen.findByText('Instruments Completed: 1/2')).toBeTruthy(); + expect(await screen.findByText('Instrument 2 of 2')).toBeTruthy(); + expect(getProgressBar().getAttribute('aria-valuenow')).toBe('1'); + }); + + it('should show progress on the form screen of a skipProgress series, which has no interstitial to show it', async () => { + const { onSubmit } = await beginSeries({ skipProgress: true }); + expect(screen.getByText('Instrument 1 of 2')).toBeTruthy(); + expect(getProgressBar().getAttribute('aria-valuenow')).toBe('0'); + await answerAndSubmit('first administration', onSubmit); + expect(await screen.findByText('Instrument 2 of 2')).toBeTruthy(); + expect(getProgressBar().getAttribute('aria-valuenow')).toBe('1'); + }); + + it('should show no progress on the overview screen, before any item is administered', async () => { + render(); + await screen.findByRole('button', { name: 'Begin' }); + expect(screen.queryByRole('progressbar')).toBeNull(); + }); + + it('should show no progress on the completion screen, once every item is administered', async () => { + const { onSubmit } = await beginSeries({ skipProgress: true }); + await answerAndSubmit('first administration', onSubmit); + await waitFor(() => { + expect(getAnswerInput().value).toBe(''); + }); + await answerAndSubmit('second administration', onSubmit); + await screen.findByText('Thank You!'); + expect(screen.queryByRole('progressbar')).toBeNull(); }); it('should refuse an initial series index past the last item', () => { @@ -409,10 +440,10 @@ describe('SeriesInstrumentRenderer', () => { ); fireEvent.click(await screen.findByRole('button', { name: 'Begin' })); - // The container's only child left is its step header once the content area renders nothing, - // which a spinner, form, iframe or error placeholder would each add to. + // The container's only children left are its step header and the progress bar once the content + // area renders nothing, which a spinner, form, iframe or error placeholder would each add to. await waitFor(() => { - expect(container.firstElementChild?.childElementCount).toBe(1); + expect(container.firstElementChild?.childElementCount).toBe(2); }); expect(container.querySelector('.animate-spinner, form, iframe')).toBeNull(); expect(screen.queryByText('Failed to Load Instrument')).toBeNull(); diff --git a/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesProgressBar.test.tsx b/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesProgressBar.test.tsx new file mode 100644 index 0000000000..2431ff33f4 --- /dev/null +++ b/packages/react-core/src/components/InstrumentRenderer/__tests__/SeriesProgressBar.test.tsx @@ -0,0 +1,41 @@ +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; + +import { SeriesProgressBar } from '../SeriesProgressBar'; + +describe('SeriesProgressBar', () => { + beforeAll(() => { + i18n.init({ translations: {} }); + i18n.changeLanguage('en'); + }); + + afterEach(() => { + cleanup(); + }); + + it('should name the item in progress, which is the one after those completed', () => { + render(); + expect(screen.getByText('Instrument 2 of 3')).toBeTruthy(); + }); + + it('should report the completed count to assistive technology', () => { + render(); + const progressBar = screen.getByRole('progressbar', { name: 'Series progress' }); + expect(progressBar.getAttribute('aria-valuemin')).toBe('0'); + expect(progressBar.getAttribute('aria-valuemax')).toBe('3'); + expect(progressBar.getAttribute('aria-valuenow')).toBe('1'); + }); + + it('should not name an item past the last, in the render before the completion screen replaces it', () => { + render(); + expect(screen.getByText('Instrument 3 of 3')).toBeTruthy(); + }); + + it('should follow the completed count as it advances', () => { + const { rerender } = render(); + rerender(); + expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe('1'); + expect(screen.getByText('Instrument 2 of 2')).toBeTruthy(); + }); +}); diff --git a/packages/schemas/src/assignment/__tests__/assignment.test.ts b/packages/schemas/src/assignment/__tests__/assignment.test.ts index bf1f340392..5ac73c7107 100644 --- a/packages/schemas/src/assignment/__tests__/assignment.test.ts +++ b/packages/schemas/src/assignment/__tests__/assignment.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + $AssignmentRedirectUrl, $BulkAssignmentFailure, $BulkAssignmentIssue, $BulkAssignmentPreflightData, @@ -39,11 +40,52 @@ const remoteAssignment = { url: 'http://localhost:3500/assignments/assignment-1' }; +describe('$AssignmentRedirectUrl', () => { + it('should accept an https URL', () => { + expect($AssignmentRedirectUrl.safeParse('https://example.com/study?id=1').data).toBe( + 'https://example.com/study?id=1' + ); + }); + + it('should normalize a URL to ASCII, so the gateway can embed it in its latin1-only page props', () => { + expect($AssignmentRedirectUrl.parse('https://exämple.com/päth')).toBe('https://xn--exmple-cua.com/p%C3%A4th'); + }); + + it('should reject a plain http URL, which would hand the subject over unencrypted', () => { + expect($AssignmentRedirectUrl.safeParse('http://example.com').success).toBe(false); + }); + + it('should reject a URL that runs script or renders a document of its own', () => { + expect($AssignmentRedirectUrl.safeParse('javascript:alert(1)').success).toBe(false); + expect($AssignmentRedirectUrl.safeParse('data:text/html,').success).toBe(false); + }); + + it('should reject a value that is not a URL', () => { + expect($AssignmentRedirectUrl.safeParse('https://').success).toBe(false); + expect($AssignmentRedirectUrl.safeParse('example.com').success).toBe(false); + }); + + it('should reject a URL over 2000 characters', () => { + expect($AssignmentRedirectUrl.safeParse(`https://example.com/${'a'.repeat(2000)}`).success).toBe(false); + }); +}); + describe('$CreateAssignmentData', () => { afterEach(() => { vi.useRealTimers(); }); + it('should accept an assignment with no redirect URL, so the subject stays on the completion screen', () => { + const result = $CreateAssignmentData.safeParse({ expiresAt: futureDate(), instrumentId: 'i', subjectId: 's' }); + expect(result.data).not.toHaveProperty('redirectUrl'); + }); + + it('should hold the redirect URL to the https rule', () => { + const data = { expiresAt: futureDate(), instrumentId: 'i', subjectId: 's' }; + expect($CreateAssignmentData.safeParse({ ...data, redirectUrl: 'https://example.com' }).success).toBe(true); + expect($CreateAssignmentData.safeParse({ ...data, redirectUrl: 'http://example.com' }).success).toBe(false); + }); + it('should reject an expiry in the past', () => { expect( $CreateAssignmentData.safeParse({ @@ -118,6 +160,15 @@ describe('$BulkAssignmentPreflightData', () => { }); describe('$CreateBulkAssignmentsData', () => { + it('should hold the redirect URL applied to the whole batch to the https rule', () => { + expect($CreateBulkAssignmentsData.safeParse({ ...baseRequest, redirectUrl: 'https://example.com' }).success).toBe( + true + ); + expect($CreateBulkAssignmentsData.safeParse({ ...baseRequest, redirectUrl: 'http://example.com' }).success).toBe( + false + ); + }); + it('should reject a timepoint whose expiry is in the past', () => { const result = $CreateBulkAssignmentsData.safeParse({ ...baseRequest, @@ -178,6 +229,14 @@ describe('$CreateRemoteAssignmentsData', () => { expect(result.data?.assignments).toHaveLength(3); }); + it('should accept an assignment stored without a redirect URL, which the database holds as null', () => { + const result = $CreateRemoteAssignmentsData.safeParse({ + assignments: [{ ...remoteAssignment, redirectUrl: null }], + instruments: [{ instrumentContainer, instrumentId: 'instrument-1' }] + }); + expect(result.success).toBe(true); + }); + it('should reject a batch with no assignments, so an empty request is not a silent no-op', () => { const result = $CreateRemoteAssignmentsData.safeParse({ assignments: [], diff --git a/packages/schemas/src/assignment/assignment.ts b/packages/schemas/src/assignment/assignment.ts index 087af657d5..815eb965b8 100644 --- a/packages/schemas/src/assignment/assignment.ts +++ b/packages/schemas/src/assignment/assignment.ts @@ -10,6 +10,18 @@ type AssignmentStatus = z.infer; /** Fallback validity period (in days) for a new remote assignment when the instance has not configured one. */ const DEFAULT_ASSIGNMENT_DURATION_DAYS = 365; +/** + * Where the gateway sends a subject once they complete the assignment. It is stored and replayed to + * whoever finishes, so only https is accepted. `href` punycodes the host and percent-encodes the + * rest, which keeps the value latin1-safe for the gateway's `btoa`-encoded page props. The URL + * check aborts, because zod otherwise still runs the transform, and `new URL` throws on a non-URL. + */ +const $AssignmentRedirectUrl = z + .url({ abort: true }) + .startsWith('https://') + .max(2000) + .transform((value) => new URL(value).href); + /** * An self-contained object representing an assignment. */ @@ -19,6 +31,7 @@ const $Assignment = $BaseModel.extend({ expiresAt: $DateLike, groupId: z.string().min(1).nullish(), instrumentId: z.string().min(1), + redirectUrl: $AssignmentRedirectUrl.nullish(), status: $AssignmentStatus, subjectId: z.string().min(1), url: z.string().url() @@ -55,6 +68,7 @@ const $CreateAssignmentData = z.object({ expiresAt: $FutureDate, groupId: z.string().nullish(), instrumentId: z.string(), + redirectUrl: $AssignmentRedirectUrl.optional(), subjectId: z.string() }); @@ -76,6 +90,8 @@ const $BulkAssignmentTimepoint = z.object({ const $BulkAssignmentRequestBase = z.object({ allowDuplicates: z.boolean().default(false), groupId: z.string().min(1), + /** Applied to every assignment the operation creates. */ + redirectUrl: $AssignmentRedirectUrl.optional(), subjectIds: $UniqueStrings.max(BULK_ASSIGNMENT_MAX_SUBJECTS), timepoints: z .array($BulkAssignmentTimepoint) @@ -221,6 +237,7 @@ export type { export { $Assignment, + $AssignmentRedirectUrl, $BulkAssignmentFailure, $BulkAssignmentIssue, $BulkAssignmentPreflightData, diff --git a/testing/src/pages/_app/group/remote-assignments.page.ts b/testing/src/pages/_app/group/remote-assignments.page.ts index 080079605b..beffaad3c0 100644 --- a/testing/src/pages/_app/group/remote-assignments.page.ts +++ b/testing/src/pages/_app/group/remote-assignments.page.ts @@ -26,6 +26,10 @@ export class RemoteAssignmentsPage extends AppPage { readonly mapStep: Locator; readonly parsePasted: Locator; readonly pasteInput: Locator; + readonly redirectUrlError: Locator; + readonly redirectUrlInput: Locator; + /** The review step's note of where the batch sends its subjects once they finish. */ + readonly reviewRedirectUrl: Locator; readonly selectAllSubjects: Locator; readonly skipUnplaceable: Locator; readonly submitBatch: Locator; @@ -49,6 +53,9 @@ export class RemoteAssignmentsPage extends AppPage { this.errorList = page.getByTestId('bulk-error-list'); this.parsePasted = page.getByTestId('bulk-parse-pasted'); this.pasteInput = page.getByTestId('bulk-paste-input'); + this.redirectUrlError = page.getByTestId('bulk-redirect-url-error'); + this.redirectUrlInput = page.getByTestId('bulk-redirect-url-input'); + this.reviewRedirectUrl = page.getByTestId('bulk-review-redirect-url'); this.skipUnplaceable = page.getByTestId('bulk-skip-unplaceable'); this.addTimepoint = page.getByTestId('bulk-add-timepoint'); this.confirmTimepoints = page.getByTestId('bulk-confirm-timepoints'); @@ -68,10 +75,15 @@ export class RemoteAssignmentsPage extends AppPage { /** Add the first offered instrument as a timepoint and continue to review. */ async addFirstInstrument() { + await this.addFirstTimepoint(); + await this.confirmTimepoints.click(); + } + + /** Add the first offered instrument as a timepoint, staying on the step. */ + async addFirstTimepoint() { await this.instrumentSelect.click(); await this.$ref.getByRole('option').first().click(); await this.addTimepoint.click(); - await this.confirmTimepoints.click(); } breadcrumb(step: WizardStepName): Locator { diff --git a/testing/src/pages/_app/instruments/render/$id.page.ts b/testing/src/pages/_app/instruments/render/$id.page.ts index 2d04387b82..e6bea0bd23 100644 --- a/testing/src/pages/_app/instruments/render/$id.page.ts +++ b/testing/src/pages/_app/instruments/render/$id.page.ts @@ -22,6 +22,9 @@ export class RenderInstrumentPage extends AppPage { readonly resetButton: Locator; /** Shown once every item of a series has been administered, in place of a scalar summary. */ readonly seriesCompletionHeading: Locator; + /** The bar shown above each item of a series, with its "Instrument N of M" label. */ + readonly seriesProgress: Locator; + readonly seriesProgressBar: Locator; readonly submitButton: Locator; readonly summaryHeading: Locator; @@ -33,6 +36,8 @@ export class RenderInstrumentPage extends AppPage { this.leaveDialog = page.getByTestId('blocker-dialog'); this.resetButton = page.getByRole('button', { name: 'Reset' }); this.seriesCompletionHeading = page.getByRole('heading', { name: /Thank You/i }); + this.seriesProgress = page.getByTestId('series-progress'); + this.seriesProgressBar = this.seriesProgress.getByRole('progressbar'); this.submitButton = page.getByRole('button', { name: 'Submit' }); this.summaryHeading = page.getByRole('heading', { name: /Summary of Results/i }); this.errorMessages = page.getByTestId('error-message-text'); diff --git a/testing/src/pages/_app/session/remote-assignment.page.ts b/testing/src/pages/_app/session/remote-assignment.page.ts index 7e2fbb863e..58485023fe 100644 --- a/testing/src/pages/_app/session/remote-assignment.page.ts +++ b/testing/src/pages/_app/session/remote-assignment.page.ts @@ -9,13 +9,19 @@ export class RemoteAssignmentPage extends AppPage { readonly instrumentShowcase: Locator; readonly pageHeader: Locator; readonly qrCode: Locator; + /** The dialog's optional field for where the subject is sent once they finish. */ + readonly redirectUrlInput: Locator; readonly urlInput: Locator; + /** The message libui shows under a field that failed validation. */ + readonly validationError: Locator; constructor(page: Page) { super(page); this.pageHeader = page.getByTestId('page-header'); this.instrumentShowcase = page.getByTestId('instrument-showcase'); this.createDialog = page.getByRole('dialog'); + this.redirectUrlInput = this.createDialog.getByLabel('Redirect URL (Optional)'); + this.validationError = this.createDialog.getByTestId('error-message-text'); this.urlInput = page.locator('input[readonly]'); // The copy-to-clipboard button carries no accessible name or testid; it is the only button // rendered immediately next to the readonly URL input. diff --git a/testing/src/specs/bulk-remote-assignments.spec.ts b/testing/src/specs/bulk-remote-assignments.spec.ts index 3972b7688b..6a33e76edc 100644 --- a/testing/src/specs/bulk-remote-assignments.spec.ts +++ b/testing/src/specs/bulk-remote-assignments.spec.ts @@ -58,6 +58,31 @@ test.describe('bulk remote assignments', () => { return apiRequestContext.post(`${API}${path}`, { data, headers: { Authorization: `Bearer ${adminToken}` } }); }; + test('should send every subject of a batch on to its redirect URL, and refuse one that is not https', async ({ + adminToken, + api, + apiRequestContext, + uniqueId + }) => { + const { group, subjectIds } = await seedGroup({ adminToken, api, apiRequestContext }, uniqueId, 2); + const [instrumentId] = await instrumentIdsFor(apiRequestContext, adminToken, 1); + const request = { groupId: group.id, subjectIds, timepoints: [{ expiresAt: futureIso(30), instrumentId }] }; + + const refused = await post(apiRequestContext, adminToken, '/assignments/bulk', { + ...request, + redirectUrl: 'http://example.com/done' + }); + expect(refused.status()).toBe(400); + + const redirectUrl = `https://example.com/done/${uniqueId}`; + const created = await post(apiRequestContext, adminToken, '/assignments/bulk', { ...request, redirectUrl }); + expect(created.status(), await created.text()).toBe(201); + expect(((await created.json()) as Assignment[]).map((assignment) => assignment.redirectUrl)).toEqual([ + redirectUrl, + redirectUrl + ]); + }); + test('should create one assignment per subject per timepoint, and refuse a repeat as a conflict', async ({ adminToken, api, diff --git a/testing/src/specs/gateway-assignment.spec.ts b/testing/src/specs/gateway-assignment.spec.ts index 3e5b39bfbd..2f6e65d70e 100644 --- a/testing/src/specs/gateway-assignment.spec.ts +++ b/testing/src/specs/gateway-assignment.spec.ts @@ -120,6 +120,8 @@ test.describe('gateway remote assignment', () => { await gatewayInstrument.submit(); await expect(gatewayInstrument.summaryHeading).toBeVisible(); await expect(gatewayInstrument.errorSummary).toHaveCount(0); + // Created without a redirect URL, so the subject is left on the completion screen. + await expect(gatewayPage.getByTestId('completion-redirect')).toHaveCount(0); // Revisiting the same link after completion must not serve the instrument again. Reuses this // now-completed assignment rather than soloing a second, expensive Cap proof-of-work just to @@ -145,6 +147,48 @@ test.describe('gateway remote assignment', () => { }); }); +test.describe('gateway completion redirect', () => { + test.describe.configure({ timeout: 180_000 }); + + test('should send the subject on to the redirect URL the assignment was created with once they complete it', async ({ + api, + context, + uniqueId + }) => { + const redirectUrl = `https://redirect.example.test/done/${uniqueId}`; + const group = await api.createGroup(); + const assignment = await api.createAssignment({ + expiresAt: new Date(Date.now() + 86_400_000), + groupId: group.id, + instrumentId: await api.findInstrumentIdByName('DNP_HAPPINESS_QUESTIONNAIRE'), + redirectUrl, + subjectId: await api.createSubject(group.id) + }); + + const gatewayPage = await context.newPage(); + // The site the subject is sent back to is not part of the stack, so answer for it here. + await gatewayPage.route('https://redirect.example.test/**', (route) => { + return route.fulfill({ body: '

Study portal

', contentType: 'text/html' }); + }); + await gatewayPage.goto(assignment.url); + await gatewayPage.locator('cap-widget').click(); + + const gatewayInstrument = new RenderInstrumentPage(gatewayPage); + await expect(gatewayInstrument.beginButton).toBeEnabled({ timeout: 120_000 }); + await gatewayInstrument.begin(); + await gatewayInstrument.completeHappinessQuestionnaire(); + await gatewayInstrument.submit(); + + // The completion screen stays up through the countdown, so the subject sees it before leaving. + await expect(gatewayInstrument.summaryHeading).toBeVisible(); + await expect(gatewayPage.getByTestId('completion-redirect')).toBeVisible(); + await gatewayPage.waitForURL(redirectUrl); + await expect(gatewayPage.getByRole('heading', { name: 'Study portal' })).toBeVisible(); + + await gatewayPage.close(); + }); +}); + test.describe('gateway malformed submission', () => { test.describe.configure({ timeout: 180_000 }); diff --git a/testing/src/specs/remote-assignment.spec.ts b/testing/src/specs/remote-assignment.spec.ts index d281652d93..f4f8af20f2 100644 --- a/testing/src/specs/remote-assignment.spec.ts +++ b/testing/src/specs/remote-assignment.spec.ts @@ -82,6 +82,36 @@ test.describe('remote assignment', () => { expect(await urlInput.inputValue()).toMatch(/^https?:\/\/.+/); }); + test('should create a remote assignment that sends the subject on to the https redirect URL given', async ({ + api, + getPageModel, + page, + uniqueId + }) => { + await startSession(getPageModel, `Redirect${uniqueId}`, `User${uniqueId}`, 'Female'); + + await page.getByTestId('nav-button-/session/remote-assignment').click(); + await page.waitForURL('**/session/remote-assignment'); + + const remoteAssignmentPage = new RemoteAssignmentPage(page); + await remoteAssignmentPage.selectInstrument('Happiness Questionnaire'); + await remoteAssignmentPage.redirectUrlInput.fill('http://example.com/done'); + await remoteAssignmentPage.submitAssignmentForm(); + await expect(remoteAssignmentPage.validationError).toHaveText('Must start with "https://"'); + await expect(remoteAssignmentPage.createDialog).toBeVisible(); + + const redirectUrl = `https://example.com/done/${uniqueId}`; + await remoteAssignmentPage.redirectUrlInput.fill(redirectUrl); + await remoteAssignmentPage.submitAssignmentForm(); + await expect(remoteAssignmentPage.urlInput).toBeVisible(); + await remoteAssignmentPage.closeResultButton.click(); + + await page.locator('[data-testid^="nav-button-/datahub/subjects/"]').click(); + await page.waitForURL('**/datahub/subjects/**/table'); + const subjectId = page.url().split('/datahub/subjects/')[1]!.split('/')[0]!; + expect(await api.findAssignments(subjectId)).toMatchObject([{ redirectUrl }]); + }); + test('should offer a copy-to-clipboard control and a QR code alongside the assignment link', async ({ context, getPageModel, diff --git a/testing/src/specs/remote-assignments-wizard.spec.ts b/testing/src/specs/remote-assignments-wizard.spec.ts index 34c1a6f87d..f231f15f83 100644 --- a/testing/src/specs/remote-assignments-wizard.spec.ts +++ b/testing/src/specs/remote-assignments-wizard.spec.ts @@ -158,6 +158,33 @@ test.describe('remote assignments wizard refusals', () => { // A stray row used to stop the whole batch, and only three steps later, as an identifier the user // never typed: it is named here by its row, and can be left out without redoing the file. + test('should hold back review until the redirect URL is https, then show it for review', async ({ + api, + isolatedGroupManager, + page + }) => { + const group = await isolatedGroupManager(); + await api.createSubject(group.id); + await page.goto('/group/remote-assignments?mode=create'); + const wizardPage = new RemoteAssignmentsPage(page); + await expect(wizardPage.wizard).toBeVisible(); + + await wizardPage.selectAllSubjects.click(); + await wizardPage.useSelectedSubjects.click(); + await expect(wizardPage.timepointsStep).toBeVisible(); + await wizardPage.addFirstTimepoint(); + + await wizardPage.redirectUrlInput.fill('http://example.com/done'); + await wizardPage.redirectUrlInput.blur(); + await expect(wizardPage.redirectUrlError).toHaveText('Must start with "https://"'); + await expect(wizardPage.confirmTimepoints).toBeDisabled(); + + await wizardPage.redirectUrlInput.fill('https://example.com/done'); + await expect(wizardPage.redirectUrlError).toBeHidden(); + await wizardPage.confirmTimepoints.click(); + await expect(wizardPage.reviewRedirectUrl).toContainText('https://example.com/done'); + }); + test('should warn about a row this group cannot place and continue once it is left out', async ({ api, isolatedGroupManager, diff --git a/testing/src/specs/series-instrument.spec.ts b/testing/src/specs/series-instrument.spec.ts index 543fa4d84d..1fb4c285d9 100644 --- a/testing/src/specs/series-instrument.spec.ts +++ b/testing/src/specs/series-instrument.spec.ts @@ -1,8 +1,11 @@ import type { InstrumentInfo } from '@opendatacapture/schemas/instrument'; +import type { Page } from '@playwright/test'; import { RenderInstrumentPage } from '../pages/_app/instruments/render/$id.page'; import { expect, test } from '../support/fixtures'; +import type { GetPageModel } from '../support/fixtures'; + const API = '/api/v1'; /** @@ -13,6 +16,33 @@ const SERIES_INSTRUMENT_TITLE = 'Happiness Questionnaire (Repeated)'; const ITEM_INSTRUMENT_TITLE = 'Happiness Questionnaire'; +/** Starts a session for a new subject and opens the repeated series on its overview screen. */ +async function openSeries({ + getPageModel, + page, + uniqueId +}: { + getPageModel: GetPageModel; + page: Page; + uniqueId: string; +}) { + const startSessionPage = await getPageModel('/session/start-session'); + await startSessionPage.sessionForm.waitFor({ state: 'visible' }); + await startSessionPage.selectIdentificationMethod('PERSONAL_INFO'); + await startSessionPage.fillSessionForm(`Series${uniqueId}`, `Subject${uniqueId}`, 'Female'); + await startSessionPage.submitForm(); + await expect(startSessionPage.successMessage).toBeVisible(); + + await page.getByTestId('nav-button-/instruments/accessible-instruments').click(); + await page.waitForURL('**/instruments/accessible-instruments'); + + const card = page.locator('[data-testid^="instrument-card-"]').filter({ hasText: SERIES_INSTRUMENT_TITLE }).first(); + await expect(card).toBeVisible(); + await card.click(); + + return new RenderInstrumentPage(page); +} + test.describe('series instrument', () => { /** * Assembling a series out of instruments the group has not been granted. @@ -64,21 +94,7 @@ test.describe('series instrument', () => { page, uniqueId }) => { - const startSessionPage = await getPageModel('/session/start-session'); - await startSessionPage.sessionForm.waitFor({ state: 'visible' }); - await startSessionPage.selectIdentificationMethod('PERSONAL_INFO'); - await startSessionPage.fillSessionForm(`Series${uniqueId}`, `Subject${uniqueId}`, 'Female'); - await startSessionPage.submitForm(); - await expect(startSessionPage.successMessage).toBeVisible(); - - await page.getByTestId('nav-button-/instruments/accessible-instruments').click(); - await page.waitForURL('**/instruments/accessible-instruments'); - - const card = page.locator('[data-testid^="instrument-card-"]').filter({ hasText: SERIES_INSTRUMENT_TITLE }).first(); - await expect(card).toBeVisible(); - await card.click(); - - const instrumentPage = new RenderInstrumentPage(page); + const instrumentPage = await openSeries({ getPageModel, page, uniqueId }); await instrumentPage.begin(); await instrumentPage.completeHappinessQuestionnaire(); await expect(instrumentPage.happinessSatisfiedRadio).toBeChecked(); @@ -103,4 +119,29 @@ test.describe('series instrument', () => { // records rather than one. await expect(page.getByTestId('data-table-body').getByTestId('data-table-row')).toHaveCount(2); }); + + // A skipProgress series has no interstitial screen, so the bar on each item's form is the only + // place the subject can see how far through the series they are. + test('should show the position in a skipProgress series on each item it administers', async ({ + getPageModel, + page, + uniqueId + }) => { + const instrumentPage = await openSeries({ getPageModel, page, uniqueId }); + await expect(instrumentPage.seriesProgress).toBeHidden(); + await instrumentPage.begin(); + + await expect(instrumentPage.seriesProgress).toContainText('Instrument 1 of 2'); + await expect(instrumentPage.seriesProgressBar).toHaveAttribute('aria-valuenow', '0'); + await instrumentPage.completeHappinessQuestionnaire(); + await instrumentPage.submit(); + + await expect(instrumentPage.seriesProgress).toContainText('Instrument 2 of 2'); + await expect(instrumentPage.seriesProgressBar).toHaveAttribute('aria-valuenow', '1'); + await instrumentPage.completeHappinessQuestionnaire(); + await instrumentPage.submit(); + + await expect(instrumentPage.seriesCompletionHeading).toBeVisible(); + await expect(instrumentPage.seriesProgress).toBeHidden(); + }); });