Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
8bd9a72
feat(web): add bulk remote assignment wizard
thomasbeaudry Sep 2, 2026
35703df
feat(web): gate bulk assignments behind an admin toggle, and rework t…
thomasbeaudry Sep 3, 2026
97e58e3
fix(api): restore required:false on the uploader flag's swagger metadata
thomasbeaudry Sep 3, 2026
7759d0b
feat(web): explain the bulk workflow and use a tab strip for the source
thomasbeaudry Sep 3, 2026
e3dc4ff
feat(web): polish the bulk source step
thomasbeaudry Sep 3, 2026
d222246
feat(web): space the bulk description from the tabs and drop the em dash
thomasbeaudry Sep 3, 2026
609bbfc
feat(web): add subject search and per-row selection to the bulk picker
thomasbeaudry Sep 3, 2026
d0d5f02
feat(web): select subjects with checkboxes rather than per-row buttons
thomasbeaudry Sep 3, 2026
dd128bd
fix(web): stop the timepoint form resizing with the instrument name
thomasbeaudry Sep 9, 2026
9de3704
feat(web): give every wizard step one frame, and hand out links on co…
thomasbeaudry Sep 9, 2026
d73ae03
feat(web): stamp the bulk results csv with the time it was downloaded
thomasbeaudry Sep 9, 2026
8232cc6
feat(web): clickable breadcrumbs, and keep step data when navigating …
thomasbeaudry Sep 10, 2026
bae094b
feat(web): match breadcrumb buttons to the datahub style and widen th…
thomasbeaudry Sep 10, 2026
ee31921
fix(web): guard the bulk route, and handle a clipboard that is not there
thomasbeaudry Sep 10, 2026
0d646ab
fix(web): give every wizard step the same body height
thomasbeaudry Sep 10, 2026
0c0a79d
feat(web): make the bulk results export readable and traceable
thomasbeaudry Sep 10, 2026
10e347b
fix(web): clarify the column mapping and drop the dead preview pagina…
thomasbeaudry Sep 10, 2026
9715eb8
fix(web): shade table headers and stop the current crumb reading as d…
thomasbeaudry Sep 10, 2026
9bfe7a4
style(web): make the mapping and preview header rows clearly distinct
thomasbeaudry Sep 10, 2026
90b9181
style(web): darken the bulk error text so it reads on its tinted panel
thomasbeaudry Sep 10, 2026
593ad3c
style(web): fill the error panel for white text, and drop em dashes
thomasbeaudry Sep 10, 2026
c65db92
style(web): state the batch as an equation on review, drop the corner…
thomasbeaudry Sep 10, 2026
b2d7938
fix(web): export the displayed subject id alongside the full one
thomasbeaudry Sep 10, 2026
9b2cbe5
fix(web): copy the same rows the file carries, and drop the full iden…
thomasbeaudry Sep 10, 2026
e08f7fc
fix(web): make the exported subject value usable for a later assignment
thomasbeaudry Sep 10, 2026
b4f24e2
feat(web): name the subjects a refusal flags, and lead the export wit…
thomasbeaudry Sep 10, 2026
3d8f791
feat(web): put the subject picker on DataTable, with sorting and a se…
thomasbeaudry Sep 10, 2026
6901952
feat(web): sort the picker from the column headers, drop the sex filt…
thomasbeaudry Sep 10, 2026
c12b9b9
Merge branch 'main' into feat/bulk-remote-assignments-web
thomasbeaudry Sep 10, 2026
2022545
Merge branch 'main' into feat/bulk-remote-assignments-web
thomasbeaudry Sep 11, 2026
0cd0925
fix(web): address Copilot review findings on bulk remote assignment w…
thomasbeaudry Sep 14, 2026
8140a96
fix(api): default isBulkRemoteAssignmentsEnabled to true for new inst…
thomasbeaudry Sep 14, 2026
15d9947
Merge remote-tracking branch 'origin/main' into feat/bulk-remote-assi…
thomasbeaudry Sep 14, 2026
91242ac
fix(testing): expand Group Actions nav group in E2E tests
thomasbeaudry Sep 14, 2026
d6a6068
Merge branch 'main' into feat/bulk-remote-assignments-web
thomasbeaudry Sep 15, 2026
5c41709
fix(testing): wait for the Group Actions nav group before expanding it
thomasbeaudry Sep 15, 2026
adf59ec
Merge branch 'main' into feat/bulk-remote-assignments-web
thomasbeaudry Sep 15, 2026
2eb509d
test(serve-instrument): bind each server test to an OS-assigned free …
thomasbeaudry Sep 15, 2026
120fdf2
Merge branch 'main' into feat/bulk-remote-assignments-web
thomasbeaudry Sep 16, 2026
0c41a54
fix: address review on bulk remote assignments
thomasbeaudry Sep 16, 2026
8b1e888
refactor(web): rename bulk-remote-assignments route to remote-assignm…
joshunrau Sep 16, 2026
e5d50b1
fix(storybook): generate Tailwind utilities for apps/web and apps/pla…
joshunrau Sep 16, 2026
f23d025
feat(web): restyle the bulk remote assignment wizard as one flat card
joshunrau Sep 16, 2026
46a4182
test(testing): drive the remote assignments wizard through the UI
joshunrau Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ model SetupState {
activeLanguages Language[] @default([en, fr])
branding BrandingConfig?
defaultAssignmentDurationDays Int?
isBulkRemoteAssignmentsEnabled Boolean?
isDemo Boolean
isExperimentalFeaturesEnabled Boolean?
isSetup Boolean
Expand Down
68 changes: 67 additions & 1 deletion apps/api/src/setup/__tests__/setup.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -12,6 +13,7 @@ import { UsersService } from '@/users/users.service';
import { SetupService } from '../setup.service';

describe('SetupService', () => {
let configService: MockedInstance<ConfigService>;
let setupService: SetupService;
let setupStateModel: MockedInstance<Model<'SetupState'>>;

Expand All @@ -31,7 +33,8 @@ describe('SetupService', () => {
}).compile();
setupService = moduleRef.get(SetupService);
setupStateModel = moduleRef.get(getModelToken('SetupState'));
moduleRef.get<MockedInstance<ConfigService>>(ConfigService).get.mockReturnValue(false);
configService = moduleRef.get<MockedInstance<ConfigService>>(ConfigService);
configService.get.mockReturnValue(false);
});

describe('updateState', () => {
Expand All @@ -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 });
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/setup/dto/update-setup-state.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
10 changes: 9 additions & 1 deletion apps/api/src/setup/setup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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: {
Expand Down
54 changes: 54 additions & 0 deletions apps/web/src/__tests__/bulk-step-layout.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = <div data-testid="step-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(
<StepLayout description="description" step="INSTRUMENTS" title="title" onStepChange={onStepChange}>
{body}
</StepLayout>
);
expect(screen.getByTestId('bulk-breadcrumb-INSTRUMENTS').getAttribute('aria-current')).toBe('step');
expect(screen.getByTestId<HTMLButtonElement>('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(
<StepLayout description="description" step={null} title="title">
{body}
</StepLayout>
);
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 = <button data-testid="step-primary" type="button" />;
const { rerender } = render(
<StepLayout description="description" footer={footer} step="SUBJECTS" title="title">
{body}
</StepLayout>
);
expect(screen.getByTestId('step-primary')).toBeTruthy();
rerender(
<StepLayout description="description" step="SUBJECTS" title="title">
{body}
</StepLayout>
);
expect(screen.queryByTestId('step-primary')).toBeNull();
});
});
45 changes: 40 additions & 5 deletions apps/web/src/__tests__/gateway-route-guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,32 @@ const GATEWAY_ROUTES = [
importRoute: async () => (await import('@/routes/_app/group/email-templates')).Route,
params: {},
redirectsTo: '/dashboard'
},
{
importRoute: async () => (await import('@/routes/_app/group/remote-assignments')).Route,
params: {},
redirectsTo: '/dashboard'
}
] as const;

