import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DndContext, closestCorners, pointerWithin, rectIntersection, DragOverlay, useSensor, useSensors, MouseSensor, KeyboardSensor, } from '@dnd-kit/core'; import type { DragEndEvent } from '@dnd-kit/core'; import { SortableContext } from '@dnd-kit/sortable'; import { Accordion, AccordionItem, Button, IconButton, InlineLoading } from '@carbon/react'; import { Add, TrashCan, Edit } from '@carbon/react/icons'; import { useParams } from 'react-router-dom'; import type { FormSchema, FormField } from '@openmrs/esm-form-engine-lib'; import { showModal, showSnackbar } from '@openmrs/esm-framework'; import { moveQuestion, type DragQuestionData } from './drag-and-drop-helpers'; import DraggableQuestion from './draggable/draggable-question.component'; import EditableValue from './editable/editable-value.component'; import type { Schema } from '@types'; import styles from './interactive-builder.scss'; interface ValidationError { errorMessage?: string; warningMessage?: string; field: { label: string; concept: string; id?: string; type?: string }; } interface InteractiveBuilderProps { isLoading: boolean; onSchemaChange: (schema: Schema) => void; schema: Schema; validationResponse: Array; } interface SubQuestionProps { question: FormField; pageIndex: number; sectionIndex: number; questionIndex: number; } const InteractiveBuilder: React.FC = ({ isLoading, onSchemaChange, schema, validationResponse, }) => { const [activeQuestion, setActiveQuestion] = useState(null); const mouseSensor = useSensor(MouseSensor, { activationConstraint: { distance: 10, // Enable sort function when dragging 10px 💡 here!!!. }, }); const keyboardSensor = useSensor(KeyboardSensor); const sensors = useSensors(mouseSensor, keyboardSensor); const { t } = useTranslation(); const { formUuid } = useParams<{ formUuid: string }>(); const isEditingExistingForm = Boolean(formUuid); const initializeSchema = useCallback(() => { const dummySchema: FormSchema = { name: '', pages: [], processor: 'EncounterFormProcessor', encounterType: '', referencedForms: [], uuid: '', }; if (!schema) { onSchemaChange({ ...dummySchema }); } return schema || dummySchema; }, [onSchemaChange, schema]); const launchNewFormModal = useCallback(() => { const schema = initializeSchema(); const dispose = showModal('new-form-modal', { closeModal: () => dispose(), schema, onSchemaChange, }); }, [onSchemaChange, initializeSchema]); const launchAddPageModal = useCallback(() => { const dispose = showModal('new-page-modal', { closeModal: () => dispose(), schema, onSchemaChange, }); }, [schema, onSchemaChange]); const launchDeletePageModal = useCallback( (pageIndex: number) => { const dispose = showModal('delete-page-modal', { closeModal: () => dispose(), onSchemaChange, schema, pageIndex, }); }, [onSchemaChange, schema], ); const launchAddSectionModal = useCallback( (pageIndex: number) => { const dispose = showModal('new-section-modal', { closeModal: () => dispose(), pageIndex, schema, onSchemaChange, }); }, [schema, onSchemaChange], ); const launchEditSectionModal = useCallback( (pageIndex: number, sectionIndex: number) => { const modalType = 'edit'; const dispose = showModal('new-section-modal', { closeModal: () => dispose(), pageIndex, sectionIndex, schema, onSchemaChange, modalType, }); }, [onSchemaChange, schema], ); const launchDeleteSectionModal = useCallback( (pageIndex: number, sectionIndex: number) => { const dispose = showModal('delete-section-modal', { closeModal: () => dispose(), pageIndex, sectionIndex, schema, onSchemaChange, }); }, [onSchemaChange, schema], ); const launchAddFormReferenceModal = useCallback( (pageIndex: number, mode?: string, sectionIndex?: number) => { const dispose = showModal('add-form-reference-modal', { closeModal: () => dispose(), pageIndex, schema, onSchemaChange, mode, sectionIndex, }); }, [onSchemaChange, schema], ); const launchAddQuestionModal = useCallback( (pageIndex: number, sectionIndex: number) => { const dispose = showModal('question-modal', { closeModal: () => dispose(), onSchemaChange, schema, pageIndex, sectionIndex, }); }, [onSchemaChange, schema], ); const renameSchema = useCallback( (value: string) => { try { if (value) { schema.name = value; } onSchemaChange({ ...schema }); showSnackbar({ title: t('success', 'Success!'), kind: 'success', isLowContrast: true, subtitle: t('formRenamed', 'Form renamed'), }); } catch (error) { showSnackbar({ title: t('errorRenamingForm', 'Error renaming form'), kind: 'error', subtitle: error?.message, }); } }, [onSchemaChange, schema, t], ); const renamePage = useCallback( (name: string, pageIndex: number) => { try { if (name) { schema.pages[pageIndex].label = name; } onSchemaChange({ ...schema }); showSnackbar({ title: t('success', 'Success!'), kind: 'success', isLowContrast: true, subtitle: t('pageRenamed', 'Page renamed'), }); } catch (error) { if (error instanceof Error) { showSnackbar({ title: t('errorRenamingPage', 'Error renaming page'), kind: 'error', subtitle: error?.message, }); } } }, [onSchemaChange, schema, t], ); const renameSection = useCallback( (name: string, pageIndex: number, sectionIndex: number) => { try { if (name) { schema.pages[pageIndex].sections[sectionIndex].label = name; } onSchemaChange({ ...schema }); showSnackbar({ title: t('success', 'Success!'), kind: 'success', isLowContrast: true, subtitle: t('sectionRenamed', 'Section renamed'), }); } catch (error) { if (error instanceof Error) { showSnackbar({ title: t('errorRenamingSection', 'Error renaming section'), kind: 'error', subtitle: error?.message, }); } } }, [onSchemaChange, schema, t], ); const duplicateQuestion = useCallback( (question: FormField, pageId: number, sectionId: number, questionId?: number) => { try { const questionToDuplicate: FormField = JSON.parse(JSON.stringify(question)); questionToDuplicate.id = questionToDuplicate.id + 'Duplicate'; if (Number.isInteger(questionId)) { schema.pages[pageId].sections[sectionId].questions[questionId].questions.push(questionToDuplicate); } else { schema.pages[pageId].sections[sectionId].questions.push(questionToDuplicate); } onSchemaChange({ ...schema }); showSnackbar({ title: t('success', 'Success!'), kind: 'success', isLowContrast: true, subtitle: t( 'questionDuplicated', "Question duplicated. Please change the duplicated question's ID to a unique, camelcased value", ), }); } catch (error) { if (error instanceof Error) { showSnackbar({ title: t('errorDuplicatingQuestion', 'Error duplicating question'), kind: 'error', subtitle: error?.message, }); } } }, [onSchemaChange, schema, t], ); const handleDragStart = (event) => { setActiveQuestion(event.active.data.current?.question); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over) return; const activeId = active.id; const overId = over.id; const activeQuestion = active.data.current as DragQuestionData; const overQuestion = over.data.current as DragQuestionData; if (activeId === overId) return; if (activeQuestion.type === 'question' && overQuestion.type === 'obsQuestion') return; if ( activeQuestion.type === 'obsQuestion' && (activeQuestion.question.sectionIndex !== overQuestion.question.sectionIndex || activeQuestion.question.pageIndex !== overQuestion.question.pageIndex || activeQuestion.question.questionIndex !== overQuestion.question.questionIndex) ) return; const newSchema = moveQuestion(schema, activeQuestion, overQuestion, overId); if (!newSchema) { console.warn('Drag-and-drop: move failed, aborting'); return; } onSchemaChange(newSchema); setActiveQuestion(null); }; const getAnswerErrors = (answers: Array>) => { const answerLabels = answers?.map((answer) => answer.label) || []; const errors: Array = validationResponse.filter((error) => answerLabels?.includes(error.field.label), ); return errors || []; }; const getValidationError = (question: FormField) => { const errorField: ValidationError = validationResponse.find( (error) => error.field.label === question.label && error.field.id === question.id && error.field.type === question.type, ); return errorField?.errorMessage || ''; }; const ObsGroupSubQuestions = ({ question, pageIndex, sectionIndex, questionIndex }: SubQuestionProps) => { return (
{question.questions.map((qn, qnIndex) => { return ( ); })}
); }; return (
{isLoading ? : null} {schema?.name && ( <>

{t('welcomeHeading', 'Welcome to the Interactive Schema builder')}

{t( 'welcomeExplainer', 'Add pages, sections and questions to your form. The Preview tab automatically updates as you build your form. For a detailed explanation of what constitutes an OpenMRS form schema, please read through the ', )}{' '} {t('formBuilderDocs', 'form builder documentation')}.

renameSchema(name)} />
)} {!isEditingExistingForm && !schema?.name && (

{t( 'interactiveBuilderHelperText', 'The Interactive Builder lets you build your form schema without writing JSON code. The Preview tab automatically updates as you build your form. When done, click Save Form to save your form.', )}

)} [...rectIntersection(args), ...closestCorners(args), ...pointerWithin(args)]} onDragStart={handleDragStart} onDragEnd={(event: DragEndEvent) => handleDragEnd(event)} sensors={sensors} > page?.sections?.flatMap((section) => section?.questions?.map((qn) => qn.id) || []) || [], ) || [] } > {schema?.pages?.length ? schema.pages.map((page, pageIndex) => (
renamePage(name, pageIndex)} />
launchDeletePageModal(pageIndex)} size="md" >
{page?.sections?.length ? (

{t( 'expandSectionExplainer', 'Below are the sections linked to this page. Expand each section to add questions to it.', )}

) : null} {page?.sections?.length ? ( page.sections?.map((section, sectionIndex) => ( <>

{section.label}

section.reference ? launchAddFormReferenceModal(pageIndex, 'edit', sectionIndex) : launchEditSectionModal(pageIndex, sectionIndex) } size="md" >
launchDeleteSectionModal(pageIndex, sectionIndex)} size="md" >
{section.questions?.length ? ( section.questions.map((question, questionIndex) => { return (
{getValidationError(question) && (
{getValidationError(question)}
)} {getAnswerErrors(question.questionOptions.answers)?.length ? (
{t('answerErrors', 'Answer Errors')}
{getAnswerErrors(question.questionOptions.answers)?.map((error, index) => (
{`${error.field.label}: ${error.errorMessage}`}
))}
) : null}
); }) ) : section.reference ? (

{t( 'sectionReferenceExplainer', 'This section is a reference to another form. Modify the referenced form to add questions to this section.', )}

) : (

{t( 'sectionExplainer', 'A section will typically contain one or more questions. Click the button below to add a question to this section.', )}

)}
)) ) : (

{t( 'pageExplainer', 'Pages typically have one or more sections. Click the button below to add a section to your page.', )}

)}
)) : null} {activeQuestion ? (
) : null}
); }; export default InteractiveBuilder;