Skip to content
1 change: 1 addition & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ model SetupState {
updatedAt DateTime @updatedAt @db.Date
id String @id @default(auto()) @map("_id") @db.ObjectId
branding BrandingConfig?
defaultAssignmentDurationDays Int?
isDemo Boolean
isExperimentalFeaturesEnabled Boolean?
isSetup Boolean
Expand Down
59 changes: 59 additions & 0 deletions apps/api/src/setup/__tests__/setup.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { ConfigService, getModelToken, LoggingService, PRISMA_CLIENT_TOKEN } from '@douglasneuroinformatics/libnest';
import type { Model } from '@douglasneuroinformatics/libnest';
import { MockFactory } from '@douglasneuroinformatics/libnest/testing';
import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing';
import { Test } from '@nestjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { DemoService } from '@/demo/demo.service';
import { InstrumentReposService } from '@/instrument-repos/instrument-repos.service';
import { UsersService } from '@/users/users.service';

import { SetupService } from '../setup.service';

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

beforeEach(async () => {
vi.stubGlobal('__RELEASE__', { buildTime: 0, type: 'test', version: '0.0.0' });
const moduleRef = await Test.createTestingModule({
providers: [
SetupService,
MockFactory.createForModelToken(getModelToken('SetupState')),
MockFactory.createForService(ConfigService),
MockFactory.createForService(DemoService),
MockFactory.createForService(InstrumentReposService),
MockFactory.createForService(LoggingService),
MockFactory.createForService(UsersService),
{ provide: PRISMA_CLIENT_TOKEN, useValue: {} }
]
}).compile();
setupService = moduleRef.get(SetupService);
setupStateModel = moduleRef.get(getModelToken('SetupState'));
moduleRef.get<MockedInstance<ConfigService>>(ConfigService).get.mockReturnValue(false);
});

describe('updateState', () => {
it('should persist defaultAssignmentDurationDays', async () => {
setupStateModel.findFirst.mockResolvedValue({ id: 'setup-1', isSetup: true });
await setupService.updateState({ defaultAssignmentDurationDays: 45 });
expect(setupStateModel.update.mock.lastCall?.[0]).toMatchObject({
data: { defaultAssignmentDurationDays: 45 },
where: { id: 'setup-1' }
});
});
});

describe('getState', () => {
it('should return the saved defaultAssignmentDurationDays', async () => {
setupStateModel.findFirst.mockResolvedValue({ defaultAssignmentDurationDays: 45, isDemo: false, isSetup: true });
await expect(setupService.getState()).resolves.toMatchObject({ defaultAssignmentDurationDays: 45 });
});

it('should return null when unset', async () => {
setupStateModel.findFirst.mockResolvedValue({ isDemo: false, isSetup: true });
await expect(setupService.getState()).resolves.toMatchObject({ defaultAssignmentDurationDays: null });
});
});
});
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 @@ -8,6 +8,9 @@ export class UpdateSetupStateDto implements UpdateSetupStateData {
@ApiProperty({ required: false })
branding?: BrandingConfig | null;

@ApiProperty({ required: false })
defaultAssignmentDurationDays?: null | number;

@ApiProperty({ required: false })
isExperimentalFeaturesEnabled?: boolean;
}
1 change: 1 addition & 0 deletions apps/api/src/setup/setup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export class SetupService {
const branding = $BrandingConfig.nullable().safeParse(savedOptions?.branding ?? null);
return {
branding: branding.success ? branding.data : null,
defaultAssignmentDurationDays: savedOptions?.defaultAssignmentDurationDays ?? null,
isDemo: Boolean(savedOptions?.isDemo),
isExperimentalFeaturesEnabled: Boolean(savedOptions?.isExperimentalFeaturesEnabled),
isGatewayEnabled: this.configService.get('GATEWAY_ENABLED'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,23 @@ export const InstrumentShowcase: React.FC<{

const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
Comment thread
joshunrau marked this conversation as resolved.
// The SearchBar is a bare <input> inside a <form>; without preventDefault the Enter keypress
// submits that form, reloading the app and dropping the user back at the login page.
event.preventDefault();
const instrument = filteredInstruments[highlightedIndex];
if (instrument) {
onSelect(instrument);
}
return;
}
if (filteredInstruments.length === 0) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
setHighlightedIndex((prev) => Math.min(prev + 1, filteredInstruments.length - 1));
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setHighlightedIndex((prev) => Math.max(prev - 1, 0));
} else if (event.key === 'Enter') {
event.preventDefault();
const instrument = filteredInstruments[highlightedIndex];
if (instrument) {
onSelect(instrument);
}
}
},
[filteredInstruments, highlightedIndex, onSelect]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { InstrumentShowcase } from '../InstrumentShowcase';

// Initialises the shared libui translator, which the showcase's controls read on render.
import '@/services/i18n';

describe('InstrumentShowcase', () => {
afterEach(cleanup);

it('should not submit the wrapping search form when Enter is pressed with no matching instruments', () => {
render(<InstrumentShowcase data={[]} onSelect={vi.fn()} />);
const searchBar = screen.getByRole('searchbox');
// fireEvent returns false when the event was canceled (preventDefault called). Leaving it
// un-cancelled lets the SearchBar form submit and reload the app back to the login page.
expect(fireEvent.keyDown(searchBar, { key: 'Enter' })).toBe(false);
});

it('should not select anything when Enter is pressed with no matching instruments', () => {
const onSelect = vi.fn();
render(<InstrumentShowcase data={[]} onSelect={onSelect} />);
fireEvent.keyDown(screen.getByRole('searchbox'), { key: 'Enter' });
expect(onSelect).not.toHaveBeenCalled();
});
});
26 changes: 26 additions & 0 deletions apps/web/src/components/SaveStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from 'react';

import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { CheckIcon, Loader2Icon } from 'lucide-react';

export const SaveStatus = ({ state }: { state: 'idle' | 'saved' | 'saving' }) => {
const { t } = useTranslation();
if (state === 'idle') {
return null;
}
return (
<div className="fixed bottom-4 right-4 z-50 flex items-center gap-1.5 rounded-full border border-slate-200/70 bg-white/95 px-3 py-1.5 text-xs font-medium shadow-md backdrop-blur dark:border-slate-700/70 dark:bg-slate-800/95">
Comment thread
joshunrau marked this conversation as resolved.
{state === 'saving' ? (
<React.Fragment>
<Loader2Icon className="text-muted-foreground h-3.5 w-3.5 animate-spin" />
<span className="text-muted-foreground">{t({ en: 'Saving…', fr: 'Enregistrement…' })}</span>
</React.Fragment>
) : (
<React.Fragment>
<CheckIcon className="h-3.5 w-3.5 text-green-600" />
<span>{t({ en: 'All changes saved', fr: 'Modifications enregistrées' })}</span>
</React.Fragment>
)}
</div>
);
};
Loading
Loading