import { v4 as uuidv4 } from 'uuid'; import type { Coding } from 'fhir/r4'; import type { ExtendedQuestionnaireItem, IValueSet } from '../../../../services'; import { AKASHIC_ANSWER_CODE_SYSTEM, deriveAnswerOptionCode } from '../../../../utils/questionnaireAnswerCode'; import { EMAIL_ADDRESS_REGEX, EMAIL_ENTRY_FORMAT } from '../../../../utils/questionnaireEmailField'; const ITEM_CONTROL_SYSTEM = 'http://hl7.org/fhir/questionnaire-item-control'; export interface NamedFieldType { key: string; label: string; type: string; itemControl?: string; repeats?: boolean; regex?: string; entryFormat?: string; } export { EMAIL_ADDRESS_REGEX, EMAIL_ENTRY_FORMAT }; const EMAIL_KEY = 'email'; // Plain names over the item types the builder already supports. The three list presentations are one // choice type with a different item control; Checkboxes also allows more than one answer. export const NAMED_FIELD_TYPES: NamedFieldType[] = [ { key: 'string', label: 'Short text', type: 'string' }, { key: EMAIL_KEY, label: 'Email address', type: 'string', regex: EMAIL_ADDRESS_REGEX, entryFormat: EMAIL_ENTRY_FORMAT }, { key: 'text', label: 'Long text', type: 'text' }, { key: 'url', label: 'Website link', type: 'url' }, { key: 'integer', label: 'Whole number', type: 'integer' }, { key: 'decimal', label: 'Decimal number', type: 'decimal' }, { key: 'quantity', label: 'Quantity with units', type: 'quantity' }, { key: 'date', label: 'Date', type: 'date' }, { key: 'time', label: 'Time', type: 'time' }, { key: 'dateTime', label: 'Date and time', type: 'dateTime' }, { key: 'boolean', label: 'Yes / no', type: 'boolean' }, { key: 'radio', label: 'Radio buttons', type: 'choice', itemControl: 'radio-button' }, { key: 'dropdown', label: 'Select / dropdown', type: 'choice', itemControl: 'drop-down' }, { key: 'checkboxes', label: 'Checkboxes', type: 'choice', itemControl: 'check-box', repeats: true }, { key: 'open-choice', label: 'Choice with other answer', type: 'open-choice' }, { key: 'attachment', label: 'File upload', type: 'attachment' }, { key: 'display', label: 'Instructions', type: 'display' }, { key: 'group', label: 'Question group', type: 'group' }, ]; export const ANSWER_TYPE_LABELS: Record = { string: 'Short text', text: 'Long text', url: 'Website link', integer: 'Whole number', decimal: 'Decimal number', quantity: 'Quantity with units', date: 'Date', time: 'Time', dateTime: 'Date and time', boolean: 'Yes / no', choice: 'Choose from a list', 'open-choice': 'Choice with other answer', attachment: 'File upload', display: 'Instructions', group: 'Question group', }; export const CONDITION_OPERATOR_LABELS: Record = { exists: 'Has an answer', '=': 'Is equal to', '!=': 'Is not equal to', '>': 'Is greater than', '<': 'Is less than', '>=': 'Is at least', '<=': 'Is at most', }; export const DISPLAY_AS_LABELS: Record = { 'radio-button': 'Radio buttons', 'drop-down': 'Dropdown', 'check-box': 'Checkboxes', slider: 'Slider', 'text-box': 'Number box', list: 'List', gtable: 'Table', }; export const ENABLE_BEHAVIOR_LABELS: Record = { all: 'All conditions are met', any: 'Any condition is met', }; // Swaps only the words shown; the stored code is untouched. export const withPlainLabels = (options: IValueSet[], labels: Record): IValueSet[] => options.map((option) => ({ ...option, display: labels[option.code] ?? option.display })); export const namedFieldType = (key: string): NamedFieldType | undefined => NAMED_FIELD_TYPES.find((named) => named.key === key); // The named type a stored field reads as: only the email rule turns a string into an Email address. export const fieldTypeKeyOf = (type: string | undefined, regex: string | undefined): string => type === 'string' && regex === EMAIL_ADDRESS_REGEX ? EMAIL_KEY : type ?? ''; // What changing a field's type writes. Leaving Email address also drops its rule, or the field // would keep refusing anything that is not an address. export const fieldTypePatch = (toKey: string, fromKey?: string): { type: string; regex?: string; entryFormat?: string } => { if (toKey === EMAIL_KEY) return { type: 'string', regex: EMAIL_ADDRESS_REGEX, entryFormat: EMAIL_ENTRY_FORMAT }; return fromKey === EMAIL_KEY ? { type: toKey, regex: '', entryFormat: '' } : { type: toKey }; }; export const isChoiceFieldType = (key: string): boolean => { const type = namedFieldType(key)?.type; return type === 'choice' || type === 'open-choice'; }; export interface NewFieldDraft { label: string; fieldTypeKey: string; required: boolean; help: string; choices: string[]; code?: Coding; unit?: Coding; } export const buildNewField = (draft: NewFieldDraft, itemControls: IValueSet[] = []): ExtendedQuestionnaireItem => { const named = namedFieldType(draft.fieldTypeKey) ?? NAMED_FIELD_TYPES[0]; const isQuestion = named.type !== 'display' && named.type !== 'group'; // The serializer reads itemControl.code on every whole number, so an empty control is the resting value. const field: Record = { linkId: uuidv4(), type: named.type, text: draft.label.trim(), itemControl: {} }; if (isQuestion) { field.required = draft.required; } if (named.type !== 'display' && draft.help.trim()) { field.help = draft.help.trim(); } if (named.repeats) { field.repeats = true; } if (named.regex) { field.regex = named.regex; } if (named.entryFormat) { field.entryFormat = named.entryFormat; } if (named.itemControl) { const control = itemControls.find((option) => option.code === named.itemControl); field.itemControl = { system: ITEM_CONTROL_SYSTEM, code: named.itemControl, display: control?.display ?? DISPLAY_AS_LABELS[named.itemControl] }; } if (named.type === 'choice' || named.type === 'open-choice') { const taken: string[] = []; field.answerOption = draft.choices.map((choice) => choice.trim()).filter(Boolean).map((display) => { const code = deriveAnswerOptionCode(display, taken); taken.push(code); return { initialSelected: false, value: { code, display, system: AKASHIC_ANSWER_CODE_SYSTEM, score: null } }; }); } if (isQuestion && draft.code) { field.code = [draft.code]; } if (draft.unit) { if (named.type === 'quantity') { field.unitOption = [draft.unit]; } else if (named.type === 'integer' || named.type === 'decimal') { field.unit = draft.unit; } } return field as unknown as ExtendedQuestionnaireItem; }; export interface FieldEntry { linkId: string; name: string; depth: number; isGroup: boolean; } export const fieldName = (item: ExtendedQuestionnaireItem): string => { const text = (item.text ?? '').trim() || 'Untitled field'; return item.prefix ? `${item.prefix} ${text}` : text; }; const isHelpItem = (item: ExtendedQuestionnaireItem): boolean => item.linkId.endsWith('_help'); // Every field in form order, groups before their contents, for Jump to field. export const listFields = (tree: ExtendedQuestionnaireItem[], depth = 0): FieldEntry[] => (tree ?? []).filter((item) => !isHelpItem(item)).flatMap((item) => [ { linkId: item.linkId, name: fieldName(item), depth, isGroup: item.type === 'group' }, ...(item.type === 'group' ? listFields(item.item ?? [], depth + 1) : []), ]); const findField = (tree: ExtendedQuestionnaireItem[], linkId: string): ExtendedQuestionnaireItem | undefined => { for (const item of tree ?? []) { if (item.linkId === linkId) return item; const found = findField(item.item ?? [], linkId); if (found) return found; } return undefined; }; /** The owning group's linkId, null at the top level, undefined when the field is not in the tree. */ export const findParentLinkId = (tree: ExtendedQuestionnaireItem[], linkId: string, parent: string | null = null): string | null | undefined => { for (const item of tree ?? []) { if (item.linkId === linkId) return parent; const found = findParentLinkId(item.item ?? [], linkId, item.linkId); if (found !== undefined) return found; } return undefined; }; export interface MoveDestination { parentLinkId: string | null; label: string; } // A group cannot move into itself or anything inside it, so the moving field's own subtree is skipped. export const moveDestinations = (tree: ExtendedQuestionnaireItem[], sourceLinkId: string): MoveDestination[] => { const groups: MoveDestination[] = []; const walk = (list: ExtendedQuestionnaireItem[]) => list.forEach((item) => { if (item.linkId === sourceLinkId || item.type !== 'group') return; groups.push({ parentLinkId: item.linkId, label: fieldName(item) }); walk(item.item ?? []); }); walk(tree ?? []); return [{ parentLinkId: null, label: 'Outside all groups' }, ...groups]; }; export interface MovePosition { index: number; label: string; } // Indexes count the destination list with the moving field already taken out, as moveItemInTree applies them. export const movePositions = (tree: ExtendedQuestionnaireItem[], parentLinkId: string | null, sourceLinkId: string): MovePosition[] => { const list = parentLinkId === null ? tree ?? [] : findField(tree, parentLinkId)?.item ?? []; const siblings = list.filter((item) => item.linkId !== sourceLinkId); return [ { index: 0, label: 'At the start' }, ...siblings.map((item, index) => ({ index: index + 1, label: `After ${fieldName(item)}` })), ]; }; /** Where the field sits now, in the same terms as moveDestinations and movePositions. */ export const currentPlacement = (tree: ExtendedQuestionnaireItem[], linkId: string): { parentLinkId: string | null; index: number } | undefined => { const parentLinkId = findParentLinkId(tree, linkId); if (parentLinkId === undefined) return undefined; const list = parentLinkId === null ? tree : findField(tree, parentLinkId)?.item ?? []; return { parentLinkId, index: list.findIndex((item) => item.linkId === linkId) }; };