diff --git a/e2e/pages/form-builder-page.ts b/e2e/pages/form-builder-page.ts index d39ba95f3..9436c5a38 100644 --- a/e2e/pages/form-builder-page.ts +++ b/e2e/pages/form-builder-page.ts @@ -23,40 +23,71 @@ export class FormBuilderPage { readonly formEncounterType = () => this.page.getByRole('combobox', { name: /encounter type/i }); readonly formSaveButton = () => this.page.getByRole('dialog').getByRole('button', { name: /save/i }); + readonly pageWrapper = () => this.page.getByTestId('page-wrapper'); + readonly sectionWrapper = () => this.page.getByTestId('section-wrapper'); + readonly questionWrapper = () => this.page.getByTestId('question-wrapper'); + readonly previewTab = () => this.page.getByRole('tab', { name: /preview/i }); readonly interactiveBuilderTab = () => this.page.getByRole('tab', { name: /interactive builder/i }); - readonly startBuildingButton = () => this.page.getByRole('button', { name: /start building/i }); + readonly startBuildingButton = () => this.page.getByRole('button', { name: /start building/i }).first(); readonly interactiveFormNameInput = () => this.page.getByRole('textbox', { name: /form name/i }); readonly interactiveFormDescriptionInput = () => this.page.getByRole('textbox', { name: /form description/i }); readonly createFormButton = () => this.page.getByRole('button', { name: /create form/i }); readonly editFormNameInput = () => this.page.locator('#formNameInput'); - readonly addPageButton = () => this.page.getByRole('button', { name: /add page/i }); + readonly addPageButton = () => this.page.getByRole('button', { name: /add page/i }).first(); readonly pageNameInput = () => this.page.getByRole('textbox', { name: /enter a title for your new page/i, }); - readonly editPageButton = () => this.page.getByRole('button', { name: /edit page/i }); + readonly editPageButton = () => + this.pageWrapper() + .first() + .getByRole('button', { name: /^edit page$/i }); readonly editPageNameInput = () => this.page.locator('#pageNameInput'); - readonly deletePageButton = () => this.page.getByRole('button', { name: /delete page/i }); + readonly deletePageButton = () => + this.pageWrapper() + .first() + .getByRole('button', { name: /^delete page$/i }); + readonly saveButton = () => this.page.getByRole('button', { name: /^save$/i, exact: true }); + readonly pageCreatedMessage = () => this.page.getByText(/new page created/i); - readonly addSectionButton = () => this.page.getByRole('button', { name: /add section/i }); + readonly addSectionButton = () => this.page.getByRole('button', { name: /add section/i }).first(); readonly sectionNameInput = () => this.page.getByRole('textbox', { name: /enter a section title/i }); readonly isExpandedCheckbox = () => this.page.getByTestId('keep-section-expanded-checkbox'); - readonly editSectionButton = () => this.page.getByRole('button', { name: /edit section/i }); + + readonly editSectionButton = () => + this.sectionWrapper() + .first() + .getByRole('button', { name: /^edit section$/i }); readonly editSectionNameInput = () => this.page.locator('#sectionNameInput'); - readonly deleteSectionButton = () => this.page.getByRole('button', { name: /delete section/i }); + readonly deleteSectionButton = () => + this.sectionWrapper() + .first() + .getByRole('button', { name: /^delete section$/i }); + readonly sectionCreatedMessage = () => this.page.getByText(/new section created/i); readonly addReferenceButton = () => this.page.getByRole('button', { name: /add reference/i }); readonly selectFormDropdown = () => this.page.getByRole('combobox', { name: /Select form/i }); readonly selectFormPageDropdown = () => this.page.getByRole('combobox', { name: /pages:$/i }); readonly selectQuestionsCheckbox = () => this.page.getByRole('group', { name: /Select questions/i }); readonly addButton = () => this.page.getByRole('button', { name: /^add$/i }); - readonly addQuestionButton = () => this.page.getByRole('button', { name: /add question/i }); - readonly editQuestionButton = () => this.page.getByRole('button', { name: /edit question/i }); - readonly duplicateQuestionButton = () => this.page.getByRole('button', { name: /duplicate question/i }); - readonly deleteQuestionButton = () => this.page.getByRole('button', { name: /delete question/i }).nth(0); - readonly questionLabelInput = () => this.page.locator('#questionLabel'); + readonly addQuestionButton = () => this.page.getByRole('button', { name: /add question/i }).first(); + + readonly editQuestionButton = () => + this.questionWrapper() + .first() + .getByRole('button', { name: /^edit question$/i }); + readonly duplicateQuestionButton = () => + this.questionWrapper() + .first() + .getByRole('button', { name: /^duplicate question$/i }); + readonly deleteQuestionButton = () => + this.questionWrapper() + .first() + .getByRole('button', { name: /^delete question$/i }); + + readonly questionLabelInput = () => this.page.locator('#questionLabel').first(); readonly questionTypeDropdown = () => this.page.getByRole('combobox', { name: /question type/i, @@ -68,7 +99,7 @@ export class FormBuilderPage { readonly conceptSearchInput = () => this.page.getByPlaceholder(/search using a concept name or uuid/i); readonly selectAnswersDropdown = () => this.page.getByText(/select answers to display/i); readonly answer = () => this.page.getByRole('menuitem', { name: /tested for covid 19/i }); - readonly questionIdInput = () => this.page.getByRole('textbox', { name: /question id/i }); + readonly questionIdInput = () => this.page.getByRole('textbox', { name: /question id/i }).first(); readonly questionCreatedMessage = () => this.page.getByText(/new question created/i); async gotoFormBuilder() { diff --git a/src/components/interactive-builder/InteractiveElementWrapper.tsx b/src/components/interactive-builder/InteractiveElementWrapper.tsx new file mode 100644 index 000000000..5378204c3 --- /dev/null +++ b/src/components/interactive-builder/InteractiveElementWrapper.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { useInteractiveElement, type InteractiveElementProps } from '../../hooks/useInteractiveElement'; + +interface InteractiveElementWrapperProps extends InteractiveElementProps { + children: React.ReactNode; + className?: string; + style?: React.CSSProperties; + role?: string; + tabIndex?: number; + onKeyDown?: (e: React.KeyboardEvent) => void; + onClick?: (e: React.MouseEvent) => void; +} + +/** + * A wrapper component that automatically handles: + * 1. Generates the correct DOM ID for the builder. + * 2. Checks the selection state. + * 3. Applies the 'builder-highlight' class if selected. + * 4. Handles click events to update the selection (stopping propagation). + */ +export const InteractiveElementWrapper: React.FC = ({ + children, + kind, + label, + pageIndex, + sectionIndex, + questionIndex, + className = '', + style, + role, + tabIndex, + onKeyDown, + onClick: externalOnClick, // Allow passing an extra click handler if absolutely necessary +}) => { + const { id, highlightClass, handleClick } = useInteractiveElement({ + kind, + label, + pageIndex, + sectionIndex, + questionIndex, + }); + + const combinedClickHandler = (e: React.MouseEvent) => { + handleClick(e); + if (externalOnClick) { + externalOnClick(e); + } + }; + + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/interactive-builder/interactive-builder.component.tsx b/src/components/interactive-builder/interactive-builder.component.tsx index 956f8b1a7..c0ee44402 100644 --- a/src/components/interactive-builder/interactive-builder.component.tsx +++ b/src/components/interactive-builder/interactive-builder.component.tsx @@ -19,9 +19,13 @@ import { showModal, showSnackbar } from '@openmrs/esm-framework'; import DraggableQuestion from './draggable/draggable-question.component'; import EditableValue from './editable/editable-value.component'; import type { DragEndEvent } from '@dnd-kit/core'; -import type { FormSchema, FormField } from '@openmrs/esm-form-engine-lib'; +import type { FormSchema, FormField, FormPage, FormSection } from '@openmrs/esm-form-engine-lib'; import type { Schema } from '@types'; import styles from './interactive-builder.scss'; +import { useSelection } from '../../context/selection-context'; +import { getBuilderElementId } from '../../utils/builder-ids'; +import { useBuilderScroll } from '../../hooks/useBuilderScroll'; +import { InteractiveElementWrapper } from './InteractiveElementWrapper'; interface ValidationError { errorMessage?: string; @@ -36,6 +40,63 @@ interface InteractiveBuilderProps { validationResponse: Array; } +interface ObsGroupSubQuestionsProps { + question: FormField; + pageIndex: number; + sectionIndex: number; + questionIndex: number; + duplicateQuestion: (question: FormField, pageId: number, sectionId: number, questionId?: number) => void; + onSchemaChange: (schema: Schema) => void; + schema: Schema; +} + +interface PageElementProps { + page: FormPage; + pageIndex: number; + renamePage: (name: string, pageIndex: number) => void; + launchDeletePageModal: (pageIndex: number) => void; + t: any; + expandedSections: Record; + toggleSection: (pageIndex: number, sectionIndex: number) => void; + launchAddFormReferenceModal: (pageIndex: number, mode?: string, sectionIndex?: number) => void; + launchEditSectionModal: (pageIndex: number, sectionIndex: number) => void; + launchDeleteSectionModal: (pageIndex: number, sectionIndex: number) => void; + launchAddQuestionModal: (pageIndex: number, sectionIndex: number) => void; + duplicateQuestion: (question: FormField, pageId: number, sectionId: number, questionId?: number) => void; + onSchemaChange: (schema: Schema) => void; + schema: Schema; + launchAddSectionModal: (pageIndex: number) => void; + validationResponse: Array; +} + +interface SectionElementProps { + section: FormSection; + pageIndex: number; + sectionIndex: number; + expandedSections: Record; + toggleSection: (pageIndex: number, sectionIndex: number) => void; + launchAddFormReferenceModal: (pageIndex: number, mode?: string, sectionIndex?: number) => void; + launchEditSectionModal: (pageIndex: number, sectionIndex: number) => void; + launchDeleteSectionModal: (pageIndex: number, sectionIndex: number) => void; + launchAddQuestionModal: (pageIndex: number, sectionIndex: number) => void; + duplicateQuestion: (question: FormField, pageId: number, sectionId: number, questionId?: number) => void; + onSchemaChange: (schema: Schema) => void; + schema: Schema; + t: any; + validationResponse: Array; +} + +interface QuestionElementProps { + question: FormField; + pageIndex: number; + sectionIndex: number; + questionIndex: number; + duplicateQuestion: (question: FormField, pageId: number, sectionId: number, questionId?: number) => void; + onSchemaChange: (schema: Schema) => void; + schema: Schema; + validationResponse: Array; +} + interface SubQuestionProps { question: FormField; pageIndex: number; @@ -49,7 +110,40 @@ const InteractiveBuilder: React.FC = ({ schema, validationResponse, }) => { + const { + setSelection, + source, + kind, + pageIndex: selectedPageIndex, + sectionIndex: selectedSectionIndex, + questionIndex: selectedQuestionIndex, + } = useSelection(); const [activeQuestion, setActiveQuestion] = useState(null); + + // State for controlled accordions + const [expandedSections, setExpandedSections] = useState>({}); + + // Sync scroll from editor + useBuilderScroll(); + + // Auto-expand section when selection comes from editor AND it is a question + React.useEffect(() => { + if (source === 'editor' && kind === 'question' && selectedPageIndex !== null && selectedSectionIndex !== null) { + setExpandedSections((prev) => ({ + ...prev, + [`${selectedPageIndex}-${selectedSectionIndex}`]: true, + })); + } + }, [source, kind, selectedPageIndex, selectedSectionIndex]); + + const toggleSection = (pageIndex: number, sectionIndex: number) => { + const key = `${pageIndex}-${sectionIndex}`; + setExpandedSections((prev) => ({ + ...prev, + [key]: !prev[key], + })); + }; + const mouseSensor = useSensor(MouseSensor, { activationConstraint: { distance: 10, // Enable sort function when dragging 10px 💡 here!!!. @@ -292,6 +386,19 @@ const InteractiveBuilder: React.FC = ({ [onSchemaChange, schema, t], ); + const handleSelection = useCallback( + ( + pageIndex: number | null, + sectionIndex: number | null = null, + questionIndex: number | null = null, + label?: string, + kind?: 'page' | 'section' | 'question' | 'form', + ) => { + setSelection(pageIndex, sectionIndex, questionIndex, label, kind, 'builder'); + }, + [setSelection], + ); + const handleDragStart = (event) => { setActiveQuestion(event.active.data.current?.question); }; @@ -380,11 +487,8 @@ const InteractiveBuilder: React.FC = ({ if (overQuestion.type === 'obsQuestion') { const newSchema = { ...schema }; const pages = newSchema.pages; - pages[pageIndex].sections[sectionIndex].questions[overQuestion.question.questionIndex].questions.splice( - overQuestion.question.subQuestionIndex, - 0, - activeQuestion.question.question, - ); + const targetQuestion = pages[pageIndex].sections[sectionIndex].questions[overQuestion.question.questionIndex]; + targetQuestion.questions.splice(overQuestion.question.subQuestionIndex, 0, activeQuestion.question.question); return newSchema; } } @@ -411,49 +515,16 @@ const InteractiveBuilder: React.FC = ({ 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) => { + if (isLoading || (isEditingExistingForm && !schema)) { return ( -
- {question.questions.map((qn, qnIndex) => { - return ( - - ); - })} +
+
); - }; + } return (
- {isLoading ? : null} - {schema?.name && ( <>
@@ -482,7 +553,13 @@ const InteractiveBuilder: React.FC = ({ {t('addPage', 'Add Page')}
-
+
{ + e.stopPropagation(); + handleSelection(null, null, null, schema.name, '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) || []) || [], - ) || [] - } + + [...rectIntersection(args), ...closestCorners(args), ...pointerWithin(args)]} + onDragStart={handleDragStart} + onDragEnd={(event: DragEndEvent) => handleDragEnd(event)} + sensors={sensors} > - {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 ? ( -
-
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} + page?.sections?.flatMap((section) => section?.questions?.map((qn) => qn.id) || []) || [], + ) || [] + } + > + {schema?.pages?.length + ? schema.pages.map((page, pageIndex) => ( + + )) + : null} + {activeQuestion ? (
@@ -712,10 +639,333 @@ const InteractiveBuilder: React.FC = ({
) : null}
-
-
+ +
); }; export default InteractiveBuilder; + +// --- Modular Sub-Components --- + +function ObsGroupSubQuestions({ + question, + pageIndex, + sectionIndex, + questionIndex, + duplicateQuestion, + onSchemaChange, + schema, +}: ObsGroupSubQuestionsProps) { + return ( +
+ {question.questions.map((qn, qnIndex) => { + return ( + + ); + })} +
+ ); +} + +function PageElement({ + page, + pageIndex, + renamePage, + launchDeletePageModal, + t, + expandedSections, + toggleSection, + launchAddFormReferenceModal, + launchEditSectionModal, + launchDeleteSectionModal, + launchAddQuestionModal, + duplicateQuestion, + onSchemaChange, + schema, + launchAddSectionModal, + validationResponse, +}: PageElementProps) { + return ( + +
+
+ renamePage(name, pageIndex)} + /> +
+ { + e.stopPropagation(); + launchDeletePageModal(pageIndex); + }} + size="md" + > + + +
+ +
+ {page?.sections?.length ? ( +

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

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

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

+ )} +
+ + + +
+ ); +} + +function SectionElement({ + section, + pageIndex, + sectionIndex, + expandedSections, + toggleSection, + launchAddFormReferenceModal, + launchEditSectionModal, + launchDeleteSectionModal, + launchAddQuestionModal, + duplicateQuestion, + onSchemaChange, + schema, + t, + validationResponse, +}: SectionElementProps) { + return ( + + + { + toggleSection(pageIndex, 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) => ( + + )) + ) : 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.', + )} +

+ )} + + +
+
+
+
+ ); +} + +function QuestionElement({ + question, + pageIndex, + sectionIndex, + questionIndex, + duplicateQuestion, + onSchemaChange, + schema, + validationResponse, +}: QuestionElementProps) { + // Helper for validation display + const getValidationError = (question) => { + // Re-implementing logic or assume it is passed? + // The original code had getValidationError in scope. + // Since this is outside the main component, I don't have access to validationResponse from props. + // This is a problem. I need to pass validationResponse to QuestionElement. + 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 getAnswerErrors = (answers: Array>) => { + const answerLabels = answers?.map((answer) => answer.label) || []; + const errors: Array = validationResponse?.filter((error) => + answerLabels?.includes(error.field.label), + ); + return errors || []; + }; + + return ( + + + + + {/* Validation errors would go here */} + {getValidationError(question) && ( +
{getValidationError(question)}
+ )} + {getAnswerErrors(question.questionOptions.answers)?.length ? ( +
+
Answer Errors
+ {getAnswerErrors(question.questionOptions.answers)?.map((error, index) => ( +
{`${error.field.label}: ${ + error.errorMessage + }`}
+ ))} +
+ ) : null} +
+ ); +} diff --git a/src/components/interactive-builder/interactive-builder.scss b/src/components/interactive-builder/interactive-builder.scss index 6ec3efb85..74d6f6245 100644 --- a/src/components/interactive-builder/interactive-builder.scss +++ b/src/components/interactive-builder/interactive-builder.scss @@ -2,11 +2,33 @@ @use '@carbon/layout'; @use '@carbon/type'; +:global(.builder-highlight) { + outline: 1px solid colors.$warm-gray-30 !important; + outline-offset: calc(-1 * layout.$spacing-01); + border-radius: layout.$spacing-02; + position: relative; + z-index: 100 !important; +} + +:global([data-testid$='-wrapper']:not(.builder-highlight)) { + outline: none !important; + + &:focus, + &:focus-visible { + outline: none !important; + } +} + +:global([data-testid$='-wrapper']) { + cursor: pointer; +} + .container { overflow-y: scroll; overflow-x: hidden; height: 95vh; padding-right: layout.$spacing-07; + :global(.cds--modal-content:focus) { outline: none; } diff --git a/src/components/interactive-builder/interactive-builder.test.tsx b/src/components/interactive-builder/interactive-builder.test.tsx index 9cf94bcda..10c5f44bd 100644 --- a/src/components/interactive-builder/interactive-builder.test.tsx +++ b/src/components/interactive-builder/interactive-builder.test.tsx @@ -4,6 +4,7 @@ import { render, screen } from '@testing-library/react'; import { showModal } from '@openmrs/esm-framework'; import { type FormSchema } from '@openmrs/esm-form-engine-lib'; import { type Schema } from '../../types'; +import { SelectionProvider } from '../../context/selection-context'; import InteractiveBuilder from './interactive-builder.component'; const mockShowModal = jest.mocked(showModal); @@ -96,9 +97,45 @@ describe('InteractiveBuilder', () => { expect(screen.getByRole('heading', { name: dummySchema.pages[0].label })).toBeInTheDocument(); expect(screen.getByRole('button', { name: dummySchema.pages[0].sections[0].label })).toBeInTheDocument(); expect(screen.getByRole('button', { name: dummySchema.pages[0].sections[1].label })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /delete page/i })).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: /delete page/i })[0]).toBeInTheDocument(); expect(screen.getByRole('button', { name: /add section/i })).toBeInTheDocument(); }); + + it('renders data-testid attributes for scoped locators', () => { + const dummySchema: FormSchema = { + encounterType: '', + name: 'Sample Form', + processor: 'EncounterFormProcessor', + referencedForms: [], + uuid: '', + version: '1.0', + pages: [ + { + label: 'Page 1', + sections: [ + { + label: 'Section 1', + isExpanded: 'true', + questions: [ + { + id: 'q1', + label: 'Question 1', + type: 'obs', + questionOptions: { rendering: 'text', concept: 'uuid' }, + }, + ], + }, + ], + }, + ], + }; + + renderInteractiveBuilder({ schema: dummySchema }); + + expect(screen.getAllByTestId('page-wrapper').length).toBeGreaterThan(0); + expect(screen.getAllByTestId('section-wrapper').length).toBeGreaterThan(0); + expect(screen.getAllByTestId('question-wrapper').length).toBeGreaterThan(0); + }); }); function renderInteractiveBuilder(props = {}) { @@ -109,5 +146,9 @@ function renderInteractiveBuilder(props = {}) { validationResponse: [], }; - render(); + render( + + + , + ); } diff --git a/src/components/schema-editor/schema-editor.component.tsx b/src/components/schema-editor/schema-editor.component.tsx index 9120d3efb..d6bfa90d9 100644 --- a/src/components/schema-editor/schema-editor.component.tsx +++ b/src/components/schema-editor/schema-editor.component.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import AceEditor from 'react-ace'; import 'ace-builds/webpack-resolver'; import { addCompleter } from 'ace-builds/src-noconflict/ext-language_tools'; @@ -10,6 +10,8 @@ import Ajv from 'ajv'; import debounce from 'lodash-es/debounce'; import { ChevronRight, ChevronLeft } from '@carbon/react/icons'; import styles from './schema-editor.scss'; +import { useSelection } from '../../context/selection-context'; +import { findLineForIndices, getSchemaCursorInfo } from '../../utils/schema-navigation'; interface MarkerProps extends IMarker { text: string; @@ -35,10 +37,29 @@ const SchemaEditor: React.FC = ({ }) => { const { schema, schemaProperties } = useStandardFormSchema(); const { t } = useTranslation(); + const { setSelection, ...selection } = useSelection(); const [autocompleteSuggestions, setAutocompleteSuggestions] = useState< Array<{ name: string; type: string; path: string }> >([]); const [currentIndex, setCurrentIndex] = useState(0); + // Ref to the underlying Ace editor instance so we can read the cursor position + const aceRef = useRef(null); + + // Scroll to selection when it comes from the builder + useEffect(() => { + if (selection.source === 'builder' && aceRef.current) { + const { pageIndex, sectionIndex, questionIndex } = selection; + // We pass the full text from the editor instance + const text = aceRef.current.getValue(); + + const line = findLineForIndices(text, pageIndex, sectionIndex, questionIndex); + + if (line >= 0) { + aceRef.current.gotoLine(line + 1, 0, true); + aceRef.current.scrollToLine(line + 1, true, true, function () {}); + } + } + }, [selection]); // Enable autocompletion in the schema const generateAutocompleteSuggestions = useCallback(() => { @@ -178,6 +199,51 @@ const SchemaEditor: React.FC = ({ debouncedValidateSchema(newValue, schema); }; + /** + * Called once when the Ace editor is created. + * + * Here we: + * - Store the editor instance in a ref. + * - Attach a low‑level DOM 'click' listener on the editor container. + * + * Why a DOM listener instead of React's onClick? + * - react-ace wraps Ace and doesn't directly give us a React click + * with the Ace editor reference. + * - Using the Ace editor's `container` gives us reliable access to + * the real editor and its `selection`. + */ + const handleEditorLoad = useCallback( + (editor) => { + // Keep the editor instance so we can use it later if needed. + aceRef.current = editor; + + editor.container.addEventListener('click', () => { + try { + // 1. Get the current cursor position (row/column in Ace coordinates). + const cursor = editor.selection.getCursor(); + + // 2. Ask our helper to infer which page/section/question + // the cursor is currently inside, based purely on the text. + const info = getSchemaCursorInfo(editor.getValue(), cursor.row, cursor.column); + + if (!info) { + return; + } + + const { kind, pageIndex, sectionIndex, questionIndex } = info; + + setSelection(pageIndex, sectionIndex, questionIndex, undefined, kind, 'editor'); + } catch (e) { + // If anything goes wrong (malformed JSON, unexpected structure), + // we log it instead of blowing up the editor. + // eslint-disable-next-line no-console + console.error('Error computing schema cursor info', e); + } + }); + }, + [setSelection], + ); + // Schema Validation Errors const ErrorNotification = ({ text, line }) => ( = ({
{errors.length && validationOn ? : null} void; +} + +const SelectionContext = createContext(undefined); + +export const SelectionProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [selection, setSelectionState] = useState({ + pageIndex: null, + sectionIndex: null, + questionIndex: null, + }); + + const setSelection = useCallback( + ( + pageIndex: number | null, + sectionIndex: number | null = null, + questionIndex: number | null = null, + label?: string, + kind?: 'page' | 'section' | 'question', + source?: 'editor' | 'builder', + ) => { + setSelectionState({ pageIndex, sectionIndex, questionIndex, label, kind, source }); + }, + [], + ); + + return {children}; +}; + +export const useSelection = (): SelectionContextType => { + const context = useContext(SelectionContext); + if (!context) { + throw new Error('useSelection must be used within a SelectionProvider'); + } + return context; +}; diff --git a/src/hooks/useBuilderScroll.ts b/src/hooks/useBuilderScroll.ts new file mode 100644 index 000000000..427c4cfda --- /dev/null +++ b/src/hooks/useBuilderScroll.ts @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; +import { useSelection } from '../context/selection-context'; +import { getBuilderElementId, type ElementKind } from '../utils/builder-ids'; + +export function useBuilderScroll() { + const { source, kind, pageIndex, sectionIndex, questionIndex } = useSelection(); + + useEffect(() => { + if (kind) { + const elementId = getBuilderElementId(kind as ElementKind, pageIndex, sectionIndex, questionIndex); + if (elementId) { + let attempts = 0; + // Accordion animation is ~250ms-300ms. + // We retry for ~1.5s total (15 attempts * 100ms) to ensure it renders. + const maxAttempts = 15; + + const tryScroll = () => { + const element = document.getElementById(elementId); + if (element && element.offsetParent !== null) { + // Check visibility + // Ensure it's fully rendered + if (source === 'editor') { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } else if (attempts < maxAttempts) { + attempts++; + setTimeout(tryScroll, 100); + } + }; + + tryScroll(); + } + } + }, [source, kind, pageIndex, sectionIndex, questionIndex]); +} diff --git a/src/hooks/useInteractiveElement.ts b/src/hooks/useInteractiveElement.ts new file mode 100644 index 000000000..8a1fc262b --- /dev/null +++ b/src/hooks/useInteractiveElement.ts @@ -0,0 +1,76 @@ +import { useCallback } from 'react'; +import { useSelection } from '../context/selection-context'; +import { getBuilderElementId, type ElementKind } from '../utils/builder-ids'; + +export interface InteractiveElementProps { + kind: ElementKind; + label: string; + pageIndex?: number | null; + sectionIndex?: number | null; + questionIndex?: number | null; +} + +/** + * Hook to manage the interactive state of a builder element (Form, Page, Section, Question). + * + * Convention over Configuration: + * - Automatically generates the standardized DOM ID based on the element coordinates. + * - Determines selection state by comparing coordinates with the global SelectionContext. + * - Provides a unified click handler that enforces event isolation (stopPropagation) and updates selection. + * + * @param props The coordinates and metadata of the element. + * @returns { id, isSelected, handleClick } properties to spread or apply to the UI component. + */ +export function useInteractiveElement({ + kind, + label, + pageIndex = null, + sectionIndex = null, + questionIndex = null, +}: InteractiveElementProps) { + const { + setSelection, + pageIndex: activePageIndex, + sectionIndex: activeSectionIndex, + questionIndex: activeQuestionIndex, + } = useSelection(); + + // 1. Generate Standardized ID + // This adheres to the convention defined in builder-ids.ts + const elementId = getBuilderElementId(kind, pageIndex, sectionIndex, questionIndex); + + // 2. Determine Selection State + // Logic: explicitly check if *all* relevant indices match the active selection. + // We strictly compare nulls to ensure we don't accidentally select a parent when a child is active. + const isSelected = + activePageIndex === pageIndex && activeSectionIndex === sectionIndex && activeQuestionIndex === questionIndex; + + // 3. Unified Interaction Handler + const handleClick = useCallback( + (e: React.MouseEvent | React.KeyboardEvent) => { + // Vital: Stop propagation to prevent selecting the parent container (e.g., Form or Page) + if (e && typeof e.stopPropagation === 'function') { + e.stopPropagation(); + } + + // Allow keyboard support (Enter/Space) if attached to a non-button element + if (e.type === 'keydown') { + const key = (e as React.KeyboardEvent).key; + if (key !== 'Enter' && key !== ' ') { + return; + } + } + + setSelection(pageIndex, sectionIndex, questionIndex, label, kind, 'builder'); + }, + [pageIndex, sectionIndex, questionIndex, label, kind, setSelection], + ); + + return { + id: elementId, + isSelected, + handleClick, + // Helper to conditionally apply the highlight class + highlightClass: isSelected ? 'builder-highlight' : '', + }; +} diff --git a/src/root.component.tsx b/src/root.component.tsx index 05d27e98f..772388bb4 100644 --- a/src/root.component.tsx +++ b/src/root.component.tsx @@ -4,15 +4,19 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom'; import Dashboard from './components/dashboard/dashboard.component'; import FormEditor from './components/form-editor/form-editor.component'; +import { SelectionProvider } from './context/selection-context'; + const RootComponent: React.FC = () => { return ( - - - } /> - } /> - } /> - - + + + + } /> + } /> + } /> + + + ); }; diff --git a/src/utils/builder-ids.ts b/src/utils/builder-ids.ts new file mode 100644 index 000000000..70a068b8b --- /dev/null +++ b/src/utils/builder-ids.ts @@ -0,0 +1,22 @@ +export type ElementKind = 'page' | 'section' | 'question' | 'form'; + +export function getBuilderElementId( + kind: ElementKind | undefined, + pageIndex: number | null, + sectionIndex: number | null = null, + questionIndex: number | null = null, +): string { + if (kind === 'page' && pageIndex !== null) { + return `builder-page-${pageIndex}`; + } + if (kind === 'section' && pageIndex !== null && sectionIndex !== null) { + return `builder-section-${pageIndex}-${sectionIndex}`; + } + if (kind === 'question' && pageIndex !== null && sectionIndex !== null && questionIndex !== null) { + return `builder-question-${pageIndex}-${sectionIndex}-${questionIndex}`; + } + if (kind === 'form') { + return 'builder-form-header'; + } + return ''; +} diff --git a/src/utils/schema-navigation.ts b/src/utils/schema-navigation.ts new file mode 100644 index 000000000..d8a1634e8 --- /dev/null +++ b/src/utils/schema-navigation.ts @@ -0,0 +1,392 @@ +export type Frame = { + type: 'object' | 'array'; + key: string | null; + index: number | null; +}; + +export type CursorKind = 'page' | 'section' | 'question' | 'form'; + +export interface SchemaCursorInfo { + kind: CursorKind; + pageIndex: number | null; + sectionIndex: number | null; + questionIndex: number | null; +} + +/** + * Given the full JSON text and a cursor position (row, column), + * walk the JSON structure and infer the nearest enclosing page/section/question indices. + */ +export function getSchemaCursorInfo(text: string, row: number, column: number): SchemaCursorInfo | null { + if (!text) { + return null; + } + + const lines = text.split('\n'); + if (row < 0 || row >= lines.length) { + return null; + } + + const safeColumn = Math.min(column, lines[row].length); + + let offset = 0; + for (let r = 0; r < row; r++) { + offset += lines[r].length + 1; + } + offset += safeColumn; + + const stack: Frame[] = []; + let inString = false; + let escape = false; + let currentKey: string | null = null; + + const pushObject = () => { + stack.push({ type: 'object', key: currentKey, index: null }); + currentKey = null; + }; + + const pushArray = (key: string | null) => { + stack.push({ type: 'array', key, index: 0 }); + currentKey = null; + }; + + for (let i = 0; i <= offset && i < text.length; i++) { + const ch = text[i]; + + if (escape) { + escape = false; + continue; + } + + if (inString) { + if (ch === '\\') { + escape = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + + if (ch === '"') { + inString = true; + let j = i + 1; + let str = ''; + let localEscape = false; + for (; j < text.length; j++) { + const c2 = text[j]; + if (localEscape) { + str += c2; + localEscape = false; + continue; + } + if (c2 === '\\') { + localEscape = true; + continue; + } + if (c2 === '"') { + break; + } + str += c2; + } + inString = false; + + let k = j + 1; + while (k < text.length && /\s/.test(text[k])) { + k++; + } + if (text[k] === ':') { + currentKey = str; + } + i = j; + continue; + } + + if (ch === '{') { + pushObject(); + } else if (ch === '}') { + while (stack.length && stack[stack.length - 1].type !== 'object') { + stack.pop(); + } + if (stack.length) { + stack.pop(); + } + currentKey = null; + } else if (ch === '[') { + pushArray(currentKey); + } else if (ch === ']') { + while (stack.length && stack[stack.length - 1].type !== 'array') { + stack.pop(); + } + if (stack.length) { + stack.pop(); + } + currentKey = null; + } else if (ch === ',') { + const top = stack[stack.length - 1]; + if (top && top.type === 'array' && top.index !== null) { + top.index += 1; + } + currentKey = null; + } + } + + let pageIndex: number | null = null; + let sectionIndex: number | null = null; + let questionIndex: number | null = null; + + stack.forEach((frame) => { + if (frame.type === 'array' && frame.index !== null) { + if (frame.key === 'pages') { + pageIndex = frame.index; + } else if (frame.key === 'sections') { + sectionIndex = frame.index; + } else if (frame.key === 'questions') { + questionIndex = frame.index; + } + } + }); + + const findNearestByArrayKey = (arrayKey: 'questions' | 'sections' | 'pages') => { + for (let i = stack.length - 1; i >= 0; i--) { + if (stack[i].type !== 'object') { + continue; + } + for (let j = i - 1; j >= 0; j--) { + const parent = stack[j]; + if (parent.type === 'array' && parent.key === arrayKey && parent.index !== null) { + return parent.index; + } + } + } + return null; + }; + + const qIdx = findNearestByArrayKey('questions'); + if (qIdx !== null) { + return { kind: 'question', pageIndex, sectionIndex, questionIndex: qIdx }; + } + + const sIdx = findNearestByArrayKey('sections'); + if (sIdx !== null) { + return { kind: 'section', pageIndex, sectionIndex: sIdx, questionIndex: null }; + } + + const pIdx = findNearestByArrayKey('pages'); + if (pIdx !== null) { + return { kind: 'page', pageIndex: pIdx, sectionIndex: null, questionIndex: null }; + } + + // If we are deep enough to be in an object but not in any known array, + // and checking the stack suggests we are in the root object (depth 1 or 0), + // return form kind. + // Actually, standard stack traversal: if we haven't found a page/section/question, + // we might be at the form level. + // Simplest check: if we are inside the root object (stack[0] is object) and no array parent found. + if (stack.length > 0 && stack[0].type === 'object') { + return { kind: 'form', pageIndex: null, sectionIndex: null, questionIndex: null }; + } + + return null; +} + +/** + * Finds the line number where a specific page, section, or question starts. + * It prioritizes finding the line containing the "label" property of the target object. + */ +export function findLineForIndices( + text: string, + targetPageIndex: number | null, + targetSectionIndex: number | null, + targetQuestionIndex: number | null, +): number { + if (!text) return 0; + + const stack: Frame[] = []; + let inString = false; + let escape = false; + let currentKey: string | null = null; + + const pushObject = () => { + stack.push({ type: 'object', key: currentKey, index: null }); + currentKey = null; + }; + + const pushArray = (key: string | null) => { + stack.push({ type: 'array', key, index: 0 }); + currentKey = null; + }; + + // We need to count newlines as we scan + let lineNumber = 0; + + // State for label searching + let targetFound = false; + let targetStartLine = 0; + let targetStackDepth = 0; + + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + + if (ch === '\n') { + lineNumber++; + } + + if (escape) { + escape = false; + continue; + } + + if (inString) { + if (ch === '\\') { + escape = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + + if (ch === '"') { + inString = true; + let j = i + 1; + let str = ''; + let localEscape = false; + for (; j < text.length; j++) { + const c2 = text[j]; + if (c2 === '\n') lineNumber++; // Track lines inside strings too! + + if (localEscape) { + str += c2; + localEscape = false; + continue; + } + if (c2 === '\\') { + localEscape = true; + continue; + } + if (c2 === '"') { + break; + } + str += c2; + } + inString = false; + + let k = j + 1; + while (k < text.length && /\s/.test(text[k])) { + k++; + } + if (text[k] === ':') { + currentKey = str; + // Optimization: If we are searching for label, and this key is "label", + // and we are at the right depth, return current line! + // For Form (root), we look for "name". For others, we look for "label". + const isFormTarget = targetPageIndex === null && targetSectionIndex === null && targetQuestionIndex === null; + const targetKey = isFormTarget ? 'name' : 'label'; + + if (targetFound && str === targetKey && stack.length === targetStackDepth) { + return lineNumber; + } + } + i = j; + continue; + } + + if (ch === '{') { + pushObject(); + + if (!targetFound) { + // Check if we hit the target + let currentPage: number | null = null; + let currentSection: number | null = null; + let currentQuestion: number | null = null; + let isTarget = false; + + // Check the stack for our indices + stack.forEach((frame) => { + if (frame.type === 'array' && frame.index !== null) { + if (frame.key === 'pages') currentPage = frame.index; + if (frame.key === 'sections') currentSection = frame.index; + if (frame.key === 'questions') currentQuestion = frame.index; + } + }); + + // Does this match the requested target? + if (targetQuestionIndex !== null) { + if ( + currentPage === targetPageIndex && + currentSection === targetSectionIndex && + currentQuestion === targetQuestionIndex && + stack.length >= 2 && + stack[stack.length - 2].type === 'array' && + stack[stack.length - 2].key === 'questions' + ) { + isTarget = true; + } + } else if (targetSectionIndex !== null) { + if ( + currentPage === targetPageIndex && + currentSection === targetSectionIndex && + stack.length >= 2 && + stack[stack.length - 2].type === 'array' && + stack[stack.length - 2].key === 'sections' + ) { + isTarget = true; + } + } else if (targetPageIndex !== null) { + if ( + currentPage === targetPageIndex && + stack.length >= 2 && + stack[stack.length - 2].type === 'array' && + stack[stack.length - 2].key === 'pages' + ) { + isTarget = true; + } + } else { + // Form level target (all indices null) + // If we rely on valid indices for other types, then all-null implies form. + // The root object is the first object pushed (stack length 1). + if (stack.length === 1 && lineNumber >= 0) { + isTarget = true; + } + } + + if (isTarget) { + targetFound = true; + targetStartLine = lineNumber; + targetStackDepth = stack.length; // The object we just pushed is at this depth + } + } + } else if (ch === '}') { + if (targetFound && stack.length === targetStackDepth) { + // We are closing the target object and haven't returned a label line yet. + // Return the start line of the object. + return targetStartLine; + } + + while (stack.length && stack[stack.length - 1].type !== 'object') { + stack.pop(); + } + if (stack.length) { + stack.pop(); + } + currentKey = null; + } else if (ch === '[') { + pushArray(currentKey); + } else if (ch === ']') { + while (stack.length && stack[stack.length - 1].type !== 'array') { + stack.pop(); + } + if (stack.length) { + stack.pop(); + } + currentKey = null; + } else if (ch === ',') { + const top = stack[stack.length - 1]; + if (top && top.type === 'array' && top.index !== null) { + top.index += 1; + } + currentKey = null; + } + } + + return 0; // Not found +}