import type { SaveTransport } from '../utils/saveOperation'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'; import { Bundle, Coding, ContactDetail, ContactPoint, Extension, Organization, Patient, Questionnaire, QuestionnaireItem, QuestionnaireItemAnswerOption, QuestionnaireItemEnableWhen, QuestionnaireItemInitial, QuestionnaireResponse, QuestionnaireResponseItem, QuestionnaireResponseItemAnswer, Reference, Task, ValueSet } from 'fhir/r4'; import { environment } from '../environments/environment'; import { AUTO_ASSIGN_TRIGGER_URL, AUTO_ASSIGN_PRIORITY_URL } from '../config/autoAssign'; import { useFhirServiceContext } from './FhirService.tsx'; import { useAuthServiceContext } from './AuthService.tsx'; import { useHttpService } from './HttpService.tsx'; import { IValueSet } from './ValueSetService.tsx'; import { v4 as uuidv4 } from 'uuid'; import { formatFhirHumanName } from '../utils/fhirHumanName.tsx'; import { COLUMN_GRID, COLUMN_SPAN_EXTENSION_URL, normalizeColumnSpan } from '../utils/questionnaireLayout'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { PUBLIC_FORM_TAG } from '../utils/publicFormKind'; import { searchPageSize } from '../utils/pageSizeOverride'; import { questionnaireIdFromReference } from '../utils/questionnaireReference'; import { isPinnedEdition, PACKET_RESOURCE_ID, packetPinOf, PIN_UNREADABLE, RESPONSE_EDITION_EXTENSION, type PacketPin } from '../utils/packetAnswerContinuation'; import { isCompatibilityVersioned, questionnaireCompatibilityStamp, readBooleanInitial } from '../utils/questionnaireCompatibility'; export interface ExtendedQuestionnaireItem extends QuestionnaireItem { answer: { value: any }[]; prefix: string; hidden: boolean; minLength: number | null; minValue: number | string | null; maxValue: number | string | null; entryFormat: string; regex: string; minOccurs: number; maxOccurs: number; choiceOrientation: string | null; columnSpan: number; displayCategory: Record; unit: Coding | null; unitOption: any[]; usageMode: string; supportLink: string; sliderStepValue: number; help: string; itemControl: Record; answerOption: ExtendedQuestionnaireItemAnswerOption[]; path: string; answerPath: string; item: ExtendedQuestionnaireItem[]; parent: ExtendedQuestionnaireItem | undefined; } export interface ExtendedQuestionnaireItemAnswerOption extends QuestionnaireItemAnswerOption { value: any; } export interface ExtendedQuestionnaireItemEnableWhen extends Omit { answer: any; question: QuestionnaireItem; } export interface ExtendedQuestionnaireItemInitial extends QuestionnaireItemInitial { value: any; } interface ExtendedQuestionnaireResponseItem extends QuestionnaireResponseItem { type: string; } const API_URL = environment.fhirClinicalApi; const TAG_BASE_URL = 'https://akashichealthrecords.com/fhir/tags'; const TAG_SYSTEMS = { QUESTIONNAIRE: `${TAG_BASE_URL}/questionnaire`, AUTHOR: `${TAG_BASE_URL}/author`, ORGANIZATION: `${TAG_BASE_URL}/organization` }; const TAG_CODES = { QUESTIONNAIRE: 'questionnaire', TEMPLATE: 'template' }; export const useQuestionnaireService = () => { const { currentUser, wpOrganization } = useAuthServiceContext(); const { get, post, put, delete: del } = useHttpService(); const fhirService = useFhirServiceContext(); const { createReferenceObject, unpackBundle, extractReferenceId } = fhirService; const [questionnaireList, setQuestionnaireList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [questionnaireBundle, setQuestionnaireBundle] = useState | null>(null); const questionnaireBundleRef = useRef | null>(null); const lastFetchTime = useRef(0); const [templateList, setTemplateList] = useState([]); const [templateBundle, setTemplateBundle] = useState | undefined>(); const templateBundleRef = useRef | undefined>(); const templateLastFetchTime = useRef(0); useEffect(() => { questionnaireBundleRef.current = questionnaireBundle; }, [questionnaireBundle]); useEffect(() => { templateBundleRef.current = templateBundle; }, [templateBundle]); const ucumSystemUrl = 'http://unitsofmeasure.org'; const extensionUrls = { hidden: 'http://hl7.org/fhir/StructureDefinition/questionnaire-hidden', itemControl: 'http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl', displayCategory: 'http://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory', minLength: 'http://hl7.org/fhir/StructureDefinition/minLength', minValue: 'http://hl7.org/fhir/StructureDefinition/minValue', maxValue: 'http://hl7.org/fhir/StructureDefinition/maxValue', entryFormat: 'http://hl7.org/fhir/StructureDefinition/entryFormat', regex: 'http://hl7.org/fhir/StructureDefinition/regex', minOccurs: 'http://hl7.org/fhir/StructureDefinition/questionnaire-minOccurs', maxOccurs: 'http://hl7.org/fhir/StructureDefinition/questionnaire-maxOccurs', choiceOrientation: 'http://hl7.org/fhir/StructureDefinition/questionnaire-choiceOrientation', unit: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unit', unitOption: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption', usageMode: 'http://hl7.org/fhir/StructureDefinition/questionnaire-usageMode', supportLink: 'http://hl7.org/fhir/StructureDefinition/questionnaire-supportLink', sliderStepValue: 'http://hl7.org/fhir/StructureDefinition/questionnaire-sliderStepValue', signatureRequired: 'http://hl7.org/fhir/StructureDefinition/questionnaire-signatureRequired', questionnaireConstraint: 'http://hl7.org/fhir/StructureDefinition/questionnaire-constraint', ordinalValue: 'http://hl7.org/fhir/StructureDefinition/ordinalValue', firstCreated: 'http://hl7.org/fhir/StructureDefinition/firstCreated', autoAssign: AUTO_ASSIGN_TRIGGER_URL, autoAssignPriority: AUTO_ASSIGN_PRIORITY_URL, columnSpan: COLUMN_SPAN_EXTENSION_URL }; const codeableConceptUrls = { itemControl: 'http://hl7.org/fhir/questionnaire-item-control', displayCategory: 'http://hl7.org/fhir/questionnaire-display-category' }; const fetchQuestionnaires = useCallback(async (pageSize: number = 10, page?: string, searchTerm?: string): Promise> => { return fetchAllPagesAsBundle((pageParam) => { let params = new URLSearchParams(); params.append('_sort', 'title'); if (pageSize) { params.append('_count', pageSize + ''); } if (page) { params.append('page', page); } if (searchTerm) { params.append('title', searchTerm); } params.append('_tag', `${TAG_SYSTEMS.QUESTIONNAIRE}|${TAG_CODES.QUESTIONNAIRE}`); params.append('_tag', `${TAG_SYSTEMS.ORGANIZATION}|${wpOrganization.current?.id}`); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Questionnaire?${params.toString()}`); }); }, [get]); const fetchQuestionnairesWithPolling = useCallback(async (pageSize: number = 10, page?: string, searchQuery?: string): Promise> => { const now = Date.now(); const cacheAge = now - lastFetchTime.current; const cacheFresh = cacheAge < 5 * 60 * 1000; if (!shouldReload && questionnaireBundleRef.current && cacheFresh) { return questionnaireBundleRef.current; } let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: Questionnaire[]; do { bundle = await fetchQuestionnaires(pageSize, page, searchQuery); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) : true; if (!found) { await new Promise(resolve => setTimeout(resolve, delay)); } retries--; } while (!found && retries > 0); setQuestionnaireBundle(bundle); setQuestionnaireList(entries); setLastUpserted(null); setShouldReload(false); lastFetchTime.current = now; return bundle; }, [fetchQuestionnaires, unpackBundle, lastUpserted, shouldReload]); const fetchQuestionnaireTemplates = useCallback(async (): Promise> => { return fetchAllPagesAsBundle((pageParam) => { let params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('_sort', 'title'); params.append('_tag', `${TAG_SYSTEMS.QUESTIONNAIRE}|${TAG_CODES.TEMPLATE}`); // params.append('_tag', `${TAG_SYSTEMS.ORGANIZATION}|${wpOrganization.current?.id}`); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Questionnaire?${params.toString()}`); }); }, [get]); const fetchQuestionnaireTemplatesWithPolling = useCallback(async (): Promise> => { const now = Date.now(); const cacheAge = now - templateLastFetchTime.current; const cacheFresh = cacheAge < 5 * 60 * 1000; if (templateBundleRef.current && cacheFresh) { return templateBundleRef.current; } const bundle: Bundle | null = await fetchQuestionnaireTemplates(); const entries: Questionnaire[] = unpackBundle(bundle); setTemplateBundle(bundle); setTemplateList(entries); templateLastFetchTime.current = now; return bundle; }, [fetchQuestionnaireTemplates, unpackBundle]); const fetchValueSets = useCallback(async (): Promise> => { // No `_count` was stated at all, so this returned one server page of the org's ValueSets - and // these are the answer options the questionnaire builder offers. A short read here does not show // a short list; it silently removes choices a form author could have picked. return fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/ValueSet?${params.toString()}`); }); }, [get]); /** * - THIS TAKES AN ID *OR* A REFERENCE, and it has to. * * Six call sites, and they do not agree on what they hold. Three pass a bare id they already have * (a template id, a task's questionnaire id, a submission's `questionnaireId`). The other three pass * `questionnaireResponse.questionnaire` straight through - and that field holds a bare id when the * response came from an ASSIGNED questionnaire and `Questionnaire/{id}` when it came from an attached * public-form submission, because the two writers disagree ( `utils/questionnaireReference.ts`). * * Normalising at the three reference call sites instead would leave the next one to be written free * to reintroduce the doubled URL. The single place that knows the resource type is the single place * that builds the path, so that is where the shape is settled. * * An unresolvable value THROWS rather than requesting a guessed id: every caller already has a * `try/catch`, and a thrown reason is what lets them tell the user something true. */ // A record read on the patient host has no practice; the caller names the form's own practice instead. const fetchQuestionnaire = useCallback(async (idOrReference: string, organizationId?: string): Promise => { const id = questionnaireIdFromReference(idOrReference); if (!id) { throw new Error(`Cannot resolve a questionnaire id from "${idOrReference}"`); } return get(`${API_URL}/Questionnaire/${id}`, organizationId ? { organizationId } : {}); }, [get]); /** * One named edition, as a packet form pins it, read for that form's own practice and opening. What comes back * must be that form at that edition; anything else is refused rather than shown or answered. */ const fetchQuestionnaireEdition = useCallback(async (pin: PacketPin, scope: { organizationId: string; admit: () => void; signal?: AbortSignal }): Promise => { if (!PACKET_RESOURCE_ID.test(pin.questionnaireId) || !PACKET_RESOURCE_ID.test(pin.version) || !scope.organizationId) throw new Error(PIN_UNREADABLE); const read = await get(`${API_URL}/Questionnaire/${encodeURIComponent(pin.questionnaireId)}/_history/${encodeURIComponent(pin.version)}`, { organizationId: scope.organizationId, admit: scope.admit, signal: scope.signal }); if (!isPinnedEdition(read, pin)) throw new Error(PIN_UNREADABLE); return read; }, [get]); const fetchQuestionnaireHistory = useCallback(async (id: string, count: number = 20): Promise> => { return get>(`${API_URL}/Questionnaire/${id}/_history?_count=${count}`); }, [get]); const createQuestionnaire = useCallback(async (resource: Questionnaire, organization: Organization, isTemplate: boolean = false, publicForm: boolean = false): Promise => { resource.meta = resource.meta || {}; resource.meta.tag = [ { system: TAG_SYSTEMS.ORGANIZATION, code: organization.id, display: organization.name }, { system: TAG_SYSTEMS.QUESTIONNAIRE, code: isTemplate ? TAG_CODES.TEMPLATE : TAG_CODES.QUESTIONNAIRE, display: isTemplate ? TAG_CODES.TEMPLATE : TAG_CODES.QUESTIONNAIRE }, { system: TAG_SYSTEMS.AUTHOR, code: currentUser?.fhir?.id, display: formatFhirHumanName(currentUser?.fhir?.name) }, ...(publicForm ? [PUBLIC_FORM_TAG] : []) ]; resource.extension = resource.extension || []; resource.extension.push({ url: extensionUrls.firstCreated, valueInstant: new Date().toISOString() }); return post(`${API_URL}/Questionnaire`, resource); }, [post, currentUser]); // Every caller's write makes the cached list stale, not only the editor's own save: the library hands // the editor its cached copy, so a rule saved on Automated Tasks would otherwise reopen as Off. const updateQuestionnaire = useCallback(async (resource: Questionnaire): Promise => { const saved = await put(`${API_URL}/Questionnaire/${resource.id}`, resource); lastFetchTime.current = 0; setShouldReload(true); return saved; }, [put]); // The library renders this list, so a deleted form leaves it here, not on the next fetch. const deleteQuestionnaire = useCallback(async (resource: Questionnaire): Promise => { await del(`${API_URL}/Questionnaire/${resource.id}`); lastFetchTime.current = 0; setQuestionnaireList(prev => prev.filter(q => q.id !== resource.id)); }, [del]); const transformToFhirQuestionnaire = useCallback((input: any): Questionnaire => { const { status, title, code, experimental, subjectType, publisher, description, purpose, copyright, url, contact, tags, createdAt, name, item } = input; const extensions: Extension[] = []; if (input.signatureRequired && input.signatureType.length !== 0) { extensions.push( ...(input.signatureType.map((signatureType: Coding) => { return { url: extensionUrls.signatureRequired, valueCodeableConcept: { coding: [signatureType], text: signatureType.display } }; })) ); } if (createdAt) { extensions.push({ url: extensionUrls.firstCreated, valueInstant: createdAt }); } if (input.autoAssignTrigger && input.autoAssignTrigger !== 'off') { extensions.push({ url: extensionUrls.autoAssign, valueCode: input.autoAssignTrigger }); // An automated task carries no priority - nobody sets one, so this is always `routine`. // Still written rather than omitted: a questionnaire has no native FHIR priority field, so // this extension is the only place the value can live, and a value the record does not // contain is not a value the record carries. extensions.push({ url: extensionUrls.autoAssignPriority, valueCode: 'routine' }); } // Every editor save is three-state, so its false initials are authored No. extensions.push(questionnaireCompatibilityStamp()); const questionnaire: Questionnaire = { resourceType: 'Questionnaire', status: status, ...(title && { title: title }), ...(name && { name: name }), ...(url && { url: url }), ...(contact.length !== 0 && { contact: contact.map((c: { name: string, telecom: ContactPoint[]}) => ( { ...(c.name && { name: c.name }), telecom: fhirService.transformToTelecom(c.telecom) } )) }), ...(code.length !== 0 && { code: code }), ...(tags.length !== 0 && { meta: { tag: tags } }), experimental: !!experimental, subjectType: [...subjectType], ...(publisher && { publisher: publisher }), ...(description && { description: description }), ...(purpose && { purpose: purpose }), ...(copyright && { copyright: copyright }), ...(item && item.length !== 0 && { item: item.map((iitem: any) => transformToFhirQuestionnaireItem(iitem)) }), ...(extensions?.length && { extension: extensions }) }; if (input.period) { questionnaire.effectivePeriod = {}; if (input.period.start) { questionnaire.effectivePeriod.start = fhirService.formatDate(input.period.start); } if (input.period.end) { questionnaire.effectivePeriod.end = fhirService.formatDate(input.period.end); } return questionnaire; } const { effectivePeriod, ...withoutPeriod } = questionnaire; return withoutPeriod; }, [fhirService, extensionUrls]); const transformToFhirQuestionnaireItem = useCallback((item: any): QuestionnaireItem => { const { prefix, required, repeats, readOnly, maxLength, hidden, minLength, minValue, maxValue, entryFormat, regex, minOccurs, maxOccurs, choiceOrientation, columnSpan, displayCategory, unit, unitOption, usageMode, supportLink, sliderStepValue, help, itemControl, enableWhen, enableBehavior, initial, answerOption, code, answer, item: childItems, path, answerPath, parent, ...rest } = item; const transformedEnableWhen = (enableWhen ?? []).filter((condition: any) => condition.question && condition.operator) .map((condition: any) => { const questionType = condition.question.type; let answerKey: string; let operator: any; let answer: any; if (questionType === 'string' || questionType === 'text' || questionType === 'url') { answerKey = 'answerString'; } else if (questionType === 'choice' || questionType === 'open-choice') { answerKey = 'answerCoding'; } else { answerKey = `answer${questionType.charAt(0).toUpperCase()}${questionType.slice(1)}`; } if (condition.operator === 'exists' || condition.operator === 'empty') { answerKey = 'answerBoolean'; operator = 'exists'; answer = condition.operator === 'exists'; } else { operator = condition.operator; answer = condition.answer; } if ((questionType === 'choice' || questionType === 'open-choice') && (condition.operator !== 'exists' && condition.operator !== 'empty')) { answer = { code: answer.code, display: answer.display, system: answer.system }; } if (questionType === 'boolean') { answer = answer === '' ? true : answer; } else if ((questionType === 'integer' || questionType === 'decimal') && (condition.operator !== 'exists' && condition.operator !== 'empty')) { answer = +answer; } else if (questionType === 'time' && (condition.operator !== 'exists' && condition.operator !== 'empty')) { answer = `${answer}:00`; } else if (questionType === 'dateTime' && (condition.operator !== 'exists' && condition.operator !== 'empty')) { answer = new Date(answer).toISOString(); } else if (questionType === 'quantity' && (condition.operator !== 'exists' && condition.operator !== 'empty')) { answer = { value: answer, ...(condition.unit && { code: condition.unit.code, unit: condition.unit.display, system: condition.unit.system }) }; } return { question: condition.question.linkId, operator: operator, [answerKey]: answer }; }); let transformedInitial; let transformedAnswerOption; if (item.type !== 'display' && item.type !== 'group') { const questionType = item.type; let valueKey: string; if (questionType === 'string' || questionType === 'text') { valueKey = 'valueString'; } else if (questionType === 'choice' || questionType === 'open-choice') { valueKey = 'valueCoding'; } else if (questionType === 'url') { valueKey = 'valueUri'; } else { valueKey = `value${questionType.charAt(0).toUpperCase()}${questionType.slice(1)}`; } transformedInitial = (initial ?? []).filter((initialItem: { value: string | boolean }) => { // An authored Yes or No is kept; the unset default is null and writes no initial. if (typeof initialItem.value === 'boolean') { return true; } return initialItem.value; }).map((initialItem: { value: string }) => { let value: any; if (questionType === 'integer' || questionType === 'decimal') { value = +initialItem.value; } else if (questionType === 'time') { value = `${initialItem.value}:00`; } else if (questionType === 'dateTime') { value = new Date(initialItem.value).toISOString(); } else if (questionType === 'quantity') { value = { value: initialItem.value }; } else { value = initialItem.value; } return { [valueKey]: value }; }); transformedAnswerOption = (answerOption ?? []) .filter((option: { initialSelected: boolean, value: any }) => option.value.code && option.value.display) .map((option: { initialSelected: boolean, value: any }) => { const answerOptionExtensions: Extension[] = []; const { initialSelected, value } = option; const { score, ...withoutScore } = value; // `if (score)` dropped a score of 0, the first answer of most scored instruments. if (score !== undefined && score !== null && score !== '' && Number.isFinite(+score)) { answerOptionExtensions.push({ url: extensionUrls.ordinalValue, valueDecimal: +score }); } return { initialSelected: initialSelected, [valueKey]: withoutScore, ...(answerOptionExtensions?.length && { extension: answerOptionExtensions }) }; }); } const extensions: Extension[] = []; if (item.linkId.split('_').at(-1) !== 'help') { extensions.push({ url: extensionUrls.hidden, valueBoolean: hidden ?? false }); } const normalizedColumnSpan = normalizeColumnSpan(columnSpan); // Only persist a non-default span. A full-width item writes no extension at all, so every // existing questionnaire round-trips byte-identically and no migration is needed. if (normalizedColumnSpan !== COLUMN_GRID) { extensions.push({ url: extensionUrls.columnSpan, valueInteger: normalizedColumnSpan }); } if (minLength) { extensions.push({ url: extensionUrls.minLength, valueInteger: +minLength }); } if (minValue) { const questionType: string = item.type; let valueKey: string = `value${questionType.charAt(0).toUpperCase()}${questionType.slice(1)}`; let value: string | number; if (questionType === 'integer' || questionType === 'decimal') { value = +minValue; } else if (questionType === 'time') { value = `${minValue}:00`; } else if (questionType === 'dateTime') { value = new Date(minValue).toISOString(); } else { value = minValue; } extensions.push({ url: extensionUrls.minValue, [valueKey]: value }); } if (maxValue) { const questionType = item.type; let valueKey = `value${questionType.charAt(0).toUpperCase()}${questionType.slice(1)}`; let value: string | number; if (questionType === 'integer' || questionType === 'decimal') { value = +maxValue; } else if (questionType === 'dateTime') { value = new Date(maxValue).toISOString(); } else if (questionType === 'time') { value = `${maxValue}:00`; } else { value = maxValue; } extensions.push({ url: extensionUrls.maxValue, [valueKey]: value }); } if (entryFormat) { extensions.push({ url: extensionUrls.entryFormat, valueString: entryFormat }); } if (regex) { extensions.push({ url: extensionUrls.regex, valueString: regex }); } if (itemControl && Object.keys(itemControl).length !== 0) { extensions.push({ url: extensionUrls.itemControl, valueCodeableConcept: { coding: [itemControl], text: itemControl.display } }); } if (item.repeats && item.required && minOccurs > 1) { extensions.push({ url: extensionUrls.minOccurs, valueInteger: minOccurs }); } if (item.repeats && maxOccurs > 1) { extensions.push({ url: extensionUrls.maxOccurs, valueInteger: maxOccurs }); } if ((item.type === 'choice' || item.type === 'open-choice') && itemControl && (itemControl.code === 'check-box' || itemControl.code === 'radio-button') && choiceOrientation) { extensions.push({ url: extensionUrls.choiceOrientation, valueCode: choiceOrientation }); } if (displayCategory && Object.keys(displayCategory).length !== 0) { extensions.push({ url: extensionUrls.displayCategory, valueCodeableConcept: { coding: [displayCategory], text: displayCategory.display } }); } if (unit) { extensions.push({ url: extensionUrls.unit, valueCoding: { ...unit, system: ucumSystemUrl } }); } if (unitOption) { extensions.push( ...(unitOption.map((option: IValueSet) => { return { url: extensionUrls.unitOption, valueCoding: { ...option, system: ucumSystemUrl } }; })) ); } if (usageMode) { extensions.push({ url: extensionUrls.usageMode, valueCode: usageMode }); } if (supportLink) { extensions.push({ url: extensionUrls.supportLink, valueUri: supportLink }); } if (item.type === 'integer' && itemControl.code === 'slider' && sliderStepValue) { extensions.push({ url: extensionUrls.sliderStepValue, valueInteger: +sliderStepValue }); } const processedChildItems = item.type === 'group' ? childItems?.map((childItem: QuestionnaireItem) => transformToFhirQuestionnaireItem(childItem)) : []; if (help) { processedChildItems.unshift({ linkId: `${item.linkId}_help`, text: help, type: 'display', extension: [{ url: extensionUrls.itemControl, valueCodeableConcept: { coding: [{ system: codeableConceptUrls.itemControl, code: 'help', display: 'Help-Button' }], text: 'Help-Button' } }] }); } return { ...rest, ...(prefix && { prefix: prefix }), ...(maxLength && { maxLength: +maxLength }), ...(transformedEnableWhen && transformedEnableWhen.length !== 0 && { enableWhen: transformedEnableWhen }), ...(transformedEnableWhen && transformedEnableWhen.length > 1 ? { enableBehavior: enableBehavior } : {}), ...(item.type !== 'display' && { required, repeats, readOnly }), ...((item.type !== 'display' && code?.length !== 0) && { code: code }), ...((item.type !== 'display' && item.type !== 'group' && transformedInitial && transformedInitial.length !== 0 && transformedAnswerOption && transformedAnswerOption.length === 0) && { initial: transformedInitial }), ...((item.type !== 'display' && item.type !== 'group' && transformedAnswerOption && transformedAnswerOption.length !== 0) && { answerOption: transformedAnswerOption }), ...(extensions?.length !== 0 && { extension: extensions }), ...(processedChildItems?.length !== 0 && { item: processedChildItems }) }; }, [extensionUrls, codeableConceptUrls, ucumSystemUrl]); const transformFromFhirQuestionnaire = useCallback((questionnaire: Questionnaire, questionnaireResponse?: QuestionnaireResponse) => { const extensions: Extension[] = questionnaire.extension ?? []; const formData: any = { status: questionnaire.status ?? '', title: questionnaire.title ?? '', url: questionnaire.url ?? '', name: questionnaire.name ?? '', experimental: questionnaire.experimental, subjectType: questionnaire.subjectType ?? [], publisher: questionnaire.publisher, description: questionnaire.description ?? '', purpose: questionnaire.purpose ?? '', copyright: questionnaire.copyright ?? '', identifier: [], version: '', date: '', useContext: {}, jurisdiction: {}, approvalDate: '', lastReviewDate: '', versionId: questionnaire.meta?.versionId ?? 1, createdAt: extensions.find((extension: Extension) => extension.url === extensionUrls.firstCreated)?.valueInstant, autoAssignTrigger: extensions.find((extension: Extension) => extension.url === extensionUrls.autoAssign)?.valueCode ?? 'off', autoAssignPriority: extensions.find((extension: Extension) => extension.url === extensionUrls.autoAssignPriority)?.valueCode ?? 'routine', code: (questionnaire.code ?? []).map((code: Coding) => ({ code: code.code, display: code.display, system: code.system })), tags: (questionnaire.meta?.tag ?? []).map((tag: Coding) => ({ code: tag.code, display: tag.display, system: tag.system })), contact: (questionnaire.contact ?? []).map((contact: ContactDetail) => ({ name: contact.name ?? '', telecom: fhirService.transformFromTelecom(contact) })), signatureRequired: extensions.find((extension: Extension) => extension.url === extensionUrls.signatureRequired)?.valueCodeableConcept, signatureType: extensions .filter((extension: Extension) => extension.url === extensionUrls.signatureRequired) .map((extension: Extension) => extension.valueCodeableConcept?.coding?.[0] ?? {}), item: (questionnaire.item ?? []).map((item: QuestionnaireItem, index: number) => transformFromFhirQuestionnaireItem(item, questionnaire, index, questionnaireResponse?.item)) }; if (questionnaire.effectivePeriod) { formData.period = {}; if (questionnaire.effectivePeriod.start) { formData.period.start = questionnaire.effectivePeriod.start; } if (questionnaire.effectivePeriod.end) { formData.period.end = questionnaire.effectivePeriod.end; } } return formData; }, [fhirService, extensionUrls]); const transformFromFhirQuestionnaireItem = useCallback((item: QuestionnaireItem | ExtendedQuestionnaireItem, questionnaire: Questionnaire, index?: number, responseItems?: QuestionnaireResponseItem[], parent?: ExtendedQuestionnaireItem, basePath: string = 'item', baseAnswerPath: string = 'item') => { const childItems = item.item ?? []; const extensions: Extension[] = item.extension ?? []; const minOccurs = extensions.find((ext) => ext.url === extensionUrls.minOccurs)?.valueInteger ?? 1; const maxOccurs = extensions.find((ext) => ext.url === extensionUrls.maxOccurs)?.valueInteger ?? 2; const path = `${basePath}[${index}]`; const answerPath = `${baseAnswerPath}[${index}]`; const responseItem = (responseItems ?? []).find((responseItem: QuestionnaireResponseItem) => responseItem.linkId === item.linkId); const versioned = isCompatibilityVersioned(questionnaire); const parentRef = parent ? { linkId: parent.linkId, type: parent.type, readOnly: parent.readOnly, ...(parent.parent && { parent: parent.parent }), ...(parent.path && { path: parent.path }), ...(parent.answerPath && { answerPath: parent.answerPath }) } : undefined; const formData: any = { // applies to questions & groups & display path: path, answerPath: answerPath, linkId: item.linkId, prefix: item.prefix ?? '', text: item.text, type: item.type, hidden: extensions.find((extension: Extension) => extension.url === extensionUrls.hidden)?.valueBoolean || false, columnSpan: normalizeColumnSpan( extensions.find((extension: Extension) => extension.url === extensionUrls.columnSpan)?.valueInteger ), enableWhen: item.enableWhen?.map((enableWhen: QuestionnaireItemEnableWhen) => transformFromFhirQuestionnaireItemEnableWhen(enableWhen, questionnaire)) ?? [], enableBehavior: item.enableBehavior ?? 'all', supportLink: extensions.find((extension: Extension) => extension.url === extensionUrls.supportLink)?.valueUri ?? '', usageMode: extensions.find((extension: Extension) => extension.url === extensionUrls.usageMode)?.valueCode ?? '', parent: parentRef }; // applies to questions & groups if (item.type !== 'display') { formData.code = item.code?.map((code: Coding) => ({ code: code.code, display: code.display, system: code.system })) || []; formData.required = item.required ?? false; formData.repeats = item.repeats ?? false; formData.readOnly = item.readOnly ?? false; formData.help = childItems.find((childItem: QuestionnaireItem) => childItem.extension?.some((ext: Extension) => ext.url === extensionUrls.itemControl && ext.valueCodeableConcept?.coding?.some((coding: Coding) => coding.code === 'help' && coding.system === codeableConceptUrls.itemControl ) ) )?.text ?? ''; formData.itemControl = extensions.find((extension: Extension) => extension.url === extensionUrls.itemControl)?.valueCodeableConcept?.coding?.[0] ?? {}; formData.minOccurs = minOccurs; formData.maxOccurs = maxOccurs; } // applies to questions only if (item.type !== 'display' && item.type !== 'group') { formData.maxLength = item.maxLength ?? null; formData.minLength = extensions.find((extension: Extension) => extension.url === extensionUrls.minLength)?.valueInteger ?? null; formData.minValue = transformFromExtensionToValue(extensions, item.type, 'minValue'); formData.maxValue = transformFromExtensionToValue(extensions, item.type, 'maxValue'); formData.entryFormat = extensions.find((extension: Extension) => extension.url === extensionUrls.entryFormat)?.valueString ?? ''; formData.regex = extensions.find((extension: Extension) => extension.url === extensionUrls.regex)?.valueString ?? ''; formData.sliderStepValue = extensions.find((extension: Extension) => extension.url === extensionUrls.sliderStepValue)?.valueInteger ?? 1; formData.choiceOrientation = extensions.find((extension: Extension) => extension.url === extensionUrls.choiceOrientation)?.valueCode ?? null; formData.initial = item.initial?.map((initial: QuestionnaireItemInitial) => transformFromQuestionnaireItemInitial(initial, versioned)) ?? [{ value: item.type === 'boolean' || item.type === 'integer' || item.type === 'decimal' ? null : '' }]; formData.answerOption = item.answerOption?.map((answerOption: QuestionnaireItemAnswerOption) => transformFromQuestionnaireItemAnswerOption(answerOption)) ?? []; formData.unitOption = extensions .filter((extension: Extension) => extension.url === extensionUrls.unitOption) .map((extension: Extension) => extension.valueCoding); // formData.answer = transformFromQuestionnaireItemInitialToAnswer(item); formData.answer = (responseItem && responseItem.answer) ? transformFromQuestionnaireResponseItemAnswer(responseItem.answer, item.type) : transformFromQuestionnaireItemInitialToAnswer(item, versioned); formData.unit = extensions.find((extension: Extension) => extension.url === extensionUrls.unit)?.valueCoding ?? null; } // applies to groups only if (item.type === 'group') { formData.answer = []; if (responseItem) { (responseItem.answer ?? []).forEach((answerGroup: QuestionnaireResponseItemAnswer) => { if (answerGroup.item && answerGroup.item.length > 0) { formData.answer.push( childItems .filter((childItem: QuestionnaireItem) => childItem.linkId !== `${item.linkId}_help`) .map((childItem: QuestionnaireItem, childIndex: number) => transformFromFhirQuestionnaireItem(childItem, questionnaire, childIndex, answerGroup.item, formData, `${path}.item`, `${answerPath}.answer[${formData.answer.length}]`)) ); } }); } formData.item = childItems .filter((childItem: QuestionnaireItem) => childItem.linkId !== `${item.linkId}_help`) .map((childItem: QuestionnaireItem, childIndex: number) => transformFromFhirQuestionnaireItem(childItem, questionnaire, childIndex, responseItem?.item, formData, `${path}.item`)); } // If it's a group, initialize its answers to track repetitions if (item.type === 'group') { let i = 0; while (formData.answer.length < minOccurs) { formData.answer.push( childItems .filter((childItem: QuestionnaireItem) => childItem.linkId !== `${item.linkId}_help`) .map((childItem: QuestionnaireItem, childIndex: number) => transformFromFhirQuestionnaireItem(childItem, questionnaire, childIndex, undefined, formData, `${path}.item`, `${answerPath}.answer[${i}]`)) ); i++; } } return formData; }, [extensionUrls, codeableConceptUrls]); const transformFromExtensionToValue = useCallback((extensions: Extension[], questionType: string, extensionUrl: 'minValue' | 'maxValue') => { const extension: Extension | undefined = extensions.find((extension: Extension) => extension.url === extensionUrls[extensionUrl]); if (extension) { let valueKey: string = `value${questionType.charAt(0).toUpperCase()}${questionType.slice(1)}`; if (questionType === 'time') { return (extension as any)[valueKey].split(':').slice(0, 2).join(':'); } else if (questionType === 'dateTime') { return (extension as any)[valueKey].split('.')[0]; } return (extension as any)[valueKey]; } return null; }, [extensionUrls]); const findItemByLinkId = useCallback((items: QuestionnaireItem[], linkId: string): QuestionnaireItem | null => { for (const item of items) { if (item.linkId === linkId) { return item; } if (item.type === 'group' && item.item) { const found = findItemByLinkId(item.item, linkId); if (found) { return found; } } } return null; }, []); const transformFromFhirQuestionnaireItemEnableWhen = useCallback((enableWhen: QuestionnaireItemEnableWhen, questionnaire: Questionnaire) => { const formData: any = { question: transformFromFhirQuestionnaireItem(findItemByLinkId(questionnaire.item as QuestionnaireItem[], enableWhen.question) as QuestionnaireItem, questionnaire), operator: enableWhen.operator === 'exists' && enableWhen.answerBoolean === false ? 'empty' : enableWhen.operator }; // Detect and add the appropriate answer[x] property if ('answerBoolean' in enableWhen) { formData.answer = enableWhen.answerBoolean; } else if ('answerDecimal' in enableWhen) { formData.answer = enableWhen.answerDecimal; } else if ('answerInteger' in enableWhen) { formData.answer = enableWhen.answerInteger; } else if ('answerDate' in enableWhen) { formData.answer = enableWhen.answerDate; } else if ('answerDateTime' in enableWhen) { formData.answer = enableWhen.answerDateTime?.split('.')[0]; } else if ('answerTime' in enableWhen) { formData.answer = enableWhen.answerTime?.split(':').slice(0, 2).join(':'); } else if ('answerString' in enableWhen) { formData.answer = enableWhen.answerString; } else if ('answerCoding' in enableWhen) { formData.answer = enableWhen.answerCoding; } else if ('answerQuantity' in enableWhen) { formData.answer = enableWhen.answerQuantity?.value; formData.unit = { code: enableWhen.answerQuantity?.code, display: enableWhen.answerQuantity?.unit, system: enableWhen.answerQuantity?.system }; } else if ('answerReference' in enableWhen) { formData.answer = enableWhen.answerReference; } return formData; }, [findItemByLinkId]); const transformFromQuestionnaireItemInitial = useCallback((initial: QuestionnaireItemInitial, versioned: boolean = false) => { const formData: any = {}; // Detect and add the appropriate value[x] property if ('valueBoolean' in initial) { // Loading an unversioned form reads legacy false as Not set, so its first save drops it. formData.value = readBooleanInitial(initial.valueBoolean, versioned); } else if ('valueDecimal' in initial) { formData.value = initial.valueDecimal; } else if ('valueInteger' in initial) { formData.value = initial.valueInteger; } else if ('valueDate' in initial) { formData.value = initial.valueDate; } else if ('valueDateTime' in initial) { formData.value = initial.valueDateTime?.split('.')[0]; } else if ('valueTime' in initial) { formData.value = initial.valueTime?.split(':').slice(0, 2).join(':'); } else if ('valueString' in initial) { formData.value = initial.valueString; } else if ('valueUri' in initial) { formData.value = initial.valueUri; } else if ('valueAttachment' in initial) { formData.value = initial.valueAttachment; } else if ('valueCoding' in initial) { formData.value = initial.valueCoding; } else if ('valueQuantity' in initial) { formData.value = initial.valueQuantity?.value; } else if ('valueReference' in initial) { formData.value = initial.valueReference; } return formData; }, []); const transformFromQuestionnaireItemInitialToAnswer = useCallback((item: QuestionnaireItem, versioned: boolean = false) => { const initialArray = item.initial ?? []; const extensions: Extension[] = item.extension ?? []; const minOccurs = extensions.find((extension: Extension) => extension.url === extensionUrls.minOccurs)?.valueInteger ?? 1; let answers: any[] = []; initialArray.forEach((initial: QuestionnaireItemInitial) => { let value: any = ''; if ('valueBoolean' in initial) value = readBooleanInitial(initial.valueBoolean, versioned); else if ('valueDecimal' in initial) value = initial.valueDecimal; else if ('valueInteger' in initial) value = initial.valueInteger; else if ('valueDate' in initial) value = initial.valueDate; else if ('valueDateTime' in initial) value = initial.valueDateTime?.split('.')[0]; else if ('valueTime' in initial) value = initial.valueTime?.split(':').slice(0, 2).join(':'); else if ('valueString' in initial) value = initial.valueString; else if ('valueUri' in initial) value = initial.valueUri; else if ('valueAttachment' in initial) value = initial.valueAttachment; else if ('valueCoding' in initial) value = initial.valueCoding; else if ('valueQuantity' in initial) value = initial.valueQuantity?.value; else if ('valueReference' in initial) value = initial.valueReference; answers.push({ value: value }); }); if (item.type === 'choice' || item.type === 'open-choice') { const initialSelected = (item.answerOption ?? []) .filter((answer: QuestionnaireItemAnswerOption) => answer.initialSelected) .map((answer: QuestionnaireItemAnswerOption) => ({ value: answer.valueCoding })); answers = initialSelected; } // A yes/no question starts unanswered - neither Yes nor No - so it is neither counted nor sent. const defaultValue = (item.type === 'boolean' || item.type === 'integer' || item.type === 'decimal') ? null : ''; // If minOccurs is greater, add empty values to fulfill minOccurs requirement while (answers.length < minOccurs) { answers.push({ value: defaultValue }); } return answers; }, [extensionUrls]); const transformFromQuestionnaireResponseItemAnswer = useCallback((answers: QuestionnaireResponseItemAnswer[], itemType: string): { value: any }[] => { return answers.map((answer: QuestionnaireResponseItemAnswer) => { let value: any; if ('valueBoolean' in answer) { value = answer.valueBoolean; } else if ('valueDecimal' in answer) { value = answer.valueDecimal; } else if ('valueInteger' in answer) { value = answer.valueInteger; } else if ('valueDate' in answer) { value = answer.valueDate; } else if ('valueDateTime' in answer) { value = answer.valueDateTime?.split('.')[0]; } else if ('valueTime' in answer) { value = answer.valueTime?.split(':').slice(0, 2).join(':'); } else if ('valueString' in answer) { value = answer.valueString; } else if ('valueUri' in answer) { value = answer.valueUri; } else if ('valueAttachment' in answer) { value = answer.valueAttachment; } else if ('valueCoding' in answer) { value = answer.valueCoding; } else if ('valueQuantity' in answer) { value = answer.valueQuantity?.value; } else if ('valueReference' in answer) { value = answer.valueReference; } else { value = (itemType === 'boolean' || itemType === 'integer' || itemType === 'decimal') ? null : ''; } return { value }; }); }, []); const transformFromQuestionnaireItemAnswerOption = useCallback((answerOption: QuestionnaireItemAnswerOption) => { const extensions: Extension[] = answerOption.extension ?? []; const formData: any = { initialSelected: answerOption.initialSelected ?? false }; // Detect and add the appropriate value[x] property if ('valueInteger' in answerOption) { formData.value = answerOption.valueInteger; } else if ('valueDate' in answerOption) { formData.value = answerOption.valueDate; } else if ('valueTime' in answerOption) { formData.value = answerOption.valueTime; } else if ('valueString' in answerOption) { formData.value = answerOption.valueString; } else if ('valueCoding' in answerOption) { formData.value = { code: answerOption.valueCoding?.code, display: answerOption.valueCoding?.display, system: answerOption.valueCoding?.system, score: extensions.find((extension: Extension) => extension.url === extensionUrls.ordinalValue)?.valueDecimal }; } else if ('valueReference' in answerOption) { formData.value = answerOption.valueReference; } return formData; }, [extensionUrls]); const addAnswer = useCallback((item: any, questionnaire: any, original?: any) => { let answerArray = item.answer; const questionnaireItem: QuestionnaireItem = transformToFhirQuestionnaireItem(item); const childItems = questionnaireItem.item ?? []; // item is a group if (item.type === 'group') { answerArray.push( childItems .filter((childItem: QuestionnaireItem) => childItem.linkId !== `${item.linkId}_help`) .map((childItem: QuestionnaireItem) => transformFromFhirQuestionnaireItem(childItem, questionnaire, item)) ); return; } let initialArray; // item is inside a group if (original) { if (original.type === 'group') { const itemIndex = original.item.findIndex((originalItem: any) => originalItem.linkId === item.linkId); const question = original.item[itemIndex]; initialArray = question.initial; } else { initialArray = original.initial; } } else { initialArray = item.initial; } const index = answerArray.length; const initialValue = (initialArray && initialArray[index]?.value) ?? ''; answerArray.push(initialValue); }, [transformToFhirQuestionnaireItem, transformFromFhirQuestionnaireItem]); const fetchQuestionnaireResponse = useCallback(async (id: string, scope?: { subjectPatientId: string; admit: () => void; signal?: AbortSignal }): Promise => { const query = scope ? `?${new URLSearchParams({ subject: scope.subjectPatientId })}` : ""; return get(`${API_URL}/QuestionnaireResponse/${id}${query}`, scope ? { admit: scope.admit, signal: scope.signal } : undefined); }, [get]); const fetchQuestionnaireResponseHistory = useCallback(async (id: string, count: number = 20): Promise> => { return get>(`${API_URL}/QuestionnaireResponse/${id}/_history?_count=${count}`); }, [get]); const createQuestionnaireResponse = useCallback(async (resource: QuestionnaireResponse, taskId?: string, transport?: Partial): Promise => { let params = new URLSearchParams(); params.append('subject', extractReferenceId(resource.subject) as string); // The task it answers, so the server keeps one in-progress response per task and links it itself. if (taskId) params.append('task', taskId); resource.extension = resource.extension || []; resource.extension.push({ url: extensionUrls.firstCreated, valueInstant: new Date().toISOString() }); return post(`${API_URL}/QuestionnaireResponse?${params.toString()}`, resource, transport); }, [post, extractReferenceId]); const updateQuestionnaireResponse = useCallback(async (resource: QuestionnaireResponse, transport?: Partial): Promise => { return put(`${API_URL}/QuestionnaireResponse/${resource.id}`, resource, transport); }, [put]); const deleteQuestionnaireResponse = useCallback(async (resource: QuestionnaireResponse): Promise => { return del(`${API_URL}/QuestionnaireResponse/${resource.id}`); }, [del]); const transformToFhirQuestionnaireResponse = useCallback((formData: any, questionnaire: Questionnaire, patient: Patient, task: Task): QuestionnaireResponse => { const usCoreQuestionnaireresponseProfile: string = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-questionnaireresponse'; // A packet form is answered only on the edition it pins, and its answer says which; the server checks it. const pin = packetPinOf(task); if (pin && !isPinnedEdition(questionnaire, pin)) throw new Error('These answers are not on this form\'s assigned edition. Reload it to continue.'); const patientReference: Reference = createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)); const { meta, } = formData; const questionnaireResponse: QuestionnaireResponse = { resourceType: 'QuestionnaireResponse', meta: { ...meta, profile: [usCoreQuestionnaireresponseProfile] }, questionnaire: questionnaire.id, status: 'completed', author: patientReference, source: patientReference, subject: patientReference, authored: new Date().toISOString(), ...(task.encounter && { encounter: task.encounter }), ...(pin && { extension: [{ url: RESPONSE_EDITION_EXTENSION, valueId: pin.version }] }), item: [] }; const processItems = (items: ExtendedQuestionnaireItem[]): QuestionnaireResponseItem[] => { return items .filter(item => !item.hidden) .map(item => { const responseItem: ExtendedQuestionnaireResponseItem = { id: uuidv4(), linkId: item.linkId, text: item.text, type: item.type }; if (item.type !== 'group' && item.type !== 'display') { const answers = processAnswers(item); // An empty `answer` is a REFUSAL, not an empty field. FHIR's structural rule is // "the property should not be present if it has no values", and HealthLake enforces // it: `POST /R4/QuestionnaireResponse` returns a 400 naming every one of them. // // This is not the same thing as "the response is empty". The filter below KEEPS a // required item that was left blank - deliberately, so the practice can see the // question was asked - and that kept item arrived carrying `answer: []`. So ONE // unanswered required question was enough to have the whole submission refused, no // matter how much of the rest was filled in. // // The group branch a few lines down already deletes its own empty `answer` for // exactly this reason. This is the same rule, applied to the other kind of item. if (answers.length > 0) { responseItem.answer = answers; } } if (item.type === 'group') { if (item.answer && item.answer.length > 0) { responseItem.answer = []; for (let i = 0; i < item.answer.length; i++) { const answerGroup = item.answer[i]; const groupAnswer: QuestionnaireResponseItemAnswer = { id: uuidv4() }; if (Array.isArray(answerGroup) && answerGroup.length > 0) { groupAnswer.item = processItems(answerGroup); } if (groupAnswer.item && groupAnswer.item.length > 0) { responseItem.answer.push(groupAnswer); } } if (responseItem.answer.length === 0) { delete responseItem.answer; } } } return responseItem; }).filter((item: ExtendedQuestionnaireResponseItem) => { const hasAnswers = item.answer && item.answer.length > 0; const hasItems = item.item && item.item.length > 0; if (item.type === 'display') return true; if (item.type === 'group') return hasItems || hasAnswers; return hasAnswers || (formData.item.find((i: any) => i.linkId === item.linkId)?.required === true); }) .map((item: ExtendedQuestionnaireResponseItem) => { const { type, ...standardItem } = item; return standardItem; }); }; const processAnswers = (item: ExtendedQuestionnaireItem): QuestionnaireResponseItemAnswer[] => { if (!item.answer || item.answer.length === 0) { return []; } return item.answer .filter(answer => { if (answer.value === undefined || answer.value === null) return false; return !(typeof answer.value === 'string' && answer.value.trim() === ''); }) .map(answer => { const responseAnswer: QuestionnaireResponseItemAnswer = { id: uuidv4() }; switch (item.type) { case 'boolean': responseAnswer.valueBoolean = answer.value; break; case 'decimal': responseAnswer.valueDecimal = parseFloat(answer.value); break; case 'integer': responseAnswer.valueInteger = parseInt(answer.value, 10); break; case 'date': responseAnswer.valueDate = answer.value; break; case 'dateTime': // responseAnswer.valueDateTime = answer.value.includes('T') ? answer.value: `${answer.value}T00:00:00`; responseAnswer.valueDateTime = new Date(answer.value).toISOString(); break; case 'time': responseAnswer.valueTime = answer.value.includes(':') ? answer.value.split(':').length === 2 ? `${answer.value}:00` : answer.value : `${answer.value}:00:00`; break; case 'string': case 'text': responseAnswer.valueString = answer.value; break; case 'url': responseAnswer.valueUri = answer.value; break; case 'attachment': responseAnswer.valueAttachment = answer.value; break; case 'reference': responseAnswer.valueReference = answer.value; break; case 'quantity': responseAnswer.valueQuantity = { value: parseFloat(answer.value), ...(item.unit && { unit: item.unit.display, system: item.unit.system, code: item.unit.code }) }; break; case 'choice': case 'open-choice': if (typeof answer.value === 'object' && answer.value !== null) { responseAnswer.valueCoding = { code: answer.value.code, display: answer.value.display, system: answer.value.system }; } else if (typeof answer.value === 'string') { responseAnswer.valueString = answer.value; } break; default: responseAnswer.valueString = String(answer.value); } return responseAnswer; }); }; const items = processItems(formData.item); // Same rule one level up: a response with nothing in it must not carry `item: []` either. Callers // are expected to refuse an answerless submission before it gets this far (see // `emptyQuestionnaireSubmissionRefusal`) - this only makes sure that if one ever does reach here, // it fails as an empty response rather than as an unreadable array-validation error. if (items.length > 0) { questionnaireResponse.item = items; } else { delete questionnaireResponse.item; } return questionnaireResponse; }, [createReferenceObject]); const getFhirType = useCallback((lformsType: string): string => { switch (lformsType) { case 'INT': return 'integer'; case 'REAL': return 'decimal'; case 'DT': case 'DAY': case 'MONTH': case 'YEAR': return 'date'; case 'DTM': return 'dateTime'; case 'ST': case 'EMAIL': case 'PHONE': return 'string'; case 'TITLE': return 'display'; case 'TM': return 'time'; case 'SECTION': case null: return 'group'; case 'URL': return 'url'; case 'QTY': return 'quantity'; case 'CNE': return 'choice'; case 'CWE': return 'open-choice'; default: return 'string'; } }, []); // A number offered in more than one unit is a quantity, so the answer says which unit it is in. const getLoincFhirType = useCallback((lformsType: string, units?: any[] | null): string => { const type = getFhirType(lformsType); return (type === 'decimal' || type === 'integer') && (units?.length ?? 0) > 1 ? 'quantity' : type; }, [getFhirType]); // LForms units carry code/name; the question search carries only `unit`. const convertUnitsToExtensions = useCallback((units: any[], dataType: string): any[] => { if (!units || units.length === 0) return []; const coding = (unit: any) => ({ code: unit.code || unit.unit || unit.name, system: unit.system || 'http://unitsofmeasure.org', display: unit.name || unit.unit || unit.code }); if (dataType === 'quantity') { return units.map((unit) => ({ url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption', valueCoding: coding(unit) })); } const unit = units.find((candidate) => candidate.default) ?? units[0]; return [{ url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unit', valueCoding: coding(unit) }]; }, []); const convertLFormsItemsToFHIR = useCallback((items: any[]): any[] => { return items.map(item => { const type = getLoincFhirType(item.dataType, item.units); const fhirItem: any = { linkId: item.linkId || uuidv4(), text: item.question, type: type }; if (item.questionCode) { fhirItem.code = [ { system: item.questionCodeSystem || 'http://loinc.org', code: item.questionCode, display: item.question } ]; } if (item.answers && item.answers.length > 0) { // LOINC scores an instrument's answers (PHQ-9: 0-3). Kept as the standard ordinalValue, which the builder // reads back as each choice's score; a 0 is a score, not a missing one. fhirItem.answerOption = item.answers.map((answer: any) => ({ valueCoding: { code: answer.code, system: answer.system || 'http://loinc.org', display: answer.text }, ...(typeof answer.score === 'number' && Number.isFinite(answer.score) && { extension: [{ url: extensionUrls.ordinalValue, valueDecimal: answer.score }] }) })); } if (item.units && item.units.length > 0 && ['quantity', 'integer', 'decimal'].includes(type)) { fhirItem.extension = convertUnitsToExtensions(item.units, fhirItem.type); } if (item.questionCardinality && item.questionCardinality.max === '*') { fhirItem.repeats = true; } if (item.answerCardinality && item.answerCardinality.min === '1') { fhirItem.required = true; } const children = item.items && item.items.length > 0 ? convertLFormsItemsToFHIR(item.items) : []; // Coding instructions become the field's help text, which is how the builder stores help. if (item.codingInstructions) { children.unshift({ linkId: `${fhirItem.linkId}_help`, text: item.codingInstructions, type: 'display', extension: [{ url: extensionUrls.itemControl, valueCodeableConcept: { coding: [{ system: codeableConceptUrls.itemControl, code: 'help', display: 'Help-Button' }], text: 'Help-Button' } }] }); } if (children.length > 0) { fhirItem.item = children; } return fhirItem; }); }, [getLoincFhirType, convertUnitsToExtensions]); const convertFromLFormsToQuestionnaire = useCallback((lformsData: any) => { const loincDefaults = { url: `http://loinc.org/${lformsData.code}`, publisher: 'Regenstrief Institute, Inc.', contact: [ { name: 'Regenstrief Institute, Inc.', telecom: [ { system: 'url', value: 'http://loinc.org' } ] } ], copyright: 'This material contains content from LOINC (http://loinc.org). LOINC is copyright © Regenstrief Institute, Inc. and the Logical Observation Identifiers Names and Codes (LOINC) Committee. All rights reserved. LOINC is available at no cost under the license at http://loinc.org/license. LOINC® is a registered United States trademark of Regenstrief Institute, Inc.' }; const fhirQuestionnaire = { resourceType: 'Questionnaire', id: lformsData.code, ...loincDefaults, status: 'active', date: new Date().toISOString(), title: lformsData.name, code: [ { system: 'http://loinc.org', code: lformsData.code, display: lformsData.name } ], item: convertLFormsItemsToFHIR(lformsData.items || []) }; return fhirQuestionnaire; }, [convertLFormsItemsToFHIR]); return { questionnaireBundle, questionnaireList, setQuestionnaireList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, templateBundle, templateList, setTemplateList, fetchQuestionnaireTemplatesWithPolling, extensionUrls, codeableConceptUrls, ucumSystemUrl, fetchQuestionnaires, fetchQuestionnairesWithPolling, fetchValueSets, fetchQuestionnaire, fetchQuestionnaireEdition, fetchQuestionnaireHistory, createQuestionnaire, updateQuestionnaire, deleteQuestionnaire, fetchQuestionnaireResponse, fetchQuestionnaireResponseHistory, createQuestionnaireResponse, updateQuestionnaireResponse, deleteQuestionnaireResponse, transformToFhirQuestionnaire, transformToFhirQuestionnaireItem, findItemByLinkId, transformFromExtensionToValue, transformFromFhirQuestionnaire, transformFromFhirQuestionnaireItem, transformFromFhirQuestionnaireItemEnableWhen, transformFromQuestionnaireItemInitial, transformFromQuestionnaireItemAnswerOption, transformFromQuestionnaireItemInitialToAnswer, addAnswer, transformToFhirQuestionnaireResponse, convertFromLFormsToQuestionnaire, convertLFormsItemsToFHIR, getFhirType, getLoincFhirType, convertUnitsToExtensions }; }; const QuestionnaireServiceContext = createContext | undefined>(undefined); export const QuestionnaireServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const questionnaireService = useQuestionnaireService(); return ( {children} ); }; export const useQuestionnaireServiceContext = () => { const context = useContext(QuestionnaireServiceContext); if (context === undefined) { throw new Error('useQuestionnaireServiceContext must be used within a QuestionnaireServiceProvider'); } return context; };