sdc-qrf 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/utils.ts ADDED
@@ -0,0 +1,676 @@
1
+ import fhirpath from 'fhirpath';
2
+ import _ from 'lodash';
3
+ import isArray from 'lodash/isArray';
4
+ import isPlainObject from 'lodash/isPlainObject';
5
+ import queryString from 'query-string';
6
+
7
+ import {
8
+ Questionnaire,
9
+ QuestionnaireItem,
10
+ QuestionnaireItemInitial,
11
+ QuestionnaireResponse,
12
+ QuestionnaireResponseItem,
13
+ QuestionnaireResponseItemAnswer,
14
+ } from 'shared/src/contrib/aidbox';
15
+
16
+ import {
17
+ AnswerValue,
18
+ FormAnswerItems,
19
+ FormGroupItems,
20
+ FormItems,
21
+ ItemContext,
22
+ QuestionnaireResponseFormData,
23
+ RepeatableFormGroupItems,
24
+ } from './types';
25
+
26
+ export function wrapAnswerValue(type: QuestionnaireItem['type'], answer: any) {
27
+ if (type === 'choice') {
28
+ if (isPlainObject(answer)) {
29
+ return { Coding: answer };
30
+ } else {
31
+ return { string: answer };
32
+ }
33
+ }
34
+
35
+ if (type === 'open-choice') {
36
+ if (isPlainObject(answer)) {
37
+ return { Coding: answer };
38
+ } else {
39
+ return { string: answer };
40
+ }
41
+ }
42
+
43
+ if (type === 'text') {
44
+ return { string: answer };
45
+ }
46
+
47
+ if (type === 'attachment') {
48
+ return { Attachment: answer };
49
+ }
50
+
51
+ if (type === 'reference') {
52
+ return { Reference: answer };
53
+ }
54
+
55
+ if (type === 'quantity') {
56
+ return { Quantity: answer };
57
+ }
58
+
59
+ return { [type]: answer };
60
+ }
61
+
62
+ export function getBranchItems(
63
+ fieldPath: string[],
64
+ questionnaire: Questionnaire,
65
+ questionnaireResponse: QuestionnaireResponse,
66
+ ): { qItem: QuestionnaireItem; qrItems: QuestionnaireResponseItem[] } {
67
+ let qrItem: QuestionnaireResponseItem | QuestionnaireResponse | undefined =
68
+ questionnaireResponse;
69
+ let qItem: QuestionnaireItem | Questionnaire = questionnaire;
70
+
71
+ // TODO: check for question with sub items
72
+ // TODO: check for root
73
+ for (let i = 0; i < fieldPath.length; i++) {
74
+ qItem = qItem.item!.find((curItem: any) => curItem.linkId === fieldPath[i])!;
75
+
76
+ if (qrItem) {
77
+ const qrItems: QuestionnaireResponseItem[] =
78
+ qrItem.item?.filter((curItem: any) => curItem.linkId === fieldPath[i]) ?? [];
79
+
80
+ if (qItem.repeats) {
81
+ if (i + 2 < fieldPath.length) {
82
+ // In the middle
83
+ qrItem = qrItems[parseInt(fieldPath[i + 2]!, 10)];
84
+ } else {
85
+ // Leaf
86
+ return { qItem, qrItems };
87
+ }
88
+ } else {
89
+ qrItem = qrItems[0];
90
+ }
91
+ }
92
+
93
+ if (qItem.repeats || qItem.type !== 'group') {
94
+ i += 2;
95
+ } else {
96
+ i++;
97
+ }
98
+ }
99
+
100
+ return { qItem, qrItems: [qrItem] } as {
101
+ qItem: QuestionnaireItem;
102
+ qrItems: QuestionnaireResponseItem[];
103
+ };
104
+ }
105
+
106
+ export function calcContext(
107
+ initialContext: ItemContext,
108
+ variables: QuestionnaireItem['variable'],
109
+ qItem: QuestionnaireItem,
110
+ qrItem: QuestionnaireResponseItem,
111
+ ): ItemContext {
112
+ // TODO: add root variable support
113
+ return {
114
+ ...(variables || []).reduce(
115
+ (acc, curVariable) => ({
116
+ ...acc,
117
+ [curVariable.name!]: fhirpath.evaluate(qrItem || {}, curVariable.expression!, acc),
118
+ }),
119
+ { ...initialContext, context: qrItem, qitem: qItem },
120
+ ),
121
+ };
122
+ }
123
+
124
+ export function compareValue(firstAnswerValue: AnswerValue, secondAnswerValue: AnswerValue) {
125
+ const firstValueType = _.keys(firstAnswerValue)[0] as keyof AnswerValue;
126
+ const secondValueType = _.keys(secondAnswerValue)[0] as keyof AnswerValue;
127
+ if (firstValueType !== secondValueType) {
128
+ throw new Error('Enable when must be used for the same type');
129
+ }
130
+ if (
131
+ !_.includes(
132
+ ['string', 'date', 'dateTime', 'time', 'uri', 'boolean', 'integer', 'decimal'],
133
+ firstValueType,
134
+ )
135
+ ) {
136
+ throw new Error('Impossible to compare non-primitive type');
137
+ }
138
+
139
+ if (firstValueType === 'Quantity') {
140
+ throw new Error('Quantity type is not supported yet');
141
+ }
142
+
143
+ const firstValue = firstAnswerValue[firstValueType];
144
+ const secondValue = secondAnswerValue[secondValueType];
145
+
146
+ if (firstValue! < secondValue!) {
147
+ return -1;
148
+ }
149
+ if (firstValue! > secondValue!) {
150
+ return 1;
151
+ }
152
+ return 0;
153
+ }
154
+
155
+ function isGroup(question: QuestionnaireItem) {
156
+ return question.type === 'group';
157
+ }
158
+
159
+ function isFormGroupItems(
160
+ question: QuestionnaireItem,
161
+ answers: FormGroupItems | FormAnswerItems[],
162
+ ): answers is FormGroupItems {
163
+ return isGroup(question) && _.isPlainObject(answers);
164
+ }
165
+
166
+ function isRepeatableFormGroupItems(
167
+ question: QuestionnaireItem,
168
+ answers: FormGroupItems,
169
+ ): answers is RepeatableFormGroupItems {
170
+ return !!question.repeats && _.isArray(answers.items);
171
+ }
172
+
173
+ function hasSubAnswerItems(items?: FormItems): items is FormItems {
174
+ return !!items && _.some(items, (x) => !_.some(x, _.isEmpty));
175
+ }
176
+
177
+ function mapFormToResponseRecursive(
178
+ answersItems: FormItems,
179
+ questionnaireItems: QuestionnaireItem[],
180
+ ): QuestionnaireResponseItem[] {
181
+ return Object.entries(answersItems).reduce((acc, [linkId, answers]) => {
182
+ if (!linkId) {
183
+ console.warn('The answer item has no linkId');
184
+ return acc;
185
+ }
186
+
187
+ const question = questionnaireItems.filter((qItem) => qItem.linkId === linkId)[0];
188
+
189
+ if (!question) {
190
+ return acc;
191
+ }
192
+
193
+ if (isFormGroupItems(question, answers)) {
194
+ const groups = isRepeatableFormGroupItems(question, answers)
195
+ ? answers.items || []
196
+ : answers.items
197
+ ? [answers.items]
198
+ : [];
199
+ return groups.reduce((newAcc, group) => {
200
+ const items = mapFormToResponseRecursive(group, question.item ?? []);
201
+
202
+ return [
203
+ ...newAcc,
204
+ {
205
+ linkId,
206
+ ...(items.length ? { item: items } : {}),
207
+ },
208
+ ];
209
+ }, acc);
210
+ }
211
+
212
+ return [
213
+ ...acc,
214
+ {
215
+ linkId,
216
+ answer: answers.reduce((answersAcc, answer) => {
217
+ if (typeof answer === 'undefined') {
218
+ return answersAcc;
219
+ }
220
+
221
+ if (!answer.value) {
222
+ return answersAcc;
223
+ }
224
+
225
+ const items = hasSubAnswerItems(answer.items)
226
+ ? mapFormToResponseRecursive(answer.items, question.item ?? [])
227
+ : [];
228
+
229
+ return [
230
+ ...answersAcc,
231
+ {
232
+ value: answer.value,
233
+ ...(items.length ? { item: items } : {}),
234
+ },
235
+ ];
236
+ }, [] as QuestionnaireResponseItemAnswer[]),
237
+ },
238
+ ];
239
+ }, [] as QuestionnaireResponseItem[]);
240
+ }
241
+
242
+ export function mapFormToResponse(
243
+ values: FormItems,
244
+ questionnaire: Questionnaire,
245
+ keepDisabledAnswers?: boolean,
246
+ ): Pick<QuestionnaireResponse, 'item'> {
247
+ return {
248
+ item: mapFormToResponseRecursive(
249
+ keepDisabledAnswers ? values : removeDisabledAnswers(questionnaire.item ?? [], values),
250
+ questionnaire.item ?? [],
251
+ ),
252
+ };
253
+ }
254
+
255
+ function mapResponseToFormRecursive(
256
+ questionnaireResponseItems: QuestionnaireResponseItem[],
257
+ questionnaireItems: QuestionnaireItem[],
258
+ ): FormItems {
259
+ return questionnaireItems.reduce((acc, question) => {
260
+ const { linkId, initial, repeats, text } = question;
261
+
262
+ if (!linkId) {
263
+ console.warn('The question has no linkId');
264
+ return acc;
265
+ }
266
+
267
+ const qrItems =
268
+ questionnaireResponseItems.filter((qrItem) => qrItem.linkId === linkId) ?? [];
269
+
270
+ if (qrItems.length && isGroup(question)) {
271
+ if (repeats) {
272
+ return {
273
+ ...acc,
274
+ [linkId]: {
275
+ question: text,
276
+ items: qrItems.map((qrItem) => {
277
+ return mapResponseToFormRecursive(
278
+ qrItem.item ?? [],
279
+ question.item ?? [],
280
+ );
281
+ }),
282
+ },
283
+ };
284
+ } else {
285
+ return {
286
+ ...acc,
287
+ [linkId]: {
288
+ question: text,
289
+ items: mapResponseToFormRecursive(
290
+ qrItems[0]?.item ?? [],
291
+ question.item ?? [],
292
+ ),
293
+ },
294
+ };
295
+ }
296
+ }
297
+
298
+ const answers = qrItems?.[0]?.answer?.length
299
+ ? qrItems[0].answer
300
+ : initialToQuestionnaireResponseItemAnswer(initial);
301
+
302
+ if (!answers.length) {
303
+ return acc;
304
+ }
305
+
306
+ return {
307
+ ...acc,
308
+ [linkId]: answers.map((answer) => ({
309
+ question: text,
310
+ value: answer.value,
311
+ items: mapResponseToFormRecursive(answer.item ?? [], question.item ?? []),
312
+ })),
313
+ };
314
+ }, {});
315
+ }
316
+
317
+ export function mapResponseToForm(resource: QuestionnaireResponse, questionnaire: Questionnaire) {
318
+ return mapResponseToFormRecursive(resource.item ?? [], questionnaire.item ?? []);
319
+ }
320
+
321
+ function initialToQuestionnaireResponseItemAnswer(initial: QuestionnaireItemInitial[] | undefined) {
322
+ return (initial ?? []).map(({ value }) => ({ value } as QuestionnaireResponseItemAnswer));
323
+ }
324
+
325
+ export function findAnswersForQuestionsRecursive(linkId: string, values?: FormItems): any | null {
326
+ if (values && _.has(values, linkId)) {
327
+ return values[linkId];
328
+ }
329
+
330
+ return _.reduce(
331
+ values,
332
+ (acc, v) => {
333
+ if (acc) {
334
+ return acc;
335
+ }
336
+
337
+ if (_.isArray(v)) {
338
+ return _.reduce(
339
+ v,
340
+ (acc2, v2) => {
341
+ if (acc2) {
342
+ return acc2;
343
+ }
344
+
345
+ return findAnswersForQuestionsRecursive(linkId, v2.items);
346
+ },
347
+ null,
348
+ );
349
+ } else if (_.isArray(v.items)) {
350
+ return _.reduce(
351
+ v.items,
352
+ (acc2, v2) => {
353
+ if (acc2) {
354
+ return acc2;
355
+ }
356
+
357
+ return findAnswersForQuestionsRecursive(linkId, v2);
358
+ },
359
+ null,
360
+ );
361
+ } else {
362
+ return findAnswersForQuestionsRecursive(linkId, v.items);
363
+ }
364
+ },
365
+ null,
366
+ );
367
+ }
368
+
369
+ export function findAnswersForQuestion<T = any>(
370
+ linkId: string,
371
+ parentPath: string[],
372
+ values: FormItems,
373
+ ): Array<FormAnswerItems<T>> {
374
+ const p = _.cloneDeep(parentPath);
375
+
376
+ // Go up
377
+ while (p.length) {
378
+ const part = p.pop()!;
379
+
380
+ // Find answers in parent groups (including repeatable)
381
+ // They might have either 'items' of the group or number of the repeatable group in path
382
+ if (part === 'items' || !isNaN(part as any)) {
383
+ const parentGroup = _.get(values, [...p, part]);
384
+
385
+ if (typeof parentGroup === 'object' && linkId in parentGroup) {
386
+ return parentGroup[linkId];
387
+ }
388
+ }
389
+ }
390
+
391
+ // Go down
392
+ const answers = findAnswersForQuestionsRecursive(linkId, values);
393
+
394
+ return answers ? answers : [];
395
+ }
396
+
397
+ export function isValueEqual(firstValue: AnswerValue, secondValue: AnswerValue) {
398
+ const firstValueType = _.keys(firstValue)[0];
399
+ const secondValueType = _.keys(secondValue)[0];
400
+
401
+ if (firstValueType !== secondValueType) {
402
+ console.error('Enable when must be used for the same type');
403
+
404
+ return false;
405
+ }
406
+
407
+ if (firstValueType === 'Coding') {
408
+ // NOTE: what if undefined === undefined
409
+ return firstValue.Coding?.code === secondValue.Coding?.code;
410
+ }
411
+
412
+ return _.isEqual(firstValue, secondValue);
413
+ }
414
+
415
+ export function getChecker(
416
+ operator: string,
417
+ ): (values: Array<{ value: any }>, answerValue: any) => boolean {
418
+ if (operator === '=') {
419
+ return (values, answerValue) =>
420
+ _.findIndex(values, ({ value }) => isValueEqual(value, answerValue)) !== -1;
421
+ }
422
+
423
+ if (operator === '!=') {
424
+ return (values, answerValue) =>
425
+ _.findIndex(values, ({ value }) => isValueEqual(value, answerValue)) === -1;
426
+ }
427
+
428
+ if (operator === 'exists') {
429
+ return (values, answerValue) => {
430
+ const answersLength = _.reject(
431
+ values,
432
+ (value) => _.isEmpty(value.value) || _.every(_.mapValues(value.value, _.isEmpty)),
433
+ ).length;
434
+ const answer = answerValue?.boolean ?? true;
435
+ return answersLength > 0 === answer;
436
+ };
437
+ }
438
+
439
+ if (operator === '>=') {
440
+ return (values, answerValue) =>
441
+ _.findIndex(
442
+ _.reject(values, (value) => _.isEmpty(value.value)),
443
+ ({ value }) => compareValue(value, answerValue) >= 0,
444
+ ) !== -1;
445
+ }
446
+
447
+ if (operator === '>') {
448
+ return (values, answerValue) =>
449
+ _.findIndex(
450
+ _.reject(values, (value) => _.isEmpty(value.value)),
451
+ ({ value }) => compareValue(value, answerValue) > 0,
452
+ ) !== -1;
453
+ }
454
+
455
+ if (operator === '<=') {
456
+ return (values, answerValue) =>
457
+ _.findIndex(
458
+ _.reject(values, (value) => _.isEmpty(value.value)),
459
+ ({ value }) => compareValue(value, answerValue) <= 0,
460
+ ) !== -1;
461
+ }
462
+
463
+ if (operator === '<') {
464
+ return (values, answerValue) =>
465
+ _.findIndex(
466
+ _.reject(values, (value) => _.isEmpty(value.value)),
467
+ ({ value }) => compareValue(value, answerValue) < 0,
468
+ ) !== -1;
469
+ }
470
+
471
+ console.error(`Unsupported enableWhen.operator ${operator}`);
472
+
473
+ return _.constant(true);
474
+ }
475
+
476
+ function isQuestionEnabled(qItem: QuestionnaireItem, parentPath: string[], values: FormItems) {
477
+ const { enableWhen, enableBehavior } = qItem;
478
+
479
+ if (!enableWhen) {
480
+ return true;
481
+ }
482
+
483
+ const iterFn = enableBehavior === 'any' ? _.some : _.every;
484
+
485
+ return iterFn(enableWhen, ({ question, answer, operator }) => {
486
+ const check = getChecker(operator);
487
+
488
+ if (_.includes(parentPath, question)) {
489
+ // TODO: handle double-nested values
490
+ const parentAnswerPath = _.slice(parentPath, 0, parentPath.length - 1);
491
+ const parentAnswer = _.get(values, parentAnswerPath);
492
+
493
+ return check(parentAnswer ? [parentAnswer] : [], answer);
494
+ }
495
+ const answers = findAnswersForQuestion(question, parentPath, values);
496
+
497
+ return check(_.compact(answers), answer);
498
+ });
499
+ }
500
+
501
+ export function removeDisabledAnswers(
502
+ questionnaireItems: QuestionnaireItem[],
503
+ values: FormItems,
504
+ ): FormItems {
505
+ return removeDisabledAnswersRecursive(questionnaireItems, [], values, {});
506
+ }
507
+
508
+ function removeDisabledAnswersRecursive(
509
+ questionnaireItems: QuestionnaireItem[],
510
+ parentPath: string[],
511
+ answersItems: FormItems,
512
+ initialValues: FormItems,
513
+ ): FormItems {
514
+ return questionnaireItems.reduce((acc, questionnaireItem) => {
515
+ const values = parentPath.length ? _.set(_.cloneDeep(initialValues), parentPath, acc) : acc;
516
+
517
+ const { linkId } = questionnaireItem;
518
+ const answers = answersItems[linkId!];
519
+
520
+ if (!answers) {
521
+ return acc;
522
+ }
523
+
524
+ if (!isQuestionEnabled(questionnaireItem, parentPath, values)) {
525
+ return acc;
526
+ }
527
+
528
+ if (isFormGroupItems(questionnaireItem, answers)) {
529
+ if (!answers.items) {
530
+ return acc;
531
+ }
532
+
533
+ if (isRepeatableFormGroupItems(questionnaireItem, answers)) {
534
+ return {
535
+ ...acc,
536
+ [linkId!]: {
537
+ ...answers,
538
+ items: answers.items.map((group, index) =>
539
+ removeDisabledAnswersRecursive(
540
+ questionnaireItem.item ?? [],
541
+ [...parentPath, linkId!, 'items', index.toString()],
542
+ group,
543
+ values,
544
+ ),
545
+ ),
546
+ },
547
+ };
548
+ } else {
549
+ return {
550
+ ...acc,
551
+ [linkId!]: {
552
+ ...answers,
553
+ items: removeDisabledAnswersRecursive(
554
+ questionnaireItem.item ?? [],
555
+ [...parentPath, linkId!, 'items'],
556
+ answers.items,
557
+ values,
558
+ ),
559
+ },
560
+ };
561
+ }
562
+ }
563
+
564
+ return {
565
+ ...acc,
566
+ [linkId!]: answers.reduce((answersAcc, answer, index) => {
567
+ if (typeof answer === 'undefined') {
568
+ return answersAcc;
569
+ }
570
+
571
+ if (!answer.value) {
572
+ return answersAcc;
573
+ }
574
+
575
+ const items = hasSubAnswerItems(answer.items)
576
+ ? removeDisabledAnswersRecursive(
577
+ questionnaireItem.item ?? [],
578
+ [...parentPath, linkId!, index.toString(), 'items'],
579
+ answer.items,
580
+ values,
581
+ )
582
+ : {};
583
+
584
+ return [...answersAcc, { ...answer, items }];
585
+ }, [] as any),
586
+ };
587
+ }, {} as any);
588
+ }
589
+
590
+ export function getEnabledQuestions(
591
+ questionnaireItems: QuestionnaireItem[],
592
+ parentPath: string[],
593
+ values: FormItems,
594
+ ) {
595
+ return _.filter(questionnaireItems, (qItem) => {
596
+ const { linkId } = qItem;
597
+
598
+ if (!linkId) {
599
+ return false;
600
+ }
601
+
602
+ return isQuestionEnabled(qItem, parentPath, values);
603
+ });
604
+ }
605
+
606
+ export function calcInitialContext(
607
+ qrfDataContext: QuestionnaireResponseFormData['context'],
608
+ values: FormItems,
609
+ ): ItemContext {
610
+ const questionnaireResponse = {
611
+ ...qrfDataContext.questionnaireResponse,
612
+ ...mapFormToResponse(values, qrfDataContext.questionnaire),
613
+ };
614
+
615
+ return {
616
+ ...qrfDataContext.launchContextParameters.reduce(
617
+ (acc, { name, value, resource }) => ({
618
+ ...acc,
619
+ [name]:
620
+ value && isPlainObject(value)
621
+ ? value[Object.keys(value)[0] as keyof AnswerValue]
622
+ : resource,
623
+ }),
624
+ {},
625
+ ),
626
+
627
+ // Vars defined in IG
628
+ questionnaire: qrfDataContext.questionnaire,
629
+ resource: questionnaireResponse,
630
+ context: questionnaireResponse,
631
+
632
+ // Vars we use for backward compatibility
633
+ Questionnaire: qrfDataContext.questionnaire,
634
+ QuestionnaireResponse: questionnaireResponse,
635
+ };
636
+ }
637
+
638
+ function resolveTemplateExpr(str: string, context: ItemContext) {
639
+ const matches = str.match(/{{[^}]+}}/g);
640
+
641
+ if (matches) {
642
+ return matches.reduce((result, match) => {
643
+ const expr = match.replace(/[{}]/g, '');
644
+
645
+ const resolvedVar = fhirpath.evaluate(context.context || {}, expr, context);
646
+
647
+ if (resolvedVar?.length) {
648
+ return result.replace(match, resolvedVar.join(','));
649
+ } else {
650
+ return result.replace(match, '');
651
+ }
652
+ }, str);
653
+ }
654
+
655
+ return str;
656
+ }
657
+
658
+ export function parseFhirQueryExpression(expression: string, context: ItemContext) {
659
+ const [resourceType, paramsQS] = expression.split('?', 2);
660
+ const searchParams = Object.fromEntries(
661
+ Object.entries(queryString.parse(paramsQS ?? '')).map(([key, value]) => {
662
+ if (!value) {
663
+ return [key, value];
664
+ }
665
+
666
+ return [
667
+ key,
668
+ isArray(value)
669
+ ? value.map((arrValue) => resolveTemplateExpr(arrValue!, context))
670
+ : resolveTemplateExpr(value, context),
671
+ ];
672
+ }),
673
+ );
674
+
675
+ return [resourceType, searchParams];
676
+ }