import { v4 as uuidv4 } from 'uuid'; import { TextareaControl } from "../../../../components/ui/textarea-control"; import { CustomSelectControl } from "../../../../components/ui/custom-select-control"; import { RadioControl } from "../../../../components/ui/radio-control"; import { Panel, PanelBody } from "../../../../components/ui/panel"; import { SelectControl } from "../../../../components/ui/select-control"; import FieldTypeSelect from "./FieldTypeSelect"; import { Scrollable } from "../../../../components/ui/scrollable"; import type { CustomSelectOption } from "../../../../components/ui/custom-select-control"; import { Card, CardBody } from '../../../../components/ui/wp-card'; import DiscardChangesDialog from '../../../../components/DiscardChangesDialog/DiscardChangesDialog'; import { InputControl } from '../../../../components/ui/input-control'; import { Divider } from '../../../../components/ui/divider'; import { DismissibleAlert } from '../../../../components/ui/dismissible-alert'; import { Heading } from '../../../../components/ui/heading'; import { Flex, FlexItem, FlexBlock } from '../../../../components/ui/flex'; import { Spacer } from '../../../../components/ui/spacer'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { ContactPoint, Organization, Questionnaire, QuestionnaireItem, QuestionnaireItemAnswerOption, QuestionnaireItemEnableWhen, Coding } from 'fhir/r4'; import { ExtendedQuestionnaireItem, ExtendedQuestionnaireItemAnswerOption, ExtendedQuestionnaireItemEnableWhen, ExtendedQuestionnaireItemInitial, IValueSet, useAuthServiceContext, useQuestionnaireServiceContext, useValueSetServiceContext } from '../../../../services'; import { AppSpinner, FhirCode, FhirCodeableConcept, FhirCodeDisplay, FhirPeriod } from '../../../../components'; import { Controller, FormProvider, useFieldArray, useForm, useWatch } from 'react-hook-form'; import QuestionnairePreview from './QuestionnairePreview/QuestionnairePreview.tsx'; import { Tabs, TabsList, TabsTrigger } from '../../../../components/ui/tabs'; import FieldTypeAssist from '../../../../components/FieldTypeAssist/FieldTypeAssist'; import BooleanInitialControl from './BooleanInitialControl'; import { ArrowDown, ArrowUp, ChevronDown, ChevronLeft, PlusCircle } from 'lucide-react'; import UcumUnitSearch from '../../../../components/UcumUnitSearch/UcumUnitSearch.tsx'; import { useState, useEffect, useMemo, useRef } from 'react'; import { Trash2 } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../../../components/ui/dialog.tsx'; import { Button as UiButton } from '../../../../components/ui/button.tsx'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../../../../components/ui/accordion.tsx'; import { Info } from 'lucide-react'; import { buildPublicFormSnapshot } from '../../../../utils/publicFormSnapshot.ts'; import { isPublicFormDraft } from '../../../../utils/publicFormKind.ts'; import { publicFormAsksForEmail } from '../../../../utils/publicFormAsksForEmail'; import { usePublicFormService } from '../../../../services/PublicFormService.tsx'; import FieldWidthControl from './FieldWidthControl.tsx'; import { PublishedFormUpdateReview } from './PublishedFormUpdateReview.tsx'; import { PublishHeaderAction, publishIsOffered } from './PublishHeaderAction'; import AddFieldDialog from './AddFieldDialog.tsx'; import MoveFieldDialog from './MoveFieldDialog.tsx'; import { ANSWER_TYPE_LABELS, CONDITION_OPERATOR_LABELS, DISPLAY_AS_LABELS, ENABLE_BEHAVIOR_LABELS, fieldName, listFields, withPlainLabels } from './builderFields.ts'; import { moveItemInTree, moveWithinList } from '../../../../utils/questionnaireReorder'; import { useIsPhoneWidth } from '../../../../hooks/useIsPhoneWidth'; import { describePublicFormChanges, PublicFormSnapshot } from '../../../../utils/publicFormChanges.ts'; import { savePublicForm, getPublicForms } from '../../../../utils/wpAdminApi.ts'; import { invalidatePaginatorContext } from '../../../../services/PaginatorService.tsx'; import { isWpAdminMount } from '../../../../utils/mountContext.ts'; import { isHostedMount, PUBLIC_FORMS_NEED_WORDPRESS } from '../../../../utils/hostedMount.ts'; import { AKASHIC_ANSWER_CODE_SYSTEM, deriveAnswerOptionCode, isLoincOwnedOption } from '../../../../utils/questionnaireAnswerCode'; // - WordPress's ToggleControl, swapped for the ui/* adapter. Same props, same JSX; // only `__nextHasNoMarginBottom` is dropped, which means nothing outside WordPress. import { ToggleControl } from '../../../../components/ui/toggle-control'; import { describeWriteFailure } from '../../../../utils/fetchFailureMessage'; import { resolveUnansweredPublish, UNANSWERED_PUBLISH_COPY, wasUnanswered } from '../../../../utils/unansweredPublish.ts'; const QuestionnaireEditor = () => { const { templateId, id, loincId } = useParams<{ templateId: string; id: string; loincId: string; }>(); const navigate = useNavigate(); const { state } = useLocation(); // Created from the Public forms tab; the saved form then carries its public tag instead. const createdAsPublicForm = !!(state as { publicForm?: boolean } | null)?.publicForm; const { wpOrganization } = useAuthServiceContext(); const { fetchQuestionnaire, transformToFhirQuestionnaire, transformFromFhirQuestionnaire, transformToFhirQuestionnaireItem, transformFromFhirQuestionnaireItem, createQuestionnaire, updateQuestionnaire, findItemByLinkId, setLastUpserted, setShouldReload, setUnsaved, setQuestionnaireList, convertFromLFormsToQuestionnaire, setTemplateList, fetchQuestionnaireTemplatesWithPolling, templateBundle } = useQuestionnaireServiceContext(); const { fetchValueSet, valueSetUrls, fetchFromLoinc } = useValueSetServiceContext(); const { publishPublicForm, listPublicForms, getPublishedSnapshot } = usePublicFormService(); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); // One dialog for the whole page: it explains what Score IS, not this option's value, so the copy is // identical for every option and a single boolean is enough. const [scoreInfoOpen, setScoreInfoOpen] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); const [questionnaire, setQuestionnaire] = useState(undefined); // THERE IS NO PER-FORM CAPTCHA STATE ANY MORE. It briefly existed as an option defaulted ON; // Spencer replaced that the same day - *"we should just force captcha in all public forms"* - because // the captcha became the deterministic bot FLOOR once the form-mint secret went away with the iframe, // and a floor a practice can switch off is not a floor. The server decides, and it always says yes. const [publishing, setPublishing] = useState(false); const [publishError, setPublishError] = useState(undefined); const [publishedFormHandle, setPublishedFormHandle] = useState(undefined); const [unpublishedFormHandle, setUnpublishedFormHandle] = useState<{ questionnaireId: string; formHandle: string } | undefined>(undefined); // A public form keeps its public settings while unpublished; only its handle goes away. const [publicKind, setPublicKind] = useState(false); const isPublic = publicKind || !!publishedFormHandle || isPublicFormDraft(questionnaire) || (id === 'new' && createdAsPublicForm); const [previewOpen, setPreviewOpen] = useState(false); const [reviewingUpdate, setReviewingUpdate] = useState(undefined); const [updateConfirmed, setUpdateConfirmed] = useState(false); const [liveSnapshot, setLiveSnapshot] = useState(undefined); const [selectedItem, setSelectedItem] = useState(null); // A form that exists opens on its canvas, as the deck draws it; a new one starts where it is named. const [segment, setSegment] = useState<'details' | 'items'>(id && id !== 'new' ? 'items' : 'details'); const [assistFieldPath, setAssistFieldPath] = useState(null); // Undefined group adds at the end of the form; a group adds at the end of that group. const [addFieldTarget, setAddFieldTarget] = useState<{ group?: ExtendedQuestionnaireItem } | null>(null); const [movingField, setMovingField] = useState(false); // The design as last loaded or saved. Structural edits reset the form's own baseline, so isDirty // cannot say whether leaving would drop work; the FHIR shape of the values can. const savedDesign = useRef(null); const [confirmingLeave, setConfirmingLeave] = useState(false); const leaveEditor = () => navigate(templateId ? '../../..' : loincId ? '../..' : '..', { relative: 'path', state: { returningFromDetail: true, formsTab: isPublic ? 'public' : 'clinical' }, }); const [settingsSheetOpen, setSettingsSheetOpen] = useState(false); const isPhone = useIsPhoneWidth(); const spacerMargin = '24px'; const isNew = id === 'new'; const hostedLock = isHostedMount(); // The form's kind decides which editor this is, so an existing form is read before the editor renders. const [kindCheck, setKindCheck] = useState<'checking' | 'known' | 'failed'>( !isNew && !templateId && !loincId ? 'checking' : 'known', ); // Item fields are addressed by paths built at runtime, so the form is typed by path, not by shape. const methods = useForm>({ defaultValues: { // A public form is never assignable, so until it is published it is a draft to FHIR as well. status: isNew && createdAsPublicForm ? 'draft' : '', title: '', url: '', name: '', experimental: false, subjectType: [], publisher: '', description: '', purpose: '', copyright: '', versionId: '', code: [], tags: [], contact: [], signatureRequired: false, signatureType: [], autoAssignTrigger: 'off', item: [] } }); const { control, handleSubmit, setValue, getValues, watch, reset } = methods; // A new form's baseline is its empty defaults; a loaded one replaces this when it arrives. // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { if (savedDesign.current === null) savedDesign.current = JSON.stringify(transformToFhirQuestionnaire(getValues())); }, []); const previewMethods = useForm>({ defaultValues: {} }); // react-hook-form only resolves watch() against a *literal* field path. Every questionnaire-item // path here is built at runtime from the item's own `path`, so the call falls through to the // readonly-array overload and the result is typed as an array of every possible field value. // Downstream that made `type === 'group'` and its ~25 siblings look like comparisons between an // array and a string, which is where 40 "no overlap" errors and 40 unmatched overloads came from. // None of it reflected runtime, where watch(name) returns that one field's value. Reading through // this helper keeps the runtime call identical and lets each site state the type it expects. const watchPath = (fieldPath: string): T => (watch as unknown as (name: string) => unknown)(fieldPath) as T; // getValues has the same literal-path constraint as watch, and the same runtime behaviour. const getValuesPath = (fieldPath: string): T => (getValues as unknown as (name: string) => unknown)(fieldPath) as T; const contactFieldArray = useFieldArray({ control, name: 'contact' }) const items = useWatch({ control, name: 'item', defaultValue: [] }); /** * THE THREE STATUSES A QUESTIONNAIRE LIBRARY HAS, and the fourth that FHIR has and it does not. * * `publication-status` is HL7's value set and it carries `unknown` - *the status of this resource * is not known* - which is a true thing to be able to say about a resource somebody else * published, and a meaningless thing to choose about a form this practice is authoring in front * of you. MEASURED walking B8: the picker offered it as a fourth peer of Draft, Active and * Retired, with nothing to say it means something different in kind. * * Narrowed here rather than in `ValueSetService`: the value set is FHIR's and correct, and other * readers of it may legitimately want all four. What is wrong is offering all four HERE. */ const LIBRARY_STATUSES = ['draft', 'active', 'retired']; // value sets const [signatureType, setSignatureType] = useState([]); const [publicationStatus, setPublicationStatus] = useState([]); const [resourceTypes, setResourceTypes] = useState([]); const [itemType, setItemType] = useState([]); const [questionnaireEnableOperator, setQuestionnaireEnableOperator] = useState([]); const [questionnaireEnableBehavior, setQuestionnaireEnableBehavior] = useState([]); const [questionnaireItemControl, setQuestionnaireItemControl] = useState([]); const [, setChoiceListOrientation] = useState([]); const [, setQuestionnaireUsageMode] = useState([]); useEffect(() => { const loadValueSets = async () => { const [ signatureTypeValueSet, publicationStatusValueSet, resourceTypesValueSet, itemTypeValueSet, questionnaireEnableOperatorValueSet, questionnaireEnableBehaviorValueSet, questionnaireItemControlValueSet, choiceListOrientationValueSet, questionnaireUsageModeValueSet, ] = await Promise.all([ fetchValueSet(valueSetUrls.signatureType), fetchValueSet(valueSetUrls.publicationStatus), fetchValueSet(valueSetUrls.resourceTypes), fetchValueSet(valueSetUrls.itemType), fetchValueSet(valueSetUrls.questionnaireEnableOperator), fetchValueSet(valueSetUrls.questionnaireEnableBehavior), fetchValueSet(valueSetUrls.questionnaireItemControl, true), fetchValueSet(valueSetUrls.choiceListOrientation), fetchValueSet(valueSetUrls.questionnaireUsageMode), ]); setSignatureType(signatureTypeValueSet); // Narrowed on the way in, so every reader of `publicationStatus` on this page gets the // three rather than each site remembering to filter. setPublicationStatus( publicationStatusValueSet.filter((option: IValueSet) => LIBRARY_STATUSES.includes(option.code as string)), ); setResourceTypes(resourceTypesValueSet); setItemType(itemTypeValueSet); setQuestionnaireEnableOperator(questionnaireEnableOperatorValueSet); setQuestionnaireEnableBehavior(questionnaireEnableBehaviorValueSet); setQuestionnaireItemControl(questionnaireItemControlValueSet); setChoiceListOrientation(choiceListOrientationValueSet); setQuestionnaireUsageMode(questionnaireUsageModeValueSet); }; loadValueSets(); }, [fetchValueSet, valueSetUrls]); useEffect(() => { const loadQuestionnaire = async () => { if ((isNew && !loincId && !templateId) || questionnaire) return; const { questionnaire: questionnaireState, template: templateState } = state ?? {}; if (questionnaireState) { const formData = transformFromFhirQuestionnaire(questionnaireState); reset(formData); savedDesign.current = JSON.stringify(transformToFhirQuestionnaire(formData)); setQuestionnaire(questionnaireState); return; } if (templateState) { const formData = transformFromFhirQuestionnaire(templateState); reset(formData); savedDesign.current = JSON.stringify(transformToFhirQuestionnaire(formData)); setQuestionnaire(templateState); return; } setLoading(true); try { let loadedQuestionnaire: Questionnaire | undefined; if (isNew && loincId) { const response = await fetchFromLoinc(loincId); if ((response as { type?: string }).type === 'LOINC') { loadedQuestionnaire = convertFromLFormsToQuestionnaire(response) as Questionnaire; } else { loadedQuestionnaire = response; } } else if (isNew && templateId) { loadedQuestionnaire = await fetchQuestionnaire(templateId); } else if (!isNew && id) { loadedQuestionnaire = await fetchQuestionnaire(id); } if (!loadedQuestionnaire) throw new Error('No questionnaire to load'); const transformedQuestionnaire = transformFromFhirQuestionnaire(loadedQuestionnaire); reset(transformedQuestionnaire); savedDesign.current = JSON.stringify(transformToFhirQuestionnaire(transformedQuestionnaire)); setQuestionnaire(loadedQuestionnaire); } catch (error) { console.error('Failed to load questionnaire:', error); setError('Could not load questionnaire. Try again.'); } finally { setLoading(false); } }; loadQuestionnaire(); }, [id, loincId, isNew, fetchFromLoinc]); /** * RE-HYDRATE THE PUBLISHED HANDLE FROM THE RECORD -, Spencer's ruling. * * A previously-published form has to read as published (and offer Update) on load. That * used to be read from a WordPress option, which is the duplicate the ruling deletes: it is unreadable * from akashic.health, it needed a wp-admin nonce, and it carried the org's mint secret beside it. * Dropping the `isWpAdminMount()` gate is the visible half of the fix - the standalone client can * now see its own published forms. * * THE WORDPRESS FALLBACK IS TRANSITIONAL AND HAS A STATED END. Registrations written before the * org index existed have no index item, so the API cannot list them until they are next published - * and without this an existing form would read as unpublished in the editor the moment * the endpoint deploys. Two orgs have one today. * It is a FALLBACK, never a source: the API answer wins whenever it has one, so a re-publish moves a * form onto the record permanently. Delete this branch after the pre-beta data wipe - by then every * form has been re-published and the option is dead. */ useEffect(() => { const qid = questionnaire?.id; // The editor is reused across forms: nothing learned about the last form may carry to this one. setPublishedFormHandle(undefined); setUnpublishedFormHandle(undefined); setPublicKind(false); if (!qid) return; let cancelled = false; (async () => { let registryAnswered = false; try { const forms = await listPublicForms(); const match = forms.find((form) => form.questionnaireId === qid && form.active); if (cancelled) return; registryAnswered = true; if (forms.some((form) => form.questionnaireId === qid)) { setPublicKind(true); const retired = forms.filter((form) => form.questionnaireId === qid && !form.active) .sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]?.formHandle; if (retired) setUnpublishedFormHandle({ questionnaireId: qid, formHandle: retired }); // The registry knows this form, so it decides: a WordPress record an unpublish left behind is not a publication. if (match) setPublishedFormHandle(match.formHandle); setKindCheck('known'); return; } } catch { // fall through to the transitional read below } if (cancelled) return; if (!isWpAdminMount()) { setKindCheck((current) => (current === 'checking' ? (registryAnswered ? 'known' : 'failed') : current)); return; } // The kind is not known until the WordPress record has also answered. let legacyPublished = false; try { const { forms } = await getPublicForms(); if (!cancelled && forms[qid]?.handle) { legacyPublished = true; setPublicKind(true); setPublishedFormHandle(forms[qid].handle); } } catch { // the registry's answer stands; with no answer from either, the kind stays unknown } if (cancelled) return; setKindCheck((current) => (current === 'checking' ? (registryAnswered || legacyPublished ? 'known' : 'failed') : current)); })(); return () => { cancelled = true; }; }, [questionnaire?.id, listPublicForms]); useEffect(() => { const loadTemplates = async () => { try { await fetchQuestionnaireTemplatesWithPolling(); } catch (error) { console.error('Failed to load questionnaire templates:', error); setError(`Could not load questionnaire templates. Try again.`); } }; if (!templateBundle) { loadTemplates(); } }, [fetchQuestionnaireTemplatesWithPolling, templateBundle]); const onSubmit = async (data: any, isTemplate: boolean = false) => { setSuccess(undefined); setError(undefined); if (segment === 'items' && !data.title) { setError(`Questionnaire title is required.`); setSegment('details'); return; } if (segment === 'items' && !data.status) { setError(`Questionnaire status is required.`); setSegment('details'); return; } setSubmitting(true); try { let result: Questionnaire | undefined; const transformedQuestionnaire: Questionnaire = transformToFhirQuestionnaire(data); if (isNew) { const created = await createQuestionnaire(transformedQuestionnaire, wpOrganization.current as Organization, isTemplate, !isTemplate && isPublic); result = created; if (isTemplate) { setTemplateList(prev => [...prev, created]); navigate(loincId ? '../..' : '..', { relative: 'path' }); } else { setQuestionnaireList(prev => [...prev, created]); invalidatePaginatorContext('questionnaires'); if (templateId) { navigate(`../../../${result.id}`, { relative: 'path' }); } else { navigate(loincId ? `../../${result.id}` : `../${result.id}`, { relative: 'path' }); } } } else if (questionnaire?.id) { const updated = await updateQuestionnaire({ id: questionnaire.id, ...transformedQuestionnaire }); result = updated; setQuestionnaire(updated); setQuestionnaireList(prev => prev.map(p => p.id === updated.id ? updated : p)); } if (result) { savedDesign.current = JSON.stringify(transformedQuestionnaire); setLastUpserted(result.id); setShouldReload(true); setUnsaved(false); // setForm(prev => ({ // ...prev, // versionId: result?.meta?.versionId || 1 // })); } setSuccess(`Questionnaire ${isNew ? 'created' : 'updated'} successfully.`); setTimeout(() => { setSuccess(undefined); }, 3_000); } catch (error: any) { console.error(`Error ${isNew ? 'creating' : 'updating'} questionnaire:`, error); setError(describeWriteFailure(error, `Failed to ${isNew ? 'create' : 'update'} questionnaire`)); } finally { setSubmitting(false); } }; useEffect(() => { setLiveSnapshot(undefined); if (!publishedFormHandle) return; let cancelled = false; getPublishedSnapshot(publishedFormHandle) .then((snapshot) => { if (!cancelled) setLiveSnapshot(snapshot); }) .catch(() => { /* unknown is not "changed": the state line stays Published */ }); return () => { cancelled = true; }; }, [publishedFormHandle, getPublishedSnapshot]); // Measured against the saved draft, not unsaved edits: this says what Save draft left unpublished. const hasUnpublishedChanges = useMemo( () => !!publishedFormHandle && !!liveSnapshot && !!questionnaire && describePublicFormChanges(liveSnapshot, buildPublicFormSnapshot(questionnaire)).length > 0, [publishedFormHandle, liveSnapshot, questionnaire], ); const publishOffered = publishIsOffered(isPublic, Boolean(publishedFormHandle), hasUnpublishedChanges); const buildDraftSnapshot = (): PublicFormSnapshot => { // From the useWatch('item') value the editor renders, because whole-form getValues() can lag it. const formValues = getValues(); const liveItems = items && items.length > 0 ? items : ((formValues as { item?: unknown[] }).item ?? []); return buildPublicFormSnapshot(transformToFhirQuestionnaire({ ...formValues, item: liveItems })); }; // An update publishes exactly the snapshot that was reviewed. const handlePublish = async (reviewed?: PublicFormSnapshot) => { if (!questionnaire || !questionnaire.id) { setPublishError('Save the questionnaire before publishing.'); return; } setPublishing(true); setPublishError(undefined); setUpdateConfirmed(false); const snapshot = reviewed ?? buildDraftSnapshot(); try { // NO CAPTCHA ARGUMENT - the server decides, and it decides "always". Spencer, 2026-08-20: // *"we should just force captcha in all public forms."* `utils/roomLocation`-style rules do // not apply here; there is simply nothing for a practice to choose. // The server mints the handle and keeps a republished form's, so its shortcode stays stable. const { formHandle, mintSecret } = await publishPublicForm(questionnaire.id, [window.location.host], snapshot); // The form is live from here; a failed WordPress record is said as that, not as a failed publish. setPublishedFormHandle(formHandle); setLiveSnapshot(snapshot); try { await savePublicForm(questionnaire.id, formHandle, mintSecret, '', questionnaire.title ?? ''); if (reviewed) { setReviewingUpdate(undefined); setUpdateConfirmed(true); } } catch (recordError) { console.error('Published, but WordPress did not record the form:', recordError); setPublishError('The form is published, but WordPress could not record it, so its shortcode may not work yet. Publish again to record it.'); } } catch (error: any) { console.error('Error publishing public form:', error); if (!wasUnanswered(error)) { setPublishError(describeWriteFailure(error, 'Failed to publish form')); return; } // No answer is not a refusal: the request may have published, so the server says which first. const resolved = await resolveUnansweredPublish(questionnaire.id, snapshot, listPublicForms, getPublishedSnapshot); if (resolved.outcome === 'live') { setPublishedFormHandle(resolved.formHandle); setLiveSnapshot(snapshot); if (reviewed) setReviewingUpdate(undefined); } setPublishError(UNANSWERED_PUBLISH_COPY[resolved.outcome]); } finally { setPublishing(false); } }; // THE `turnstileConfigured` GATE IS GONE. It read the org's own Turnstile keys and disabled this // toggle until a practice had pasted them in. Spencer, 2026-08-20: *"Turnstile is now an internal // Akashic thing, it's no longer something the orgs have to setup since the public forms are rendered in // an iframe."* The widget's hostname is ours now, so there is one platform key and nothing for a // practice to configure - which is what makes bot protection available to every org by default instead // of being an onboarding step nobody wanted. const scrollToSelected = (item: ExtendedQuestionnaireItem) => { document.getElementById(`item-${item.linkId}`)?.scrollIntoView?.({ behavior: 'smooth', block: 'center' }); }; // Selecting from the canvas or Jump to field; on a phone this also opens the settings sheet. const selectField = (linkId: string, reveal: boolean) => { const item = findItemByLinkId(getValues('item'), linkId) as ExtendedQuestionnaireItem | null; setSelectedItem(item); setSettingsSheetOpen(Boolean(item)); if (item && reveal) { setTimeout(() => scrollToSelected(item), 0); } }; // A reset rebuilds every item object and path, so the selection is re-read by its linkId. const reselect = (linkId: string) => { const fresh = findItemByLinkId(getValues('item'), linkId) as ExtendedQuestionnaireItem | null; setSelectedItem(fresh); if (fresh) { setTimeout(() => scrollToSelected(fresh), 200); } }; // Moves change sibling indexes, so they go through the same transform and reset cycle as dragging. const moveSelectedWithinGroup = (delta: -1 | 1) => { if (!selectedItem) return; const listPath = selectedItem.parent ? `${selectedItem.parent.path}.item` : 'item'; const siblings = getValuesPath(listPath) ?? []; const from = siblings.findIndex((sibling) => sibling.linkId === selectedItem.linkId); const to = from + delta; if (from === -1 || to < 0 || to >= siblings.length) return; setValue(listPath, moveWithinList(siblings, from, to)); reset(transformFromFhirQuestionnaire(transformToFhirQuestionnaire(getValues()))); reselect(selectedItem.linkId); }; const moveSelectedTo = (targetParentLinkId: string | null, targetIndex: number) => { if (!selectedItem) return; const nextTree = moveItemInTree(getValues('item') ?? [], selectedItem.linkId, targetParentLinkId, targetIndex); if (!nextTree) return; setValue('item', nextTree); reset(transformFromFhirQuestionnaire(transformToFhirQuestionnaire(getValues()))); reselect(selectedItem.linkId); }; const addItem = (type: 'group' | 'display' | 'question', context?: ExtendedQuestionnaireItem, item?: ExtendedQuestionnaireItem) => { let itemArray = getValuesPath(context ? `${context.path}.item` : 'item'); itemArray.push(item ?? { linkId: uuidv4(), type: type === 'question' ? 'string' : type, text: 'New Item' } as ExtendedQuestionnaireItem); const q = transformToFhirQuestionnaire(getValues()); const transformedQ = transformFromFhirQuestionnaire(q); reset(transformedQ); itemArray = getValuesPath(context ? `${context.path}.item` : 'item'); const selected = itemArray.at(-1); setSelectedItem(selected ?? null); setTimeout(() => { if (selected) scrollToSelected(selected); }, 200); }; const removeItem = (item: ExtendedQuestionnaireItem) => { const linkIdToRemove = item.linkId; const isReferencedInEnableWhen = (items: ExtendedQuestionnaireItem[]): boolean => { for (const item of items) { if (item.enableWhen && item.enableWhen.length > 0) { for (const condition of item.enableWhen) { // In form state the condition holds the referenced item itself, not its linkId. if ((condition.question as unknown as ExtendedQuestionnaireItem).linkId === linkIdToRemove) { return true; } } } if (item.item && item.item.length > 0) { if (isReferencedInEnableWhen(item.item)) { return true; } } } return false; }; if (isReferencedInEnableWhen(getValues('item'))) { setError(`"${item.text || 'This field'}" cannot be deleted because another field's "Show this field when" condition uses it. Remove that condition first.`); return; } const removeFromItemArray = (itemArray: QuestionnaireItem[]): QuestionnaireItem[] => { const items = itemArray; return items .filter((currentItem: QuestionnaireItem) => currentItem.linkId !== linkIdToRemove) .map((currentItem: QuestionnaireItem) => { if (currentItem.type === 'group' && currentItem.item && currentItem.item.length > 0) { return { ...currentItem, item: removeFromItemArray(currentItem.item) }; } return currentItem; }); }; const q = transformToFhirQuestionnaire(getValues()); const filteredItems = removeFromItemArray(q.item ?? []); const transformedQ = transformFromFhirQuestionnaire({ ...q, item: filteredItems }); reset(transformedQ); if (selectedItem) { if (selectedItem.linkId === linkIdToRemove) { setSelectedItem(null); } else { const linkId = selectedItem.linkId; const selected = findItemByLinkId(getValues('item'), linkId) as ExtendedQuestionnaireItem | null; setSelectedItem(selected); if (selected) { scrollToSelected(selected); } } } }; const getItemControlOptions = () => { const type = selectedItem?.type; const repeats = selectedItem?.repeats; const choiceTypes = ['drop-down', 'radio-button']; const repeatingChoiceTypes = ['drop-down', 'check-box']; const numericTypes = ['text-box', 'slider']; const groupTypes = ['list', 'gtable']; if (questionnaireItemControl) { const allItems = Object.values(questionnaireItemControl as unknown as Record).flat(); return allItems.filter((itemType: IValueSet) => { if (type === 'choice' || type === 'open-choice') { return repeats ? repeatingChoiceTypes.includes(itemType.code) : choiceTypes.includes(itemType.code); } else if (type === 'integer') { return numericTypes.includes(itemType.code); } else if (type === 'group') { return groupTypes.includes(itemType.code); } return true; }); } return []; }; // Every item control the value set offers, for building a new field of a named list type. const getItemControlCodings = (): IValueSet[] => questionnaireItemControl ? Object.values(questionnaireItemControl as unknown as Record).flat() : []; const findRootGroup = (item: ExtendedQuestionnaireItem): ExtendedQuestionnaireItem => { if (!item.parent) { return item; } return findRootGroup(item.parent); }; const isDisabledByParent = (): boolean => { const parent = selectedItem?.parent; const isInGroup: boolean = parent?.type === 'group'; if (!isInGroup) { return false; } const root = findRootGroup(selectedItem as ExtendedQuestionnaireItem); return Boolean(root?.readOnly); }; const addEnableWhen = (path: string) => { const enableWhenArray: QuestionnaireItemEnableWhen[] = getValuesPath(`${path}.enableWhen`); setValue(`${path}.enableWhen`, [ ...enableWhenArray, { question: null, operator: '', answer: '', unit: null } ]); }; const removeEnableWhen = (path: string, index: number) => { const enableWhenArray: QuestionnaireItemEnableWhen[] = getValuesPath(`${path}.enableWhen`) ?? []; enableWhenArray.splice(index, 1); setValue(`${path}.enableWhen`, enableWhenArray); }; const canAddInitial = (_path: string) => true; const addInitial = (path: string) => { const initial: ExtendedQuestionnaireItemAnswerOption[] = getValuesPath(`${path}.initial`); setValue(`${path}.initial`, [ ...initial, { value: '' } ]); }; const removeInitial = (path: string, index: number) => { const initial: ExtendedQuestionnaireItemAnswerOption[] = getValuesPath(`${path}.initial`) || []; initial.splice(index, 1); setValue(`${path}.initial`, initial); }; const handleInitialInputChange = (value: string | boolean | null, index: number) => { const isInGroup: boolean = selectedItem?.parent?.type === 'group'; const executeChange = (item: ExtendedQuestionnaireItem) => { const path = item.answerPath; const answerArray: { value: unknown }[] = getValuesPath(`${path}.answer`) ?? []; if (index >= 0 && index < answerArray.length) { answerArray[index].value = item.type === 'integer' || item.type === 'decimal' ? Number(value) : value; setValue(`${path}.answer`, answerArray); } }; if (isInGroup) { const syncAnswers = (node: ExtendedQuestionnaireItem) => { const path = node.answerPath; const answers = getValuesPath(`${path}.answer`) ?? []; for (let i = 0; i < answers.length; i++) { const answerGroup = answers[i]; for (let j = 0; j < answerGroup.length; j++) { const answerItem = answerGroup[j]; if (answerItem.linkId === selectedItem?.linkId) { executeChange(answerItem); } else if (answerItem.type === 'group') { syncAnswers(answerItem); } } } }; const root = findRootGroup(selectedItem as ExtendedQuestionnaireItem); syncAnswers(root); } else { executeChange(selectedItem as ExtendedQuestionnaireItem); } }; const addAnswerOption = (path: string) => { const answerOption: ExtendedQuestionnaireItemAnswerOption[] = getValuesPath(`${path}.answerOption`); setValue(`${path}.answerOption`, [ ...answerOption, { initialSelected: false, value: { code: '', display: '', system: '', score: null } } ]); }; const removeAnswerOption = (path: string, index: number) => { const answerOption: ExtendedQuestionnaireItemAnswerOption[] = getValuesPath(`${path}.answerOption`) || []; answerOption.splice(index, 1); setValue(`${path}.answerOption`, answerOption); }; // Derive a stable local code for a manually authored option, ONCE, on the label's blur. // // The `!option.value.code` gate is load-bearing: it means this can only ever fire once per option, so // relabelling never changes an existing code. A changed code orphans every QuestionnaireResponse that // referenced the old one. Do not remove the gate, and do not move this to onChange (which fires per // keystroke and would freeze the code to the first character typed). // // Deriving here rather than in the serializer is deliberate: on the update path, builder state does not // re-seed after a save (the load effect self-guards on `questionnaire`, and onSubmit's update path has no reset), // so a serialize-time derivation would re-run on every save and silently change the code. The create path does // re-seed by refetching, but that is harmless - the refetch reads the already-persisted code, so the gate still holds. const deriveAnswerOptionCodeIfEmpty = (path: string, index: number) => { const options: ExtendedQuestionnaireItemAnswerOption[] = getValuesPath(`${path}.answerOption`) ?? []; const option = options[index]; if (!option) { return; } const display = (option.value.display ?? '').trim(); if (display === '' || option.value.code) { return; } const takenCodes: string[] = options .filter((_: ExtendedQuestionnaireItemAnswerOption, i: number) => i !== index) .map((other: ExtendedQuestionnaireItemAnswerOption) => other.value.code) .filter((code: string) => Boolean(code)); setValue(`${path}.answerOption.${index}.value.code`, deriveAnswerOptionCode(display, takenCodes), { shouldDirty: true }); setValue(`${path}.answerOption.${index}.value.system`, AKASHIC_ANSWER_CODE_SYSTEM, { shouldDirty: true }); }; const addAnswer = (item: ExtendedQuestionnaireItem, original?: ExtendedQuestionnaireItem) => { const path = item.answerPath; const answerArray = getValuesPath(`${path}.answer`) ?? []; const questionnaireItem = transformToFhirQuestionnaireItem(item); const childItems = questionnaireItem.item ?? []; // item is a group if (item.type === 'group') { const newGroupAnswer = childItems .filter((childItem: QuestionnaireItem) => childItem.linkId !== `${item.linkId}_help`) .map((childItem: QuestionnaireItem, childIndex: number) => transformFromFhirQuestionnaireItem(childItem, getValues() as Questionnaire, childIndex, undefined, item, `${item.path}.item`, `${path}.answer[${item.answer.length}]`) ); setValue(`${path}.answer`, [...answerArray, newGroupAnswer]); return; } let initialArray; // item is inside a group if (original) { if (original.type === 'group') { const itemIndex = original.item.findIndex((originalItem: ExtendedQuestionnaireItem) => originalItem.linkId === item.linkId); const question = original.item[itemIndex]; initialArray = question.initial; } else { initialArray = original.initial; } } else { initialArray = getValuesPath(`${item.path}.initial`) ?? []; } const index = answerArray.length; const initialValue = (initialArray?.[index] as { value?: unknown } | undefined)?.value ?? ''; setValue(`${path}.answer`, [...answerArray, { value: initialValue }]); }; const handleMinOccursChange = (minOccurs: number) => { const parent: ExtendedQuestionnaireItem = selectedItem?.parent as ExtendedQuestionnaireItem; const isInGroup: boolean = parent?.type === 'group'; const executeChange = (item: ExtendedQuestionnaireItem, original?: ExtendedQuestionnaireItem) => { const path = item.answerPath; const answerArray = getValuesPath(`${path}.answer`) ?? []; let i = answerArray.length; while (i++ < minOccurs) { addAnswer(item, original); } i = answerArray.length; while (i-- > minOccurs) { answerArray.pop(); setValue(`${path}.answer`, answerArray); } }; if (isInGroup) { const syncAnswers = (node: ExtendedQuestionnaireItem) => { const path = node.answerPath; const answers = getValuesPath(`${path}.answer`) ?? []; for (let i = 0; i < answers.length; i++) { const answerGroup = answers[i]; for (let j = 0; j < answerGroup.length; j++) { const answerItem = answerGroup[j]; if (answerItem.linkId === selectedItem?.linkId) { executeChange(answerItem); } else if (answerItem.type === 'group') { syncAnswers(answerItem); } } } }; const root = findRootGroup(selectedItem as ExtendedQuestionnaireItem); syncAnswers(root); } else { executeChange(selectedItem as ExtendedQuestionnaireItem); } }; const handleMaxOccursChange = (maxOccurs: number) => { const parent: ExtendedQuestionnaireItem = selectedItem?.parent as ExtendedQuestionnaireItem; const isInGroup: boolean = parent?.type === 'group'; const executeChange = (item: ExtendedQuestionnaireItem) => { const path = item.answerPath; const answerArray = getValuesPath(`${path}.answer`) ?? []; let i = answerArray.length; while (i-- > maxOccurs) { answerArray.pop(); setValue(`${path}.answer`, answerArray); } }; if (isInGroup) { const syncAnswers = (node: ExtendedQuestionnaireItem) => { const path = node.answerPath; const answers = getValuesPath(`${path}.answer`) ?? []; for (let i = 0; i < answers.length; i++) { const answerGroup = answers[i]; for (let j = 0; j < answerGroup.length; j++) { const answerItem = answerGroup[j]; if (answerItem.linkId === selectedItem?.linkId) { executeChange(answerItem); } else if (answerItem.type === 'group') { syncAnswers(answerItem); } } } }; const root = findRootGroup(selectedItem as ExtendedQuestionnaireItem); syncAnswers(root); } else { executeChange(selectedItem as ExtendedQuestionnaireItem); } }; const handleRepeatsChange = (repeats: boolean) => { if (!selectedItem) return; const parent: ExtendedQuestionnaireItem = selectedItem?.parent as ExtendedQuestionnaireItem; const isInGroup: boolean = parent?.type === 'group'; const initialArray = getValuesPath(`${selectedItem.path}.initial`) ?? []; const minOccurs = getValuesPath(`${selectedItem.path}.minOccurs`) ?? 1; const executeChange = (item: ExtendedQuestionnaireItem, original?: ExtendedQuestionnaireItem) => { const path = item.answerPath; const answerArray = getValuesPath(`${path}.answer`) ?? []; // Rule: Can only have multiple initial values for repeating items if (!repeats) { if (initialArray) { let i = initialArray.length; while (i-- > 1) { initialArray.pop(); setValue(`${selectedItem.path}.initial`, initialArray); } } if (answerArray) { let i = answerArray.length; while (i-- > 1) { answerArray.pop(); setValue(`${path}.answer`, answerArray); } } } else { let i = answerArray.length; while (i++ < minOccurs) { addAnswer(item, original); } } }; if (isInGroup) { const syncAnswers = (node: ExtendedQuestionnaireItem) => { const path = node.answerPath; const answers = getValuesPath(`${path}.answer`) ?? []; for (let i = 0; i < answers.length; i++) { const answerGroup = answers[i]; for (let j = 0; j < answerGroup.length; j++) { const answerItem = answerGroup[j]; if (answerItem.linkId === selectedItem?.linkId) { executeChange(answerItem); } else if (answerItem.type === 'group') { syncAnswers(answerItem); } } } }; const root = findRootGroup(selectedItem as ExtendedQuestionnaireItem); syncAnswers(root); } else { executeChange(selectedItem as ExtendedQuestionnaireItem); } const itemControl = getValuesPath(`${selectedItem?.path}.itemControl`); if (itemControl) { setValue(`${selectedItem?.path}.itemControl`, {}); } }; const handleAnswerOptionSelectionChange = (value: boolean, index: number) => { const path = selectedItem?.path; const answerOptionsArray: QuestionnaireItemAnswerOption[] = getValuesPath(`${path}.answerOption`) ?? []; answerOptionsArray.forEach((option: QuestionnaireItemAnswerOption, i: number) => option.initialSelected = value && i === index); setValue(`${path}.answerOption`, answerOptionsArray); }; const getApplicableQuestionnaireEnableWhenOperators = (condition: ExtendedQuestionnaireItemEnableWhen): IValueSet[] => { const question = condition.question; const type = question?.type; if (['integer', 'decimal', 'quantity', 'date', 'dateTime', 'time'].includes(type)) { return questionnaireEnableOperator; } return questionnaireEnableOperator.filter((operator: IValueSet) => { return operator.code !== '<' && operator.code !== '<=' && operator.code !== '>' && operator.code !== '>='; }); }; const getQuestionPredicates = () => { const itemsArray = getValues('item'); const selectedLinkId = selectedItem?.linkId; const extractQuestions = (array: ExtendedQuestionnaireItem[]): ExtendedQuestionnaireItem[] => { let questions: ExtendedQuestionnaireItem[] = []; array.forEach((item) => { const type = item.type; const linkId = item.linkId; if (type !== 'display' && type !== 'group' && linkId !== selectedLinkId) { questions.push(item); } if (item.item && Array.isArray(item.item)) { questions = questions.concat(extractQuestions(item.item)); } }); return questions; }; return extractQuestions(itemsArray); }; const descriptionField = ( (isPublic ? ( ) : ( ))}/> ); if (loading || (kindCheck === 'checking' && questionnaire)) { return ( ); } if ((hostedLock && isPublic) || kindCheck === 'failed') { return (

