diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 05a717c14..2c409954e 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 diff --git a/apps/api/src/setup/__tests__/setup.service.spec.ts b/apps/api/src/setup/__tests__/setup.service.spec.ts new file mode 100644 index 000000000..3fe503c85 --- /dev/null +++ b/apps/api/src/setup/__tests__/setup.service.spec.ts @@ -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>; + + 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>(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 }); + }); + }); +}); diff --git a/apps/api/src/setup/dto/update-setup-state.dto.ts b/apps/api/src/setup/dto/update-setup-state.dto.ts index 233e89429..59d966082 100644 --- a/apps/api/src/setup/dto/update-setup-state.dto.ts +++ b/apps/api/src/setup/dto/update-setup-state.dto.ts @@ -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; } diff --git a/apps/api/src/setup/setup.service.ts b/apps/api/src/setup/setup.service.ts index 85a131d74..a719710a5 100644 --- a/apps/api/src/setup/setup.service.ts +++ b/apps/api/src/setup/setup.service.ts @@ -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'), diff --git a/apps/web/src/components/InstrumentShowcase/InstrumentShowcase.tsx b/apps/web/src/components/InstrumentShowcase/InstrumentShowcase.tsx index 486012ee4..5e24adc2a 100644 --- a/apps/web/src/components/InstrumentShowcase/InstrumentShowcase.tsx +++ b/apps/web/src/components/InstrumentShowcase/InstrumentShowcase.tsx @@ -69,6 +69,16 @@ export const InstrumentShowcase: React.FC<{ const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + // The SearchBar is a bare inside a
; 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(); @@ -76,12 +86,6 @@ export const InstrumentShowcase: React.FC<{ } 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] diff --git a/apps/web/src/components/InstrumentShowcase/__tests__/InstrumentShowcase.test.tsx b/apps/web/src/components/InstrumentShowcase/__tests__/InstrumentShowcase.test.tsx new file mode 100644 index 000000000..794169635 --- /dev/null +++ b/apps/web/src/components/InstrumentShowcase/__tests__/InstrumentShowcase.test.tsx @@ -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(); + 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(); + fireEvent.keyDown(screen.getByRole('searchbox'), { key: 'Enter' }); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/SaveStatus.tsx b/apps/web/src/components/SaveStatus.tsx new file mode 100644 index 000000000..ec45837fc --- /dev/null +++ b/apps/web/src/components/SaveStatus.tsx @@ -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 ( +
+ {state === 'saving' ? ( + + + {t({ en: 'Saving…', fr: 'Enregistrement…' })} + + ) : ( + + + {t({ en: 'All changes saved', fr: 'Modifications enregistrées' })} + + )} +
+ ); +}; diff --git a/apps/web/src/routes/_app/admin/settings.tsx b/apps/web/src/routes/_app/admin/settings.tsx index 430d84437..6a97f9428 100644 --- a/apps/web/src/routes/_app/admin/settings.tsx +++ b/apps/web/src/routes/_app/admin/settings.tsx @@ -1,15 +1,19 @@ -import React, { useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Button, Card, Heading, HoverCard, Select } from '@douglasneuroinformatics/libui/components'; -import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { Card, Heading, HoverCard, Input, Select, Separator } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { DEFAULT_ASSIGNMENT_DURATION_DAYS } from '@opendatacapture/schemas/assignment'; +import { MAX_ASSIGNMENT_DURATION_DAYS } from '@opendatacapture/schemas/setup'; import { createFileRoute } from '@tanstack/react-router'; import { CircleHelpIcon } from 'lucide-react'; import { PageHeader } from '@/components/PageHeader'; +import { SaveStatus } from '@/components/SaveStatus'; import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; import { useUpdateSetupStateMutation } from '@/hooks/useUpdateSetupStateMutation'; import { useAppStore } from '@/store'; import type { GroupSwitcherPosition } from '@/store/types'; +import { parseDurationDays } from '@/utils/assignment-duration'; /** libui ships no Switch, so this is hand-rolled — `label` names it for assistive technology. */ const Toggle = ({ @@ -35,38 +39,87 @@ const Toggle = ({ ); +const SettingSection = ({ children, title }: { children: React.ReactNode; title: string }) => ( +
+

{title}

+ {children} +
+); + +const DURATION_AUTOSAVE_DELAY = 700; + const RouteComponent = () => { const { t } = useTranslation(); const setupStateQuery = useSetupStateQuery(); const updateSetupStateMutation = useUpdateSetupStateMutation(); - const addNotification = useNotificationsStore((store) => store.addNotification); const groupSwitcherPosition = useAppStore((store) => store.groupSwitcherPosition); const setGroupSwitcherPosition = useAppStore((store) => store.setGroupSwitcherPosition); + // `mutate` is referentially stable across renders, so callbacks that depend on `autosave` stay stable too. + const { mutate } = updateSetupStateMutation; + const [saveState, setSaveState] = useState<'idle' | 'saved' | 'saving'>('idle'); + const savedTimerRef = useRef>(undefined); + + const autosave = useCallback( + (data: Parameters[0]) => { + setSaveState('saving'); + mutate(data, { + onSettled: () => { + setSaveState('saved'); + clearTimeout(savedTimerRef.current); + savedTimerRef.current = setTimeout(() => setSaveState('idle'), 2000); + } + }); + }, + [mutate] + ); + const uploaderLabel = t({ en: 'Enable Uploader', fr: 'Activer le téléversement' }); + const uploaderEnabled = setupStateQuery.data.isExperimentalFeaturesEnabled ?? false; - // The toggle is staged locally until Save, so it holds its own state. Resync it whenever the server - // value changes underneath — saving invalidates the query, and the response is the authority on what - // was actually stored. - const savedUploaderEnabled = setupStateQuery.data.isExperimentalFeaturesEnabled ?? false; - const [uploaderEnabled, setUploaderEnabled] = useState(savedUploaderEnabled); - const [syncedUploaderEnabled, setSyncedUploaderEnabled] = useState(savedUploaderEnabled); - if (syncedUploaderEnabled !== savedUploaderEnabled) { - setSyncedUploaderEnabled(savedUploaderEnabled); - setUploaderEnabled(savedUploaderEnabled); + // The input holds its own draft while the admin types. The saved draft is committed after a brief pause, + // on blur, and on unmount — so navigating away (e.g. clicking a sidebar link) still persists the value. + // It is resynced whenever the server value changes underneath (a save invalidates and refetches). + const savedDurationDays = setupStateQuery.data.defaultAssignmentDurationDays ?? DEFAULT_ASSIGNMENT_DURATION_DAYS; + const [durationDays, setDurationDays] = useState(String(savedDurationDays)); + const [syncedDurationDays, setSyncedDurationDays] = useState(savedDurationDays); + if (syncedDurationDays !== savedDurationDays) { + setSyncedDurationDays(savedDurationDays); + setDurationDays(String(savedDurationDays)); } - const handleSave = () => { - updateSetupStateMutation.mutate( - { isExperimentalFeaturesEnabled: uploaderEnabled }, - { - onSuccess: () => { - addNotification({ type: 'success' }); - } - } - ); + const durationDebounceRef = useRef>(undefined); + const durationDraftRef = useRef(durationDays); + durationDraftRef.current = durationDays; + const savedDurationRef = useRef(savedDurationDays); + savedDurationRef.current = savedDurationDays; + + const saveDurationIfChanged = useCallback(() => { + const parsed = parseDurationDays(durationDraftRef.current); + if (parsed !== null && parsed !== savedDurationRef.current) { + autosave({ defaultAssignmentDurationDays: parsed }); + } + return parsed; + }, [autosave]); + + const flushDurationOnBlur = (event: React.FocusEvent) => { + clearTimeout(durationDebounceRef.current); + const parsed = parseDurationDays(event.target.value); + if (parsed !== null && parsed !== savedDurationRef.current) { + setDurationDays(String(parsed)); + autosave({ defaultAssignmentDurationDays: parsed }); + } else if (parsed === null) { + setDurationDays(String(savedDurationRef.current)); + } }; + useEffect(() => { + return () => { + clearTimeout(durationDebounceRef.current); + saveDurationIfChanged(); + }; + }, [saveDurationIfChanged]); + return ( @@ -77,75 +130,105 @@ const RouteComponent = () => { })} -
+
- - {t({ en: 'Features', fr: 'Fonctionnalités' })} - - -
-
-

{uploaderLabel}

- - - - - - {t({ - en: 'When enabled, an upload menu item appears in the sidebar that allows users to upload instrument records directly from data files, bypassing the normal session workflow.', - fr: "Lorsqu'elle est activée, un élément de menu Téléversement apparaît dans le menu latéral et permet aux utilisateurs de téléverser des enregistrements d'instruments directement à partir de fichiers de données." - })} - - + + +
+
+

{uploaderLabel}

+ + + + + + {t({ + en: 'When enabled, an upload menu item appears in the sidebar that allows users to upload instrument records directly from data files, bypassing the normal session workflow.', + fr: "Lorsqu'elle est activée, un élément de menu Téléversement apparaît dans le menu latéral et permet aux utilisateurs de téléverser des enregistrements d'instruments directement à partir de fichiers de données." + })} + + +
+ autosave({ isExperimentalFeaturesEnabled: checked })} + />
- -
- - - - - - {/* A separate card because these settings are not the instance's: they live in this browser and - apply instantly, so they are deliberately outside the Save button's scope above. */} - - - {t({ en: 'Preferences', fr: 'Préférences' })} - - {t({ - en: 'Saved in this browser and applied immediately. They affect only you, not other users of this instance.', - fr: 'Enregistrées dans ce navigateur et appliquées immédiatement. Elles ne concernent que vous, et non les autres utilisateurs de cette instance.' - })} - - - -
-

- {t({ en: 'Group Switcher Position', fr: 'Position du sélecteur de groupe' })} -

- -
+ + + +
+
+

+ {t({ en: 'Default Assignment Validity (Days)', fr: 'Validité par défaut des tâches (jours)' })} +

+ + + + + + {t({ + en: 'The number of days a new remote assignment stays valid by default. This only sets the initial expiry date when creating an assignment, which can still be changed for each one.', + fr: "Le nombre de jours pendant lesquels une nouvelle tâche à distance reste valide par défaut. Ceci ne définit que la date d'expiration initiale lors de la création d'une tâche, qui peut toujours être modifiée pour chacune." + })} + + +
+ { + setDurationDays(event.target.value); + clearTimeout(durationDebounceRef.current); + durationDebounceRef.current = setTimeout(saveDurationIfChanged, DURATION_AUTOSAVE_DELAY); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + }} + /> +
+
+ + +
+

+ {t({ en: 'Group Switcher Position', fr: 'Position du sélecteur de groupe' })} +

+ +
+
+ ); }; diff --git a/apps/web/src/routes/_app/session/remote-assignment.tsx b/apps/web/src/routes/_app/session/remote-assignment.tsx index 05e9a3740..c282660a8 100644 --- a/apps/web/src/routes/_app/session/remote-assignment.tsx +++ b/apps/web/src/routes/_app/session/remote-assignment.tsx @@ -14,9 +14,9 @@ import { QRCode } from '@/components/QRCode'; import { WithFallback } from '@/components/WithFallback'; import { useCreateAssignment } from '@/hooks/useCreateAssignment'; import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; +import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; import { useAppStore } from '@/store'; - -const ONE_YEAR = 31556952000; +import { getDefaultAssignmentExpiry } from '@/utils/assignment-duration'; /** Slide-over panel shown after an assignment is created, displaying the URL, copy button, and QR code */ const AssignmentResultSlider: React.FC<{ @@ -81,6 +81,7 @@ const RouteComponent = () => { const navigate = useNavigate(); const { t } = useTranslation(); const instrumentInfoQuery = useInstrumentInfoQuery(); + const setupStateQuery = useSetupStateQuery(); const createAssignmentMutation = useCreateAssignment(); const [selectedInstrument, setSelectedInstrument] = useState(null); @@ -94,6 +95,11 @@ const RouteComponent = () => { } }, [currentSession]); + useEffect(() => { + const input = document.querySelector('[data-testid="instrument-search-bar"] input'); + input?.focus(); + }, []); + if (!currentSession) { return null; } @@ -123,7 +129,14 @@ const RouteComponent = () => { }} /> - + { + event.preventDefault(); + if (event.currentTarget instanceof HTMLElement) { + event.currentTarget.querySelector('button[type="submit"]')?.focus(); + } + }} + > {t({ @@ -153,7 +166,7 @@ const RouteComponent = () => { } }} initialValues={{ - expiresAt: new Date(Date.now() + ONE_YEAR) + expiresAt: getDefaultAssignmentExpiry(setupStateQuery.data.defaultAssignmentDurationDays) }} validationSchema={ z.object({ diff --git a/apps/web/src/utils/__tests__/assignment-duration.test.ts b/apps/web/src/utils/__tests__/assignment-duration.test.ts new file mode 100644 index 000000000..874a9b144 --- /dev/null +++ b/apps/web/src/utils/__tests__/assignment-duration.test.ts @@ -0,0 +1,37 @@ +import { DEFAULT_ASSIGNMENT_DURATION_DAYS } from '@opendatacapture/schemas/assignment'; +import { MAX_ASSIGNMENT_DURATION_DAYS } from '@opendatacapture/schemas/setup'; +import { describe, expect, it } from 'vitest'; + +import { getDefaultAssignmentExpiry, parseDurationDays } from '../assignment-duration'; + +const MS_PER_DAY = 86_400_000; +const NOW = Date.UTC(2026, 0, 1); + +describe('parseDurationDays', () => { + it.each(['1', '45', String(MAX_ASSIGNMENT_DURATION_DAYS)])('should accept the whole-day count %s', (raw) => { + expect(parseDurationDays(raw)).toBe(Number(raw)); + }); + + it.each(['', ' ', 'abc', '0', '-1', '1.5', String(MAX_ASSIGNMENT_DURATION_DAYS + 1)])( + 'should reject the invalid input %j', + (raw) => { + expect(parseDurationDays(raw)).toBeNull(); + } + ); +}); + +describe('getDefaultAssignmentExpiry', () => { + it('should apply the configured instance default when set', () => { + expect(getDefaultAssignmentExpiry(45, NOW).getTime()).toBe(NOW + 45 * MS_PER_DAY); + }); + + it('should fall back to the built-in default when null', () => { + expect(getDefaultAssignmentExpiry(null, NOW).getTime()).toBe(NOW + DEFAULT_ASSIGNMENT_DURATION_DAYS * MS_PER_DAY); + }); + + it('should fall back to the built-in default when undefined', () => { + expect(getDefaultAssignmentExpiry(undefined, NOW).getTime()).toBe( + NOW + DEFAULT_ASSIGNMENT_DURATION_DAYS * MS_PER_DAY + ); + }); +}); diff --git a/apps/web/src/utils/assignment-duration.ts b/apps/web/src/utils/assignment-duration.ts new file mode 100644 index 000000000..b1a3393ad --- /dev/null +++ b/apps/web/src/utils/assignment-duration.ts @@ -0,0 +1,21 @@ +import { DEFAULT_ASSIGNMENT_DURATION_DAYS } from '@opendatacapture/schemas/assignment'; +import { MAX_ASSIGNMENT_DURATION_DAYS } from '@opendatacapture/schemas/setup'; + +const MS_PER_DAY = 86_400_000; + +/** Returns the whole-day count if `raw` is a valid duration, otherwise null. */ +export const parseDurationDays = (raw: string): null | number => { + const parsed = Number(raw); + const isValid = + raw.trim() !== '' && Number.isInteger(parsed) && parsed >= 1 && parsed <= MAX_ASSIGNMENT_DURATION_DAYS; + return isValid ? parsed : null; +}; + +/** The expiry applied to a new remote assignment, from the instance default or the built-in fallback. */ +export const getDefaultAssignmentExpiry = ( + defaultAssignmentDurationDays: null | number | undefined, + now = Date.now() +): Date => { + const durationDays = defaultAssignmentDurationDays ?? DEFAULT_ASSIGNMENT_DURATION_DAYS; + return new Date(now + durationDays * MS_PER_DAY); +}; diff --git a/packages/schemas/src/assignment/assignment.ts b/packages/schemas/src/assignment/assignment.ts index 1675faa06..be75ecf4e 100644 --- a/packages/schemas/src/assignment/assignment.ts +++ b/packages/schemas/src/assignment/assignment.ts @@ -7,6 +7,9 @@ export const $AssignmentStatus = z.enum(['CANCELED', 'COMPLETE', 'EXPIRED', 'OUT export type AssignmentStatus = z.infer; +/** Fallback validity period (in days) for a new remote assignment when the instance has not configured one. */ +export const DEFAULT_ASSIGNMENT_DURATION_DAYS = 365; + /** * An self-contained object representing an assignment. */ diff --git a/packages/schemas/src/setup/setup.test.ts b/packages/schemas/src/setup/setup.test.ts index 57307f62a..bb56a93d0 100644 --- a/packages/schemas/src/setup/setup.test.ts +++ b/packages/schemas/src/setup/setup.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { $BrandingConfig } from './setup.js'; +import { $BrandingConfig, $UpdateSetupStateData } from './setup.js'; describe('$BrandingConfig', () => { describe('customLogoSrc', () => { @@ -43,3 +43,19 @@ describe('$BrandingConfig', () => { ); }); }); + +describe('$UpdateSetupStateData', () => { + describe('defaultAssignmentDurationDays', () => { + it.each([1, 30, 365, 3650])('should accept the positive whole day count %d', (value) => { + expect($UpdateSetupStateData.safeParse({ defaultAssignmentDurationDays: value }).success).toBe(true); + }); + + it.each([0, -1, 1.5, 3651])('should reject the out-of-range or non-integer value %d', (value) => { + expect($UpdateSetupStateData.safeParse({ defaultAssignmentDurationDays: value }).success).toBe(false); + }); + + it('should allow the field to be omitted', () => { + expect($UpdateSetupStateData.safeParse({}).success).toBe(true); + }); + }); +}); diff --git a/packages/schemas/src/setup/setup.ts b/packages/schemas/src/setup/setup.ts index 678cd361f..6ca88a107 100644 --- a/packages/schemas/src/setup/setup.ts +++ b/packages/schemas/src/setup/setup.ts @@ -176,8 +176,15 @@ const $BrandingConfig = z.object({ taglineFontSize: $FontSize.nullish() }); +/** Upper bound (in days) for a configured assignment validity period; roughly ten years. */ +const MAX_ASSIGNMENT_DURATION_DAYS = 3650; + +/** The instance-wide default validity period (in days) applied to new remote assignments. */ +const $DefaultAssignmentDurationDays = z.number().int().positive().max(MAX_ASSIGNMENT_DURATION_DAYS); + const $SetupState = z.object({ branding: $BrandingConfig.nullish(), + defaultAssignmentDurationDays: $DefaultAssignmentDurationDays.nullish(), isDemo: z.boolean(), isExperimentalFeaturesEnabled: z.boolean().nullish(), isGatewayEnabled: z.boolean(), @@ -188,6 +195,7 @@ const $SetupState = z.object({ const $UpdateSetupStateData = z.object({ branding: $BrandingConfig.nullish(), + defaultAssignmentDurationDays: $DefaultAssignmentDurationDays.nullish(), isExperimentalFeaturesEnabled: z.boolean().nullish() }); @@ -240,5 +248,6 @@ export { LOGO_ALIGNMENTS, LOGO_SIZES, LOGO_SOURCES, + MAX_ASSIGNMENT_DURATION_DAYS, PANEL_SECTIONS }; diff --git a/testing/src/pages/_app/admin/settings.page.ts b/testing/src/pages/_app/admin/settings.page.ts new file mode 100644 index 000000000..1ba7e1e2e --- /dev/null +++ b/testing/src/pages/_app/admin/settings.page.ts @@ -0,0 +1,19 @@ +import type { Locator, Page } from '@playwright/test'; + +import { AppPage } from '../route.page'; + +export class SettingsPage extends AppPage { + readonly defaultAssignmentDurationInput: Locator; + readonly pageHeader: Locator; + + constructor(page: Page) { + super(page); + this.pageHeader = page.getByTestId('page-header'); + this.defaultAssignmentDurationInput = page.getByTestId('default-assignment-duration-input'); + } + + async setDefaultAssignmentDuration(days: number) { + await this.defaultAssignmentDurationInput.fill(String(days)); + await this.defaultAssignmentDurationInput.press('Enter'); + } +} diff --git a/testing/src/specs/settings.spec.ts b/testing/src/specs/settings.spec.ts new file mode 100644 index 000000000..512024b53 --- /dev/null +++ b/testing/src/specs/settings.spec.ts @@ -0,0 +1,25 @@ +import { MAX_ASSIGNMENT_DURATION_DAYS } from '@opendatacapture/schemas/setup'; + +import { expect, test } from '../support/fixtures'; + +test.describe('application settings', () => { + test.use({ actingRole: 'ADMIN' }); + + test('should persist the default assignment duration @smoke', async ({ getPageModel, page, uniqueId }) => { + // The setting is instance-wide and every project shares one database, so a fixed value would already + // be stored by the time the second browser runs, and the settings page would skip the save entirely. + const durationDays = 1 + (Number.parseInt(uniqueId, 16) % MAX_ASSIGNMENT_DURATION_DAYS); + + const settingsPage = await getPageModel('/admin/settings'); + await expect(settingsPage.pageHeader).toContainText('Application Settings'); + + const saveResponse = page.waitForResponse( + (response) => response.url().endsWith('/v1/setup') && response.request().method() === 'PATCH' + ); + await settingsPage.setDefaultAssignmentDuration(durationDays); + expect((await saveResponse).ok()).toBe(true); + + await page.reload(); + await expect(settingsPage.defaultAssignmentDurationInput).toHaveValue(String(durationDays)); + }); +}); diff --git a/testing/src/support/fixtures.ts b/testing/src/support/fixtures.ts index 47d76af3f..c9a0fd032 100644 --- a/testing/src/support/fixtures.ts +++ b/testing/src/support/fixtures.ts @@ -3,6 +3,7 @@ import { request as apiRequestFactory, test as base, expect } from '@playwright/test'; import type { APIRequestContext } from '@playwright/test'; +import { SettingsPage } from '../pages/_app/admin/settings.page'; import { DashboardPage } from '../pages/_app/dashboard.page'; import { SubjectDataTablePage } from '../pages/_app/datahub/$subjectId/table/index.page'; import { DatahubPage } from '../pages/_app/datahub/index.page'; @@ -18,6 +19,7 @@ import { randomId } from './unique'; import type { AppState, NavigateVariadicArgs, Role, RouteTo } from './types'; const pageModels = { + '/admin/settings': SettingsPage, '/auth/login': LoginPage, '/dashboard': DashboardPage, '/datahub': DatahubPage,