Skip to content

Commit 170cbb5

Browse files
committed
feat: final subject id regex implementation
1 parent 0b83d0d commit 170cbb5

15 files changed

Lines changed: 1971 additions & 1834 deletions

File tree

apps/api/prisma/schema.prisma

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,15 @@ enum SubjectIdentificationMethod {
5757
PERSONAL_INFO
5858
}
5959

60+
type ErrorMessage {
61+
en String?
62+
fr String?
63+
}
64+
6065
type GroupSettings {
61-
defaultIdentificationMethod SubjectIdentificationMethod
66+
defaultIdentificationMethod SubjectIdentificationMethod
67+
idValidationRegex String?
68+
idValidationRegexErrorMessage ErrorMessage?
6269
}
6370

6471
model GroupModel {

apps/api/src/demo/demo.service.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,11 @@ export class DemoService {
7474
await this.instrumentsService.create({ bundle: happinessQuestionnaireWithConsent });
7575
this.logger.debug('Done creating series instruments');
7676

77-
const groups: Group[] = [];
77+
const groups: ({ dummyIdPrefix?: string } & Group)[] = [];
7878
for (const group of DEMO_GROUPS) {
79-
groups.push(await this.groupsService.create(group));
79+
const { dummyIdPrefix, ...createGroupData } = group;
80+
const groupModel = await this.groupsService.create(createGroupData);
81+
groups.push({ ...groupModel, dummyIdPrefix });
8082
}
8183
this.logger.debug('Done creating groups');
8284

@@ -100,10 +102,10 @@ export class DemoService {
100102
};
101103

102104
let subjectId: string;
103-
if (group.type === 'CLINICAL') {
105+
if (group.settings.defaultIdentificationMethod === 'PERSONAL_INFO') {
104106
subjectId = await generateSubjectHash(subjectIdData);
105107
} else {
106-
subjectId = encodeScopedSubjectId(researchId, { groupName: group.name });
108+
subjectId = encodeScopedSubjectId((group.dummyIdPrefix ?? '') + researchId, { groupName: group.name });
107109
researchId++;
108110
}
109111

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { ValidationSchema } from '@douglasneuroinformatics/libnest/core';
22
import { ApiProperty } from '@nestjs/swagger';
33
import { $CreateGroupData } from '@opendatacapture/schemas/group';
4-
import type { CreateGroupData, GroupType } from '@opendatacapture/schemas/group';
4+
import type { CreateGroupData, GroupSettings, GroupType } from '@opendatacapture/schemas/group';
55

66
@ValidationSchema($CreateGroupData)
77
export class CreateGroupDto implements CreateGroupData {
88
@ApiProperty({ example: 'Depression Clinic' })
99
name: string;
10+
settings?: GroupSettings;
1011
type: GroupType;
1112
}
Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import { ValidationSchema } from '@douglasneuroinformatics/libnest/core';
2-
import { PartialType } from '@nestjs/swagger';
3-
import { $UpdateGroupData, type GroupSettings, type GroupType } from '@opendatacapture/schemas/group';
4-
5-
import { CreateGroupDto } from './create-group.dto';
2+
import { $UpdateGroupData } from '@opendatacapture/schemas/group';
3+
import type { GroupSettings, GroupType, UpdateGroupData } from '@opendatacapture/schemas/group';
64

75
@ValidationSchema($UpdateGroupData)
8-
export class UpdateGroupDto extends PartialType(CreateGroupDto) {
6+
export class UpdateGroupDto implements UpdateGroupData {
97
accessibleInstrumentIds?: string[];
10-
override name?: string;
11-
settings?: GroupSettings;
12-
override type?: GroupType;
8+
name?: string;
9+
settings?: Partial<GroupSettings>;
10+
type?: GroupType;
1311
}

apps/api/src/groups/groups.service.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,23 @@ export class GroupsService {
1717
private readonly instrumentsService: InstrumentsService
1818
) {}
1919

20-
async create(group: CreateGroupDto) {
21-
const exists = await this.groupModel.exists({ name: group.name });
20+
async create({ name, settings, type, ...data }: CreateGroupDto) {
21+
const exists = await this.groupModel.exists({ name });
2222
if (exists) {
23-
throw new ConflictException(`Group with name '${group.name}' already exists!`);
23+
throw new ConflictException(`Group with name '${name}' already exists!`);
2424
}
2525
return this.groupModel.create({
2626
data: {
2727
accessibleInstruments: {
2828
connect: (await this.instrumentsService.find()).map(({ id }) => ({ id }))
2929
},
30+
name,
3031
settings: {
31-
defaultIdentificationMethod: group.type === 'CLINICAL' ? 'PERSONAL_INFO' : 'CUSTOM_ID'
32+
defaultIdentificationMethod: type === 'CLINICAL' ? 'PERSONAL_INFO' : 'CUSTOM_ID',
33+
...settings
3234
},
33-
...group
35+
type,
36+
...data
3437
}
3538
});
3639
}
@@ -59,7 +62,7 @@ export class GroupsService {
5962

6063
async updateById(
6164
id: string,
62-
{ accessibleInstrumentIds, ...data }: UpdateGroupDto,
65+
{ accessibleInstrumentIds, settings, ...data }: UpdateGroupDto,
6366
{ ability }: EntityOperationOptions = {}
6467
) {
6568
const where: Prisma.GroupModelWhereInput = { AND: [accessibleQuery(ability, 'update', 'Group')], id };
@@ -78,6 +81,10 @@ export class GroupsService {
7881
set: accessibleInstrumentIds.map((id) => ({ id }))
7982
}
8083
: undefined,
84+
settings: {
85+
...group.settings,
86+
...settings
87+
},
8188
...data
8289
},
8390
where: { AND: [accessibleQuery(ability, 'update', 'Group')], id }

apps/playground/src/vim/adapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable max-lines */
12
/* eslint-disable no-fallthrough */
23

34
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
@@ -1091,7 +1092,6 @@ export default class EditorAdapter {
10911092
model.updateOptions({ tabSize: tabSize });
10921093
break;
10931094
}
1094-
// @ts-expect-error - maintain behavior of legacy code
10951095
case 'theme': {
10961096
this.theme = value as string;
10971097
this.editor.updateOptions({

apps/web/src/features/admin/pages/CreateGroupPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export const CreateGroupPage = () => {
4545
variant: 'select'
4646
}
4747
}}
48-
validationSchema={$CreateGroupData}
48+
validationSchema={$CreateGroupData.omit({ settings: true })}
4949
onSubmit={handleSubmit}
5050
/>
5151
</div>
Lines changed: 81 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,41 @@
1-
import { useMemo } from 'react';
2-
1+
/* eslint-disable max-lines-per-function */
32
import { Form } from '@douglasneuroinformatics/libui/components';
43
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
4+
import { $RegexString } from '@opendatacapture/schemas/core';
55
import type { UpdateGroupData } from '@opendatacapture/schemas/group';
6-
import type { UnilingualInstrumentInfo } from '@opendatacapture/schemas/instrument';
7-
import { $SubjectIdentificationMethod } from '@opendatacapture/schemas/subject';
6+
import { $SubjectIdentificationMethod, type SubjectIdentificationMethod } from '@opendatacapture/schemas/subject';
87
import type { Promisable } from 'type-fest';
98
import { z } from 'zod';
109

11-
import { useSetupState } from '@/hooks/useSetupState';
12-
import { useAppStore } from '@/store';
13-
14-
type InstrumentOptions = {
10+
export type AvailableInstrumentOptions = {
1511
form: { [key: string]: string };
1612
interactive: { [key: string]: string };
1713
series: { [key: string]: string };
1814
unknown: { [key: string]: string };
1915
};
2016

2117
export type ManageGroupFormProps = {
22-
availableInstruments: UnilingualInstrumentInfo[];
18+
availableInstrumentOptions: AvailableInstrumentOptions;
19+
initialValues: {
20+
accessibleFormInstrumentIds: Set<string>;
21+
accessibleInteractiveInstrumentIds: Set<string>;
22+
defaultIdentificationMethod?: SubjectIdentificationMethod;
23+
idValidationRegex?: null | string;
24+
};
2325
onSubmit: (data: Partial<UpdateGroupData>) => Promisable<any>;
26+
readOnly: boolean;
2427
};
2528

26-
export const ManageGroupForm = ({ availableInstruments, onSubmit }: ManageGroupFormProps) => {
27-
const currentGroup = useAppStore((store) => store.currentGroup);
28-
const { resolvedLanguage } = useTranslation();
29+
export const ManageGroupForm = ({
30+
availableInstrumentOptions,
31+
initialValues,
32+
onSubmit,
33+
readOnly
34+
}: ManageGroupFormProps) => {
2935
const { t } = useTranslation();
30-
const setupState = useSetupState();
31-
32-
const { initialValues, options } = useMemo(() => {
33-
const options: InstrumentOptions = {
34-
form: {},
35-
interactive: {},
36-
series: {},
37-
unknown: {}
38-
};
39-
const initialValues = {
40-
accessibleFormInstrumentIds: new Set<string>(),
41-
accessibleInteractiveInstrumentIds: new Set<string>(),
42-
defaultIdentificationMethod: currentGroup?.settings.defaultIdentificationMethod
43-
};
44-
for (const instrument of availableInstruments) {
45-
if (instrument.kind === 'FORM') {
46-
options.form[instrument.id] = instrument.details.title;
47-
if (currentGroup?.accessibleInstrumentIds.includes(instrument.id)) {
48-
initialValues.accessibleFormInstrumentIds.add(instrument.id);
49-
}
50-
} else if (instrument.kind === 'INTERACTIVE') {
51-
options.interactive[instrument.id] = instrument.details.title;
52-
if (currentGroup?.accessibleInstrumentIds.includes(instrument.id)) {
53-
initialValues.accessibleInteractiveInstrumentIds.add(instrument.id);
54-
}
55-
}
56-
}
57-
return { initialValues, options };
58-
}, [availableInstruments, currentGroup, resolvedLanguage]);
59-
60-
const isDisabled = Boolean(setupState.data?.isDemo && import.meta.env.PROD);
6136

6237
let description = t('group.manage.accessibleInstrumentsDesc');
63-
if (isDisabled) {
38+
if (readOnly) {
6439
description += ` ${t('group.manage.accessibleInstrumentDemoNote')}`;
6540
}
6641

@@ -74,13 +49,13 @@ export const ManageGroupForm = ({ availableInstruments, onSubmit }: ManageGroupF
7449
accessibleFormInstrumentIds: {
7550
kind: 'set',
7651
label: t('group.manage.forms'),
77-
options: options.form,
52+
options: availableInstrumentOptions.form,
7853
variant: 'listbox'
7954
},
8055
accessibleInteractiveInstrumentIds: {
8156
kind: 'set',
8257
label: t('group.manage.interactive'),
83-
options: options.interactive,
58+
options: availableInstrumentOptions.interactive,
8459
variant: 'listbox'
8560
}
8661
},
@@ -96,27 +71,81 @@ export const ManageGroupForm = ({ availableInstruments, onSubmit }: ManageGroupF
9671
PERSONAL_INFO: t('common.personalInfo')
9772
},
9873
variant: 'select'
74+
},
75+
idValidationRegex: {
76+
description: t({
77+
en: 'Define a custom regular expression to validate subject IDs (see https://regexr.com for help designing your regular expression).',
78+
fr: "Définir une expression régulière pour valider les identifiants des sujets (voir https://regexr.com pour obtenir de l'aide dans la conception de votre expression régulière)."
79+
}),
80+
kind: 'string',
81+
label: t({
82+
en: 'ID Validation Pattern',
83+
fr: 'TBD'
84+
}),
85+
variant: 'input'
86+
},
87+
idValidationRegexErrorMessageEn: {
88+
deps: ['idValidationRegex'],
89+
kind: 'dynamic',
90+
render: (data) => {
91+
if (!data.idValidationRegex) {
92+
return null;
93+
}
94+
return {
95+
kind: 'string',
96+
label: t({
97+
en: 'Custom ID Validation Message (English)',
98+
fr: 'Message de validation spécifique (en anglais)'
99+
}),
100+
variant: 'input'
101+
};
102+
}
103+
},
104+
idValidationRegexErrorMessageFr: {
105+
deps: ['idValidationRegex'],
106+
kind: 'dynamic',
107+
render: (data) => {
108+
if (!data.idValidationRegex) {
109+
return null;
110+
}
111+
return {
112+
kind: 'string',
113+
label: t({
114+
en: 'Custom ID Validation Message (French)',
115+
fr: 'Message de validation spécifique (en français)'
116+
}),
117+
variant: 'input'
118+
};
119+
}
99120
}
100121
},
101122
title: t('group.manage.groupSettings')
102123
}
103124
]}
104125
initialValues={initialValues}
105126
preventResetValuesOnReset={true}
106-
readOnly={isDisabled}
127+
readOnly={readOnly}
107128
validationSchema={z.object({
108129
accessibleFormInstrumentIds: z.set(z.string()),
109130
accessibleInteractiveInstrumentIds: z.set(z.string()),
110-
defaultIdentificationMethod: $SubjectIdentificationMethod.optional()
131+
defaultIdentificationMethod: $SubjectIdentificationMethod.optional(),
132+
idValidationRegex: $RegexString.optional(),
133+
idValidationRegexErrorMessageEn: z.string().optional(),
134+
idValidationRegexErrorMessageFr: z.string().optional()
111135
})}
112-
onSubmit={(data) =>
136+
onSubmit={(data) => {
113137
void onSubmit({
114138
accessibleInstrumentIds: [...data.accessibleFormInstrumentIds, ...data.accessibleInteractiveInstrumentIds],
115139
settings: {
116-
defaultIdentificationMethod: data.defaultIdentificationMethod
140+
defaultIdentificationMethod: data.defaultIdentificationMethod,
141+
idValidationRegex: data.idValidationRegex,
142+
idValidationRegexErrorMessage: {
143+
en: data.idValidationRegexErrorMessageEn,
144+
fr: data.idValidationRegexErrorMessageFr
145+
}
117146
}
118-
})
119-
}
147+
});
148+
}}
120149
/>
121150
);
122151
};

0 commit comments

Comments
 (0)