{watch('title') || 'Untitled form'}

{isPublic ? 'Public form' : 'Form'}

{isPublic ? PUBLIC_FORMS_NEED_WORDPRESS : 'We could not check whether this is a public form, so it cannot be edited here. Reload to try again.'}
); } return (

{watch('title') || (isPublic ? 'Untitled form' : 'Untitled questionnaire')}

{isPublic ? `Public form · ${hasUnpublishedChanges ? 'Unpublished changes' : publishedFormHandle ? 'Published' : unpublishedFormHandle ? 'Unpublished' : 'Draft'}` : `Clinical questionnaire${CLINICAL_STATUS[watch('status')] ? ` · ${CLINICAL_STATUS[watch('status')]}` : ''}`}

{!templateId && isNew && !isPublic && ( )} {/* While Publish is offered it is the main act, so saving a draft steps back. */} handlePublish()} onReviewUpdate={() => { setUpdateConfirmed(false); setReviewingUpdate(buildDraftSnapshot()); }}/>
{isPublic && segment === 'items' && ( Website visitors · submissions go to the front desk. Saving a draft does not publish it. )} {isPublic && segment === 'items' && !publicFormAsksForEmail(items) && ( No question asks for an email address, so you will not be able to reply to these submissions. Add a question with “Email” in its label. )} {!isPublic && segment === 'items' && ( Patient portal · answers save to the patient record. Drafts cannot be assigned. )}
{confirmingLeave && ( setConfirmingLeave(false)} onDiscard={leaveEditor}/> )} Preview
{publishedFormHandle && reviewingUpdate && ( setReviewingUpdate(undefined)} onClose={() => { if (!publishing) setReviewingUpdate(undefined); }} onConfirm={() => handlePublish(reviewingUpdate)}/> )} {/* Explains the Score field. Score is honest FHIR (the ordinalValue extension), but nothing in Akashic computes with it yet, so its purpose is invisible without this. */} What is Score?

