diff --git a/src/components/form-editor/form-editor.component.tsx b/src/components/form-editor/form-editor.component.tsx index 90162c13..ce43e9ac 100644 --- a/src/components/form-editor/form-editor.component.tsx +++ b/src/components/form-editor/form-editor.component.tsx @@ -48,6 +48,11 @@ interface MarkerProps extends IMarker { text: string; } +interface SelectedQuestion { + questionId: string; + sectionLabel: string; +} + type Status = 'idle' | 'formLoaded' | 'schemaLoaded'; const ErrorNotification = ({ error, title }: ErrorProps) => { @@ -63,6 +68,7 @@ const ErrorNotification = ({ error, title }: ErrorProps) => { }; const FormEditorContent: React.FC = ({ t }) => { + const [selectedQuestion, setSelectedQuestion] = useState(null); const defaultEnterDelayInMs = 300; const { formUuid } = useParams<{ formUuid: string }>(); const { blockRenderingWithErrors, dataTypeToRenderingMap } = useConfig(); @@ -276,6 +282,12 @@ const FormEditorContent: React.FC = ({ t }) => { const responsiveSize = isMaximized ? 16 : 8; + const [scrollToString, setScrollToString] = useState(''); + + const resetScrollToString = useCallback(() => { + setScrollToString(''); + }, []); + return (
= ({ t }) => { setValidationOn={setValidationOn} stringifiedSchema={stringifiedSchema} validationOn={validationOn} + scrollToString={scrollToString} + onScrollComplete={resetScrollToString} + setSelectedQuestion={setSelectedQuestion} />
@@ -407,6 +422,8 @@ const FormEditorContent: React.FC = ({ t }) => { onSchemaChange={updateSchema} isLoading={isLoadingFormOrSchema} validationResponse={validationResponse} + setScrollToString={setScrollToString} + selectedQuestion={selectedQuestion} /> {form && } diff --git a/src/components/interactive-builder/draggable-question.component.tsx b/src/components/interactive-builder/draggable-question.component.tsx index 350b572b..e6e91676 100644 --- a/src/components/interactive-builder/draggable-question.component.tsx +++ b/src/components/interactive-builder/draggable-question.component.tsx @@ -72,6 +72,7 @@ const DraggableQuestion: React.FC = ({ return (
void; schema: Schema; validationResponse: Array; + setScrollToString: (scrollToString: string) => void; + selectedQuestion: SelectedQuestion; } const InteractiveBuilder: React.FC = ({ @@ -31,7 +34,73 @@ const InteractiveBuilder: React.FC = ({ onSchemaChange, schema, validationResponse, + setScrollToString, + selectedQuestion = { questionId: '', sectionLabel: '' } }) => { + const accordionRefs = useRef(new Map()); + const [selectedQuestionId, setSelectedQuestionId] = useState(null); + const questionRefs = useRef>(new Map()); + + const scrollToElementByIdAndOpenAccordion = useCallback((id: string, secondId: string) => { + const element = document.getElementById(id); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + const button = element.querySelector('button[type="button"].cds--accordion__heading'); + if (button && button instanceof HTMLButtonElement) { + setTimeout(() => { + const ariaExpanded = button.getAttribute('aria-expanded'); + if (ariaExpanded === 'false') { + button.setAttribute('aria-expanded', 'true'); + button.click(); + } + const nestedElement = document.getElementById(secondId); + setTimeout(() => { + if (nestedElement) { + nestedElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 100); + }, 100); + } + } + }, []); + + useEffect(() => { + if (selectedQuestion) { + const { questionId, sectionLabel } = selectedQuestion; + scrollToElementByIdAndOpenAccordion(sectionLabel, questionId); + } + }, [scrollToElementByIdAndOpenAccordion, selectedQuestion]) + + useEffect(() => { + const handleDoubleClickOutside = () => { + const highlightedElement = document.querySelector('[class*="highlightedBorder"]'); + if (highlightedElement) { + highlightedElement.classList.forEach(className => { + if (className.includes('highlightedBorder')) { + highlightedElement.classList.remove(className); + } + }); + } + }; + document.addEventListener('dblclick', handleDoubleClickOutside); + return () => { + document.removeEventListener('dblclick', handleDoubleClickOutside); + }; + }, []); + + + useEffect(() => { + if (selectedQuestionId && questionRefs.current.has(selectedQuestionId)) { + const questionElement = questionRefs.current.get(selectedQuestionId); + questionElement?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, [selectedQuestionId]); + + const handleQuestionSelect = (questionId: string) => { + setSelectedQuestionId(questionId); + setScrollToString(questionId) + }; + const mouseSensor = useSensor(MouseSensor, { activationConstraint: { distance: 10, // Enable sort function when dragging 10px 💡 here!!! @@ -411,8 +480,15 @@ const InteractiveBuilder: React.FC = ({ ) : null} {page?.sections?.length ? ( page.sections?.map((section, sectionIndex) => ( - - + + { + if (el) { + accordionRefs.current.set(sectionIndex.toString(), el); + } + }} + > <>
@@ -441,17 +517,25 @@ const InteractiveBuilder: React.FC = ({ id={`droppable-question-${pageIndex}-${sectionIndex}-${questionIndex}`} key={questionIndex} > - + onClick={() => handleQuestionSelect(question.id)} + className={classNames({ + [styles.selectedQuestion]: true, + [styles.highlightedBorder]: (question.id === selectedQuestion?.questionId), + })} + > + +
{getValidationError(question) && (
{getValidationError(question)} diff --git a/src/components/interactive-builder/interactive-builder.scss b/src/components/interactive-builder/interactive-builder.scss index 4c5f089d..5f96af4f 100644 --- a/src/components/interactive-builder/interactive-builder.scss +++ b/src/components/interactive-builder/interactive-builder.scss @@ -116,4 +116,19 @@ margin-left: 2rem; margin-top: 1rem; font-size: 0.75rem; -} \ No newline at end of file +} + +.selectedQuestion { + cursor: pointer; + border: 2px solid transparent; + transition: border 0.3s ease; + outline: none; +} + +.selectedQuestion:active { + border: 2px solid blue; +} + +.selectedQuestion.highlightedBorder { + border: 2px solid blue; +} diff --git a/src/components/interactive-builder/interactive-builder.test.tsx b/src/components/interactive-builder/interactive-builder.test.tsx index 9cf94bcd..ee861790 100644 --- a/src/components/interactive-builder/interactive-builder.test.tsx +++ b/src/components/interactive-builder/interactive-builder.test.tsx @@ -107,6 +107,8 @@ function renderInteractiveBuilder(props = {}) { onSchemaChange: jest.fn(), schema: {} as Schema, validationResponse: [], + setScrollToString: jest.fn(), + selectedQuestion: { questionId: '', sectionLabel: '' }, }; render(); diff --git a/src/components/schema-editor/schema-editor.component.tsx b/src/components/schema-editor/schema-editor.component.tsx index 48effb08..1b33451e 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'; @@ -9,6 +9,7 @@ import Ajv from 'ajv'; import debounce from 'lodash-es/debounce'; import { ActionableNotification, Link } from '@carbon/react'; import { ChevronRight, ChevronLeft } from '@carbon/react/icons'; +import type { Schema, SelectedQuestion } from '../../types'; import styles from './schema-editor.scss'; @@ -24,6 +25,9 @@ interface SchemaEditorProps { errors: Array; setErrors: (errors: Array) => void; setValidationOn: (validationStatus: boolean) => void; + scrollToString: string; + onScrollComplete: () => void; + setSelectedQuestion: (selectedQn: SelectedQuestion) => void; } const SchemaEditor: React.FC = ({ @@ -33,7 +37,65 @@ const SchemaEditor: React.FC = ({ errors, validationOn, setValidationOn, + scrollToString, + onScrollComplete, + setSelectedQuestion }) => { + const editorRef = useRef(null); + + const handleEditorClick = useCallback((e: any) => { + if (!editorRef.current) return; + const editor = editorRef.current.editor; + const position = e.getDocumentPosition(); + const row: number = position.row; + const lineContent = editor.session.getLine(row); + + try { + const parsedJson: Schema = JSON.parse(stringifiedSchema); + + const findQuestionInPages = (pages) => { + for (const page of pages) { + if (page.sections && Array.isArray(page.sections)) { + for (let sectionPosition = 0; sectionPosition < page.sections.length; sectionPosition++) { + const section = page.sections[sectionPosition]; + if (section.questions && Array.isArray(section.questions)) { + for (const question of section.questions) { + if (question.label && lineContent.includes(`"label": "${question.label}"`)) { + return { questionId: question.id, sectionLabel: `${section.label}-${sectionPosition}` }; + } + } + } + } + } + } + return null; + }; + + const foundQuestion = findQuestionInPages(parsedJson.pages || []); + if (foundQuestion) { + setSelectedQuestion(foundQuestion); + } + } catch (error) { + console.error('Error parsing JSON:', error); + } + },[setSelectedQuestion, stringifiedSchema]); + + useEffect(() => { + const currentEditorRef = editorRef.current; + + if (currentEditorRef) { + const editor = currentEditorRef.editor; + editor.on("click", handleEditorClick); + } + + return () => { + if (currentEditorRef) { + const editor = currentEditorRef.editor; + editor.off("click", handleEditorClick); + } + }; + }, [stringifiedSchema, handleEditorClick]); + const { schema, schemaProperties } = useStandardFormSchema(); const { t } = useTranslation(); const [autocompleteSuggestions, setAutocompleteSuggestions] = useState< @@ -41,6 +103,47 @@ const SchemaEditor: React.FC = ({ >([]); const [currentIndex, setCurrentIndex] = useState(0); + const schemaString = useMemo(() => { + try { + return JSON.parse(stringifiedSchema) as Schema; + } catch (error) { + console.error('Error parsing schema:', error); + return null; + } + }, [stringifiedSchema]); + + + useEffect(() => { + const getLabelByQuestionId = (id: string): string | null => { + if (!schemaString) return null; + + for (const page of schemaString.pages || []) { + for (const section of page.sections || []) { + const question = section.questions?.find( + q => q.id === id + ); + + if (question) { + return (question.label ?? question.value) as string ?? null; + } + } + } + return null; + + }; + if (scrollToString && editorRef.current) { + const editor = editorRef.current.editor; + const lines = editor.getSession().getDocument().getAllLines(); + const lineIndex = lines.findIndex((line) => line.includes(getLabelByQuestionId(scrollToString))); + + if (lineIndex !== -1) { + editor.scrollToLine(lineIndex, true, true, () => {}); + editor.gotoLine(lineIndex + 1, 0, true); + onScrollComplete(); + } + } + }, [scrollToString, onScrollComplete, schemaString]); + // Enable autocompletion in the schema const generateAutocompleteSuggestions = useCallback(() => { const suggestions: Array<{ name: string; type: string; path: string }> = []; @@ -228,6 +331,7 @@ const SchemaEditor: React.FC = ({
{errors.length && validationOn ? : null}