const runGuard = (route: { options: { beforeLoad?: unknown } }, params: object) => {
const beforeLoad = route.options.beforeLoad as (opts: { params: object }) => void;
/**
* Awaited, because a guard that has to consult setup state is async — a redirect it throws arrives
* as a rejection rather than synchronously. Guards that stay synchronous are unaffected.
*/
const runGuard = async (
route: { options: { beforeLoad?: unknown } },
params: object,
setupState: { isBulkRemoteAssignmentsEnabled?: boolean } = { isBulkRemoteAssignmentsEnabled: true }
) => {
const beforeLoad = route.options.beforeLoad as (opts: {
context: { queryClient: { ensureQueryData: () => Promise<unknown> } };
params: object;
}) => Promise<void> | void;
try {
beforeLoad({ params });
await beforeLoad({
context: { queryClient: { ensureQueryData: () => Promise.resolve(setupState) } },
params
});
} catch (err) {
return err;
}
Expand All @@ -47,12 +66,28 @@ beforeEach(() => {
describe.each(GATEWAY_ROUTES)('$redirectsTo guard', ({ importRoute, params, redirectsTo }) => {
it('should redirect away when the gateway is not deployed, so a bookmarked link cannot reach a page whose endpoints are not mounted', async () => {
mocks.config.setup.isGatewayEnabled = false;
const thrown = runGuard(await importRoute(), params);
const thrown = await runGuard(await importRoute(), params);
expect(isRedirect(thrown)).toBe(true);
expect((thrown as { options: { to: string } }).options.to).toBe(redirectsTo);
});

it('should allow the route when the gateway is deployed', async () => {
expect(runGuard(await importRoute(), params)).toBeNull();
expect(await runGuard(await importRoute(), params)).toBeNull();
});
});

describe('bulk remote assignments guard', () => {
const importRoute = async () => (await import('@/routes/_app/group/remote-assignments')).Route;

// Hiding the nav link is not enough: the page must not be reachable by url when an admin has
// turned the feature off.
it('should redirect away when the instance toggle is off', async () => {
const thrown = await runGuard(await importRoute(), {}, { isBulkRemoteAssignmentsEnabled: false });
expect(isRedirect(thrown)).toBe(true);
expect((thrown as { options: { to: string } }).options.to).toBe('/dashboard');
});

it('should allow the route when the toggle is on and the gateway is deployed', async () => {
expect(await runGuard(await importRoute(), {}, { isBulkRemoteAssignmentsEnabled: true })).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React from 'react';

import type { Subject } from '@opendatacapture/schemas/subject';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

import { withAppFrame } from '@/testing/withAppFrame';

import { BulkRemoteAssignmentWizard } from './BulkRemoteAssignmentWizard';

type Story = StoryObj<typeof BulkRemoteAssignmentWizard>;

const subject = (id: string, overrides: Partial<Pick<Subject, 'dateOfBirth' | 'sex'>> = {}): Subject => ({
createdAt: new Date(),
dateOfBirth: null,
firstName: null,
groupIds: ['group-1'],
id,
lastName: null,
sex: null,
updatedAt: new Date(),
...overrides
});

export default {
args: {
defaultExpiresAt: new Date(Date.now() + 30 * 86_400_000).toISOString().split('T')[0]!,
groupId: 'group-1',
groupName: 'Depression Clinic',
instruments: [
{ id: 'instrument-1', title: 'Happiness Questionnaire' },
{ id: 'instrument-2', title: 'General Consent Form' }
],
subjectIdDisplayLength: 9,
subjects: [
subject('Depression_Clinic$001', { dateOfBirth: new Date(1979, 7, 12), sex: 'MALE' }),
subject('Depression_Clinic$002', { dateOfBirth: new Date(1972, 1, 17), sex: 'FEMALE' }),
subject('Depression_Clinic$003'),
subject('Depression_Clinic$004', { dateOfBirth: new Date(1990, 4, 3), sex: 'FEMALE' }),
subject('Depression_Clinic$005')
]
},
component: BulkRemoteAssignmentWizard,
decorators: [
// The wizard submits through a mutation, so it needs a client even in the states that never submit.
(Story: React.ComponentType) => (
<QueryClientProvider client={new QueryClient()}>
<Story />
</QueryClientProvider>
),
withAppFrame('Remote Assignments')
],
parameters: {
layout: 'fullscreen'
}
} as Meta<typeof BulkRemoteAssignmentWizard>;

/** The entry state: pick existing subjects, upload a file, or paste delimited data. */
export const Source: Story = {};

/** Subjects identified by a hash, which the picker still lists and truncates for display. */
export const HashIdentifiedSubjects: Story = {
args: {
subjects: [subject('a'.repeat(64)), subject('b'.repeat(64))]
}
};

/** The group has opted into no instruments, so no timepoint can be added. */
export const NoAccessibleInstruments: Story = {
args: {
instruments: []
}
};
Loading
Loading