Score is the numeric value of a single answer option. In FHIR it is the standard ordinalValue extension.

It is what turns a questionnaire into a scored instrument. A PHQ-9 depression screen, for example, uses the same four answers on every question:

  • Not at all — 0
  • Several days — 1
  • More than half the days — 2
  • Nearly every day — 3

The patient’s total across all nine questions is what indicates severity. On an instrument like that, the score is not a label — it is the instrument.

Akashic does not calculate scores today. We store what you enter and hand it back exactly as FHIR expects.

It is still worth filling in: because ordinalValue is standard FHIR, a system you export to or integrate with can score the response even though we do not. And questionnaires imported from LOINC arrive with their official scores already set — leaving those intact keeps the instrument valid for whoever receives it.

Leave it blank for ordinary forms. A contact form has nothing to score.

{success && ( <> setSuccess(undefined)}> {success} )} {error && ( <> setError(undefined)}>
{error}
)} setSegment(value as 'details' | 'items')}> Form builder Form Settings {/* No fixed height: inside the page body's auto-height grid a percentage clipped the settings card. */}
{/* details segment */} {segment === 'details' && (
{/* title */} ( <> {fieldState.error && ( Title is required )} )}/> {isPublic && ( <> {descriptionField} )} {/* publishing, where the deck draws it: in the form settings, not a dialog */} {!isNew && isWpAdminMount() && isPublic && (
{publishError && (
{publishError}
)} {updateConfirmed && ( Update confirmed. The draft and the published version match. )} {publishedFormHandle ? (
{hasUnpublishedChanges && (

Published version remains live. Your newer draft is saved but not published.

)}
) : null} {!publishedFormHandle && ( <>

Draft · not accepting submissions. Saving does not publish or place a form on a WordPress page.

)}

Bot protection is provided by Akashic. Submissions are handled by the front desk, which may associate one with a patient when appropriate.

)} {!isPublic && ( <> {descriptionField} {/* status */} {!publishedFormHandle && (

Drafts cannot be assigned. Active questionnaires can be assigned to patients; retired ones are no longer offered for new assignments.

)} {/* signatureRequired */} ( )}/> {/* signatureType */} {watch('signatureRequired') && ( <> )} {/* autoAssignTrigger */} ( )}/> {/* The FHIR metadata an imported standard form carries. A practice rarely changes it, so it waits here. */}
Advanced
{/* versionId */} {!isNew && ( ( )}/> )} {/* code */} {/* subjectType */} {/* publisher */} ( )}/> {/* url */} ( )}/> {contactFieldArray.fields.length !== 0 && ( <> Contact {(contactFieldArray.fields as any[]).map((contact: { name: string, telecom: ContactPoint[]}, index: number) => ( <> {/* name */} ( )}/> {contact.telecom.map((_telecom: ContactPoint, telecomIndex: number) => ( <> {/* system */} ( )}/> {/* value */} ( )}/> ))} ))} )} {/* purpose */} ( )}/> {/* copyright */} ( )}/> {/* experimental */} ( )}/> {/* period */}
{/* No Task priority control: an automation author may not work for the practice, and urgency on something that reaches a patient is not theirs to set. The extension is always `routine`. */} )}
)} {/* items segment */} {segment === 'items' && (() => { const path = selectedItem?.path ?? ''; const type = watchPath(`${path}.type`); const isGroup = type === 'group'; const isDisplay = type === 'display'; const isChoice = type === 'choice' || type === 'open-choice'; const repeats = watchPath(`${path}.repeats`); const isRequired = watchPath(`${path}.required`); const itemControl = watchPath(`${path}.itemControl`); const isSlider = type === 'integer' && itemControl?.code === 'slider'; const answerOptionArray = watchPath(`${path}.answerOption`); const initialArray = watchPath(`${path}.initial`); const enableWhenArray = watchPath(`${path}.enableWhen`); const fieldEntries = listFields(items ?? []); const addTarget = selectedItem?.type === 'group' ? selectedItem : undefined; const siblings: ExtendedQuestionnaireItem[] = selectedItem ? getValuesPath(selectedItem.parent ? `${selectedItem.parent.path}.item` : 'item') ?? [] : []; const siblingIndex = selectedItem ? siblings.findIndex((sibling) => sibling.linkId === selectedItem.linkId) : -1; const inspector = selectedItem ? (

{isGroup ? 'Selected group' : isDisplay ? 'Selected instructions' : 'Selected field'}

{/* text */} ( )}/> {!isDisplay && !isGroup && ( <> !['group', 'display', 'reference'].includes(t.code)), ANSWER_TYPE_LABELS)}/> {watchPath(`${path}.text`)?.trim() && ( )} {assistFieldPath === path && ( (`${path}.text`) ?? ''} onAccept={(match) => { setValue(`${path}.type` as Parameters[0], match.answerType ?? type, { shouldDirty: true }); // RHF PathValue has no literal for nested item.code; the array value collapses to never here. setValue(`${path}.code` as Parameters[0], [{ system: 'http://loinc.org', code: match.code, display: match.display }] as never, { shouldDirty: true }); if (match.unit) { const unitCoding = { system: 'http://unitsofmeasure.org', code: match.unit, display: match.unit }; const resolvedType = match.answerType ?? type; if (["integer", "decimal"].includes(resolvedType)) { setValue(`${path}.unit` as Parameters[0], unitCoding as never, { shouldDirty: true }); } else if (resolvedType === 'quantity') { setValue(`${path}.unitOption` as Parameters[0], [unitCoding] as never, { shouldDirty: true }); } } setAssistFieldPath(null); }} onClose={() => setAssistFieldPath(null)}/> )} )} {['quantity', 'integer', 'decimal'].includes(type) && ( <> )} {!isDisplay && ( <> ( )}/> )} {(isChoice || ['group', 'integer'].includes(type)) && (
{isChoice ? 'Answer choices' : 'Display'}
{questionnaireItemControl && (['group', 'choice', 'open-choice', 'integer'].includes(type)) && ( <> {/* itemControl */} { const options: IValueSet[] = withPlainLabels(getItemControlOptions(), DISPLAY_AS_LABELS); const selectedOption = options.find((option: IValueSet) => option.code === field.value.code); const customSelectValue: CustomSelectOption | undefined = selectedOption ? { key: selectedOption.code ?? '', name: selectedOption.display ?? '' } : undefined; return ( ({ key: option.code, name: option.display })) ] as CustomSelectOption[]} value={customSelectValue} onChange={(newValue: { selectedItem: CustomSelectOption }) => { const selectedValueSet = getItemControlOptions().find((option: IValueSet) => option.code === newValue.selectedItem.key); field.onChange(selectedValueSet); }}/> ); }}/> {isSlider && ( <> {/* sliderStepValue */} ( )}/> )} {/* hide temporarily */} {false && isChoice && ( <> {/* choiceOrientation */} ( )}/> )} )} {/* answerOptions */} {isChoice && ( <> {answerOptionArray.length === 0 ? ( No choices yet. Add at least one choice. ) : ( {answerOptionArray.map((answer: ExtendedQuestionnaireItemAnswerOption, index: number) => ( {/* initialSelected */} ( { handleAnswerOptionSelectionChange(value, index); handleInitialInputChange(answer.value, 0); onChange(value); }} disabled={submitting}/> )}/> {/* display */} ( { field.onBlur(); deriveAnswerOptionCodeIfEmpty(path, index); }}/> )}/> {/* Radix, not WordPress: this whole surface ports to the standalone web client, so new markup stays WP-free. */} Codes & score {/* code */} ( )}/> {/* system */} ( )}/> {/* score */}
( )}/>
setScoreInfoOpen(true)}>
))}
)} )}
)}
Layout & ordering
( )}/>

