Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(feat) O3-3980: Synchronize Highlighting Between Interactive Builder and Schema Editor #379

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/components/form-editor/form-editor.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -63,6 +68,7 @@ const ErrorNotification = ({ error, title }: ErrorProps) => {
};

const FormEditorContent: React.FC<TranslationFnProps> = ({ t }) => {
const [selectedQuestion, setSelectedQuestion] = useState<SelectedQuestion | null>(null);
const defaultEnterDelayInMs = 300;
const { formUuid } = useParams<{ formUuid: string }>();
const { blockRenderingWithErrors, dataTypeToRenderingMap } = useConfig<ConfigObject>();
Expand Down Expand Up @@ -276,6 +282,12 @@ const FormEditorContent: React.FC<TranslationFnProps> = ({ t }) => {

const responsiveSize = isMaximized ? 16 : 8;

const [scrollToString, setScrollToString] = useState<string>('');

const resetScrollToString = useCallback(() => {
setScrollToString('');
}, []);

return (
<div className={styles.container}>
<Grid
Expand Down Expand Up @@ -369,6 +381,9 @@ const FormEditorContent: React.FC<TranslationFnProps> = ({ t }) => {
setValidationOn={setValidationOn}
stringifiedSchema={stringifiedSchema}
validationOn={validationOn}
scrollToString={scrollToString}
onScrollComplete={resetScrollToString}
setSelectedQuestion={setSelectedQuestion}
/>
</div>
</div>
Expand Down Expand Up @@ -407,6 +422,8 @@ const FormEditorContent: React.FC<TranslationFnProps> = ({ t }) => {
onSchemaChange={updateSchema}
isLoading={isLoadingFormOrSchema}
validationResponse={validationResponse}
setScrollToString={setScrollToString}
selectedQuestion={selectedQuestion}
/>
</TabPanel>
<TabPanel>{form && <AuditDetails form={form} key={form.uuid} />}</TabPanel>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const DraggableQuestion: React.FC<DraggableQuestionProps> = ({

return (
<div
id={question.id}
className={classNames({
[styles.dragContainer]: true,
[styles.dragContainerWhenDragging]: isDragging,
Expand Down
112 changes: 98 additions & 14 deletions src/components/interactive-builder/interactive-builder.component.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import React, { useCallback } from 'react';
import React, { useCallback, useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { DragEndEvent } from '@dnd-kit/core';
import classNames from 'classnames';
import { DndContext, KeyboardSensor, MouseSensor, closestCorners, useSensor, useSensors } from '@dnd-kit/core';
import { Accordion, AccordionItem, Button, IconButton, InlineLoading } from '@carbon/react';
import { Add, TrashCan } from '@carbon/react/icons';
import { useParams } from 'react-router-dom';
import { showModal, showSnackbar } from '@openmrs/esm-framework';
import type { FormSchema } from '@openmrs/esm-form-engine-lib';
import type { Schema, Question } from '../../types';
import type { Schema, Question, SelectedQuestion } from '../../types';
import DraggableQuestion from './draggable-question.component';
import Droppable from './droppable-container.component';
import EditableValue from './editable-value.component';
Expand All @@ -24,14 +25,82 @@ interface InteractiveBuilderProps {
onSchemaChange: (schema: Schema) => void;
schema: Schema;
validationResponse: Array<ValidationError>;
setScrollToString: (scrollToString: string) => void;
selectedQuestion: SelectedQuestion;
}

const InteractiveBuilder: React.FC<InteractiveBuilderProps> = ({
isLoading,
onSchemaChange,
schema,
validationResponse,
setScrollToString,
selectedQuestion = { questionId: '', sectionLabel: '' }
}) => {
const accordionRefs = useRef(new Map());
const [selectedQuestionId, setSelectedQuestionId] = useState<string | null>(null);
const questionRefs = useRef<Map<string, HTMLDivElement>>(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!!!
Expand Down Expand Up @@ -411,8 +480,15 @@ const InteractiveBuilder: React.FC<InteractiveBuilderProps> = ({
) : null}
{page?.sections?.length ? (
page.sections?.map((section, sectionIndex) => (
<Accordion key={sectionIndex}>
<AccordionItem title={section.label}>
<Accordion id={`${section.label}-${sectionIndex}`} key={sectionIndex}>
<AccordionItem
title={section.label}
ref={(el) => {
if (el) {
accordionRefs.current.set(sectionIndex.toString(), el);
}
}}
>
<>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div className={styles.editorContainer}>
Expand Down Expand Up @@ -441,17 +517,25 @@ const InteractiveBuilder: React.FC<InteractiveBuilderProps> = ({
id={`droppable-question-${pageIndex}-${sectionIndex}-${questionIndex}`}
key={questionIndex}
>
<DraggableQuestion
handleDuplicateQuestion={duplicateQuestion}
<div
key={question.id}
onSchemaChange={onSchemaChange}
pageIndex={pageIndex}
question={question}
questionCount={section.questions.length}
questionIndex={questionIndex}
schema={schema}
sectionIndex={sectionIndex}
/>
onClick={() => handleQuestionSelect(question.id)}
className={classNames({
[styles.selectedQuestion]: true,
[styles.highlightedBorder]: (question.id === selectedQuestion?.questionId),
})}
>
<DraggableQuestion
handleDuplicateQuestion={duplicateQuestion}
onSchemaChange={onSchemaChange}
pageIndex={pageIndex}
question={question}
questionCount={section.questions.length}
questionIndex={questionIndex}
schema={schema}
sectionIndex={sectionIndex}
/>
</div>
{getValidationError(question) && (
<div className={styles.validationErrorMessage}>
{getValidationError(question)}
Expand Down
17 changes: 16 additions & 1 deletion src/components/interactive-builder/interactive-builder.scss
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,19 @@
margin-left: 2rem;
margin-top: 1rem;
font-size: 0.75rem;
}
}

.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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ function renderInteractiveBuilder(props = {}) {
onSchemaChange: jest.fn(),
schema: {} as Schema,
validationResponse: [],
setScrollToString: jest.fn(),
selectedQuestion: { questionId: '', sectionLabel: '' },
};

render(<InteractiveBuilder {...defaultProps} {...props} />);
Expand Down
106 changes: 105 additions & 1 deletion src/components/schema-editor/schema-editor.component.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand All @@ -24,6 +25,9 @@ interface SchemaEditorProps {
errors: Array<MarkerProps>;
setErrors: (errors: Array<MarkerProps>) => void;
setValidationOn: (validationStatus: boolean) => void;
scrollToString: string;
onScrollComplete: () => void;
setSelectedQuestion: (selectedQn: SelectedQuestion) => void;
}

const SchemaEditor: React.FC<SchemaEditorProps> = ({
Expand All @@ -33,14 +37,113 @@ const SchemaEditor: React.FC<SchemaEditorProps> = ({
errors,
validationOn,
setValidationOn,
scrollToString,
onScrollComplete,
setSelectedQuestion
}) => {
const editorRef = useRef<AceEditor>(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<
Array<{ name: string; type: string; path: string }>
>([]);
const [currentIndex, setCurrentIndex] = useState<number>(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 }> = [];
Expand Down Expand Up @@ -228,6 +331,7 @@ const SchemaEditor: React.FC<SchemaEditorProps> = ({
<div>
{errors.length && validationOn ? <ErrorMessages /> : null}
<AceEditor
ref={editorRef}
style={{ height: '100vh', width: '100%', border: errors.length ? '3px solid #DA1E28' : 'none' }}
mode="json"
theme="textmate"
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,3 +273,8 @@ export interface DatePickerTypeOption {
label: string;
defaultChecked: boolean;
}

export interface SelectedQuestion {
questionId: string;
sectionLabel: string;
}
Loading