Widths apply on larger screens. Fields stack on phones.

Help & validation
{!isDisplay && ( <> {['string', 'text'].includes(type) && ( <> {/* entryFormat */} ( )}/> )} {/* help */} ( )}/> )} ( )}/> {!isDisplay && !isGroup && ( <> {['string', 'text'].includes(type) && ( <> {/* minLength */} ( )}/> {/* maxLength */} ( )}/> )} {['integer', 'decimal', 'quantity', 'date', 'dateTime', 'time'].includes(type) && ( <> {/* minValue */} ( )}/> {/* maxValue */} ( )}/> )}

Validation follows the selected answer type. No regex to write.

)}
0}> Show this field when…
{enableWhenArray.length === 0 ? ( Always shown. Add conditions using another question’s answer; with more than one, choose All or Any. ) : ( {enableWhenArray.map((condition: ExtendedQuestionnaireItemEnableWhen, index: number) => ( {/* question */} { const options = getQuestionPredicates(); const selectedOption = options.find((q: ExtendedQuestionnaireItem) => q.linkId === field.value?.linkId); const customSelectValue: CustomSelectOption | undefined = selectedOption ? { key: selectedOption.linkId, name: selectedOption.text ?? '' } : undefined; return ( ({ key: q.linkId, name: q.text })) ] as CustomSelectOption[]} value={customSelectValue} onChange={(newValue: { selectedItem: CustomSelectOption }) => { const selectedQuestion = options.find((q: ExtendedQuestionnaireItem) => q.linkId === newValue.selectedItem.key); field.onChange(selectedQuestion); }}/> ); }}/> {enableWhenArray[index].question && ( <> {/* operator */} )} {enableWhenArray[index].question && enableWhenArray[index].operator && ( <> { const operator = watchPath(`${path}.enableWhen.${index}.operator`); const questionType = watchPath(`${path}.enableWhen.${index}.question.type`); // 'empty' is not a FHIR enableWhen operator (the union is // exists | = | != | > | < | >= | <=) and nothing here ever // produces one, so that half of the test never matched. if (operator === 'exists') { return <>; } if (questionType === 'choice' || questionType === 'open-choice') { const options: Coding[] = (enableWhenArray[index].question.answerOption ?? []).map((answerOption) => (answerOption as ExtendedQuestionnaireItemAnswerOption).value); const selectedOption = options.find((answer) => answer.code === field.value?.code); const customSelectValue: CustomSelectOption | undefined = selectedOption ? { key: selectedOption.code ?? '', name: selectedOption.display ?? '' } : undefined; return ( ({ key: option.code, name: option.display })) ] as CustomSelectOption[]} value={customSelectValue} onChange={(newValue: { selectedItem: CustomSelectOption }) => { const selectedAnswer = options.find((answer) => answer.code === newValue.selectedItem.key); field.onChange(selectedAnswer); }}/> ); } if (questionType === 'boolean') { return ( ); } return ( ); }}/> )} ))} )} {enableWhenArray.length > 1 && ( <> {/* enableBehavior */} ( ( { value: option.code, label: ENABLE_BEHAVIOR_LABELS[option.code] ?? option.display } ))} selected={field.value} onChange={field.onChange}/> )}/> )}
Coding & advanced
{/* code */} {/* Rule: Display items cannot have a "code" asserted */} {!isDisplay && ( <> )} {!isDisplay && ( <> {selectedItem.parent?.itemControl?.code !== 'gtable' && type !== 'boolean' && ( <> {/* repeats */} ( { handleRepeatsChange(value); field.onChange(value); }} label="Allow repeated answers" disabled={submitting}/> )}/> )} {repeats && ( <> {isRequired && ( <> {/* minOccurs */} ( { handleMinOccursChange(Number(value)); field.onChange(value); }} label="Minimum answers" type="number" min={1} disabled={submitting}/> )}/> )} {/* maxOccurs */} ( { handleMaxOccursChange(Number(value)); field.onChange(value); }} label="Maximum answers" type="number" min={2} disabled={submitting}/> )}/> )} {!isDisabledByParent() && ( <> {/* readOnly */} ( )}/> )} )} ( )}/> {/* prefix */} ( )}/> {!isDisplay && !isGroup && ( <> {/* Rule: If one or more answerOption is present, initial[x] must be missing */} {type !== 'choice' && type !== 'open-choice' && answerOptionArray.length === 0 && ( <> {initialArray.map((_initial: ExtendedQuestionnaireItemInitial, index: number) => { return ( <> {type === 'boolean' && ( <> ( { field.onChange(value); handleInitialInputChange(value ?? null, index); }} disabled={submitting}/> )}/> )} {type !== 'boolean' && ( <> ( { field.onChange(value); handleInitialInputChange(value ?? null, index); }} label="Initial answer (optional)" type={ type === 'integer' || type === 'decimal' ? 'number' : type === 'date' ? 'date' : type === 'dateTime' ? 'datetime-local' : type === 'time' ? 'time' : 'text' } disabled={submitting}/> )}/> )} {initialArray.length > 1 && ( )} ) })} {/* Rule: Can only have multiple initial values for repeating items */} {repeats && ( <> )} )} )}
) : null; return (
{fieldEntries.length > 0 && (
Jump to field
    {fieldEntries.map((entry) => (
  • ))}
)}

Drag a field to move it. Drag its right edge to resize. Select it to edit.

{(items ?? []).length === 0 && (

No fields yet. Add a field to start building this form.

)} selectField(linkId, false)}/>
{isPhone && selectedItem && ( )}
{!isPhone && ( )}
{/* On a phone the settings open over the canvas rather than far below it. Closing keeps every edit: they are already in the form, and only Save draft persists them. */} {isPhone && ( { if (!open) setSettingsSheetOpen(false); }}> Field settings
{inspector}
)} {movingField && selectedItem && ( { moveSelectedTo(parentLinkId, index); setMovingField(false); }} onClose={() => setMovingField(false)}/> )} {addFieldTarget && ( { addItem(field.type === 'group' || field.type === 'display' ? field.type : 'question', addFieldTarget.group, field); setAddFieldTarget(null); setSuccess('Field added. Unsaved changes: preview, then save your form when ready.'); }} onImport={(selection, intoDestination) => { addItem(selection.type, intoDestination ? addFieldTarget.group : undefined, selection.item); setAddFieldTarget(null); }} onClose={() => setAddFieldTarget(null)}/> )}
); })()}
); }; // The editor holds one form's state, so opening another form, even from the address bar, mounts a fresh editor. /** The questionnaire's status as its header names it; an unknown status is left unnamed. */ const CLINICAL_STATUS: Record = { draft: 'Draft', active: 'Active', retired: 'Retired' }; const QuestionnairePage = () => { const { templateId, id, loincId } = useParams<{ templateId: string; id: string; loincId: string; }>(); return ; }; export default QuestionnairePage;