diff --git a/src/components/renderer/form/form-renderer.component.tsx b/src/components/renderer/form/form-renderer.component.tsx index ea9d5299e..c9e8e61a2 100644 --- a/src/components/renderer/form/form-renderer.component.tsx +++ b/src/components/renderer/form/form-renderer.component.tsx @@ -9,6 +9,8 @@ import { FormProvider, type FormContextProps } from '../../../provider/form-prov import { isTrue } from '../../../utils/boolean-utils'; import { type FormProcessorContextProps } from '../../../types'; import { useFormStateHelpers } from '../../../hooks/useFormStateHelpers'; +import { pageObserver } from '../../sidebar/page-observer'; +import { isPageContentVisible } from '../../../utils/form-helper'; export type FormRendererProps = { processorContext: FormProcessorContextProps; @@ -23,7 +25,10 @@ export const FormRenderer = ({ isSubForm, setIsLoadingFormDependencies, }: FormRendererProps) => { - const { evaluatedFields, evaluatedFormJson } = useEvaluateFormFieldExpressions(initialValues, processorContext); + const { evaluatedFields, evaluatedFormJson, evaluatedPagesVisibility } = useEvaluateFormFieldExpressions( + initialValues, + processorContext, + ); const { registerForm, setIsFormDirty, workspaceLayout, isFormExpanded } = useFormFactory(); const methods = useForm({ defaultValues: initialValues, @@ -50,6 +55,19 @@ export const FormRenderer = ({ setForm, } = useFormStateHelpers(dispatch, formFields); + useEffect(() => { + const scrollablePages = formJson.pages.filter((page) => !page.isSubform).map((page) => page); + pageObserver.updateScrollablePages(scrollablePages); + }, [formJson.pages]); + + useEffect(() => { + pageObserver.setEvaluatedPagesVisibility(evaluatedPagesVisibility); + }, [evaluatedPagesVisibility]); + + useEffect(() => { + pageObserver.updatePagesWithErrors(invalidFields.map((field) => field.meta.pageId)); + }, [invalidFields]); + const context: FormContextProps = useMemo(() => { return { ...processorContext, @@ -80,11 +98,7 @@ export const FormRenderer = ({ return ( {formJson.pages.map((page) => { - const pageHasNoVisibleContent = - page.sections?.every((section) => section.isHidden) || - page.sections?.every((section) => section.questions?.every((question) => question.isHidden)) || - isTrue(page.isHidden); - if (!page.isSubform && pageHasNoVisibleContent) { + if (!page.isSubform && !isPageContentVisible(page)) { return null; } if (page.isSubform && page.subform?.form) { diff --git a/src/components/renderer/page/page.renderer.component.tsx b/src/components/renderer/page/page.renderer.component.tsx index 9061b1531..26faacdb8 100644 --- a/src/components/renderer/page/page.renderer.component.tsx +++ b/src/components/renderer/page/page.renderer.component.tsx @@ -6,9 +6,9 @@ import { SectionRenderer } from '../section/section-renderer.component'; import { Waypoint } from 'react-waypoint'; import styles from './page.renderer.scss'; import { Accordion, AccordionItem } from '@carbon/react'; -import { useFormFactory } from '../../../provider/form-factory-provider'; import { ChevronDownIcon, ChevronUpIcon } from '@openmrs/esm-framework'; import classNames from 'classnames'; +import { pageObserver } from '../../sidebar/page-observer'; interface PageRendererProps { page: FormPage; @@ -24,10 +24,8 @@ interface CollapsibleSectionContainerProps { function PageRenderer({ page, isFormExpanded }: PageRendererProps) { const { t } = useTranslation(); - const pageId = useMemo(() => page.label.replace(/\s/g, ''), [page.label]); const [isCollapsed, setIsCollapsed] = useState(false); - const { setCurrentPage } = useFormFactory(); const visibleSections = useMemo( () => page.sections.filter((section) => { @@ -41,12 +39,21 @@ function PageRenderer({ page, isFormExpanded }: PageRendererProps) { useEffect(() => { setIsCollapsed(!isFormExpanded); + + return () => { + pageObserver.removeInactivePage(page.id); + }; }, [isFormExpanded]); return (
- setCurrentPage(pageId)} topOffset="50%" bottomOffset="60%"> -
+ pageObserver.addActivePage(page.id)} + onLeave={() => pageObserver.removeInactivePage(page.id)} + topOffset="40%" + bottomOffset="40%"> +

{t(page.label)} diff --git a/src/components/sidebar/page-observer.ts b/src/components/sidebar/page-observer.ts new file mode 100644 index 000000000..f6d7c9e64 --- /dev/null +++ b/src/components/sidebar/page-observer.ts @@ -0,0 +1,58 @@ +import { BehaviorSubject } from 'rxjs'; +import { type FormPage } from '../../types'; + +class PageObserver { + private scrollablePagesSubject = new BehaviorSubject>([]); + private pagesWithErrorsSubject = new BehaviorSubject>(new Set()); + private activePagesSubject = new BehaviorSubject>(new Set()); + private evaluatedPagesVisibilitySubject = new BehaviorSubject(null); + + setEvaluatedPagesVisibility(evaluatedPagesVisibility: boolean) { + this.evaluatedPagesVisibilitySubject.next(evaluatedPagesVisibility); + } + + updateScrollablePages(newPages: Array) { + this.scrollablePagesSubject.next(newPages); + } + + updatePagesWithErrors(newErrors: string[]) { + this.pagesWithErrorsSubject.next(new Set(newErrors)); + } + + addActivePage(pageId: string) { + const currentActivePages = this.activePagesSubject.value; + currentActivePages.add(pageId); + this.activePagesSubject.next(currentActivePages); + } + + removeInactivePage(pageId: string) { + const currentActivePages = this.activePagesSubject.value; + currentActivePages.delete(pageId); + this.activePagesSubject.next(currentActivePages); + } + + getActivePagesObservable() { + return this.activePagesSubject.asObservable(); + } + + getScrollablePagesObservable() { + return this.scrollablePagesSubject.asObservable(); + } + + getPagesWithErrorsObservable() { + return this.pagesWithErrorsSubject.asObservable(); + } + + getEvaluatedPagesVisibilityObservable() { + return this.evaluatedPagesVisibilitySubject.asObservable(); + } + + clear() { + this.scrollablePagesSubject.next([]); + this.pagesWithErrorsSubject.next(new Set()); + this.activePagesSubject.next(new Set()); + this.evaluatedPagesVisibilitySubject.next(false); + } +} + +export const pageObserver = new PageObserver(); diff --git a/src/components/sidebar/sidebar.component.tsx b/src/components/sidebar/sidebar.component.tsx index cde388f7e..28451d32a 100644 --- a/src/components/sidebar/sidebar.component.tsx +++ b/src/components/sidebar/sidebar.component.tsx @@ -1,118 +1,79 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React from 'react'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; -import { Button, Toggle } from '@carbon/react'; -import { isEmpty } from '../../validators/form-validator'; -import { type FormPage } from '../../types'; +import { Button } from '@carbon/react'; +import { type SessionMode } from '../../types'; import styles from './sidebar.scss'; -import { scrollIntoView } from '../../utils/form-helper'; +import { usePageObserver } from './usePageObserver'; +import { useCurrentActivePage } from './useCurrentActivePage'; +import { isPageContentVisible } from '../../utils/form-helper'; +import { InlineLoading } from '@carbon/react'; interface SidebarProps { - allowUnspecifiedAll: boolean; defaultPage: string; - handleClose: () => void; - hideFormCollapseToggle: () => void; isFormSubmitting: boolean; - mode: string; + sessionMode: SessionMode; onCancel: () => void; - pagesWithErrors: string[]; - scrollablePages: Set; - selectedPage: string; - setValues: (values: unknown) => void; - values: object; + handleClose: () => void; + hideFormCollapseToggle: () => void; } const Sidebar: React.FC = ({ - allowUnspecifiedAll, defaultPage, - handleClose, - hideFormCollapseToggle, isFormSubmitting, - mode, + sessionMode, onCancel, - pagesWithErrors, - scrollablePages, - selectedPage, - setValues, - values, + handleClose, + hideFormCollapseToggle, }) => { const { t } = useTranslation(); - const pages: Array = Array.from(scrollablePages); - - useEffect(() => { - if (defaultPage && pages.some(({ label, isHidden }) => label === defaultPage && !isHidden)) { - scrollIntoView(joinWord(defaultPage)); - } - }, [defaultPage, scrollablePages]); - - const unspecifiedFields = useMemo( - () => - Object.keys(values).filter( - (key) => key.endsWith('-unspecified') && isEmpty(values[key.split('-unspecified')[0]]), - ), - [values], - ); - - const handleClick = (selected) => { - const activeId = joinWord(selected); - scrollIntoView(activeId); - }; - - const markAllAsUnspecified = useCallback( - (toggled) => { - const updatedValues = { ...values }; - unspecifiedFields.forEach((field) => { - updatedValues[field] = toggled; - }); - setValues(updatedValues); - }, - [unspecifiedFields, values, setValues], - ); + const { pages, pagesWithErrors, activePages, evaluatedPagesVisibility } = usePageObserver(); + const { currentActivePage, requestPage } = useCurrentActivePage({ + pages, + defaultPage, + activePages, + evaluatedPagesVisibility, + }); return (

- {pages.map((page, index) => { - if (page.isHidden) return null; + {pages + .filter((page) => isPageContentVisible(page)) + .map((page) => { + const isActive = page.id === currentActivePage; + const hasError = pagesWithErrors.includes(page.id); + return ( +
+ +
+ ); + })} + {sessionMode !== 'view' &&
} - const isCurrentlySelected = joinWord(page.label) === selectedPage; - const hasError = pagesWithErrors.includes(page.label); - - return ( - - ); - })} - {mode !== 'view' &&
} -
- {allowUnspecifiedAll && mode !== 'view' && ( -
- -
- )} - {mode !== 'view' && ( +
+ {sessionMode !== 'view' && ( )}
); }; -function joinWord(value) { - return value.replace(/\s/g, ''); -} - export default Sidebar; diff --git a/src/components/sidebar/sidebar.scss b/src/components/sidebar/sidebar.scss index 81195676d..4ca3f4020 100644 --- a/src/components/sidebar/sidebar.scss +++ b/src/components/sidebar/sidebar.scss @@ -1,102 +1,87 @@ @use '@carbon/colors'; @use '@carbon/type'; -.sidebar { - width: 12rem; - min-height: 8rem; - overscroll-behavior: contain; - margin-right: 1rem; -} - -.sidebarList { - max-height: 100%; -} - -.sidenavActions { - margin-left: 0.6rem; -} - -@media all and (device-width: 600px) and (device-height: 1024px) and (orientation: portrait) { - .sidebar { - width: 11rem; - max-height: 500px; - margin-right: 20px; - position: fixed; - } - - .sidebarList { - max-height: 200px; - overflow-y: scroll; - } -} - -.link { - margin: 0.375rem 0 0.375rem 0.5rem; - font-size: 1rem; - line-height: 1.43; - letter-spacing: 0.16px; - color: colors.$gray-100; - cursor: pointer; - - :hover { - outline: none; - } -} - -.section { +.tab { border-left: 0.5rem solid colors.$teal-20; display: flex; align-items: center; - height: 2rem; + height: 3rem; padding: 0.25rem 0.5rem; background-color: colors.$white; - cursor: pointer; + margin: 0 0 0.063rem; } -/* Tablet */ -:global(.omrs-breakpoint-lt-desktop) { - .section { - height: 3rem; - } -} - -.sectionLink { +.tab button { @include type.type-style('body-01'); + font-family: inherit; + display: block; + background-color: inherit; + width: 100%; + border: none; + outline: none; + text-align: left; + min-height: 2rem; + white-space: normal; + word-wrap: break-word; + margin: 0 0 0.063rem; color: colors.$gray-100; + :hover { + cursor: pointer; + } } -.activeSection { - @extend .section; +.activeTab { border-left: 0.5rem solid colors.$teal-50; background-color: #ededed; - .sectionLink { + button { font-weight: 600; } } -.sectionDone { - @extend .section; - border-left: 0.5rem solid colors.$teal-80; - background-color: colors.$green-10; +.errorTab { + border-left: 0.5rem solid colors.$red-40; + background-color: colors.$red-10; + + button { + color: colors.$red-60 !important; + } } -.erroredSection { - @extend .section; +.activeErrorTab { + @extend .errorTab; + background-color: colors.$red-30; border-left: 0.5rem solid colors.$red-60; - color: colors.$red-70 !important; - background-color: colors.$red-20; + + button { + font-weight: 600; + } } -.activeErroredSection { - @extend .erroredSection; - font-weight: 600; +.sidebar { + width: 12rem; + min-height: 8rem; + overscroll-behavior: contain; + margin-right: 1rem; +} + +.sideNavActions { + margin-left: 0.6rem; +} + +@media all and (device-width: 600px) and (device-height: 1024px) and (orientation: portrait) { + .sidebar { + width: 11rem; + max-height: 500px; + margin-right: 20px; + position: fixed; + } } .divider { border: 0; border-top: 1px solid colors.$gray-40; - margin: 2.5rem 0.5rem 1rem; + margin: 1rem 0.5rem; } .button { @@ -114,8 +99,7 @@ .closeButton { @extend .button; + &:hover { + background-color: colors.$red-60 !important; + } } - -.toggleContainer { - margin-bottom: 0.5rem; -} \ No newline at end of file diff --git a/src/components/sidebar/useCurrentActivePage.test.ts b/src/components/sidebar/useCurrentActivePage.test.ts new file mode 100644 index 000000000..5dc0820f0 --- /dev/null +++ b/src/components/sidebar/useCurrentActivePage.test.ts @@ -0,0 +1,222 @@ +import { useCurrentActivePage } from './useCurrentActivePage'; +import { scrollIntoView } from '../../utils/form-helper'; +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { type FormPage } from '../../types'; + +jest.mock('../../utils/form-helper', () => ({ + scrollIntoView: jest.fn(), +})); + +describe('useCurrentActivePage', () => { + const mockPages = [ + { id: 'page-1', label: 'Page 1', isHidden: false }, + { id: 'page-2', label: 'Page 2', isHidden: false }, + { + id: 'page-3', + label: 'Page 3', + isHidden: false, + }, + { id: 'page-4', label: 'Page 4', isHidden: false }, + { id: 'page-5', label: 'Hidden Page', isHidden: true }, + ] as Array; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + describe('Initialization', () => { + it('should initialize with default page when available and not hidden', () => { + const { result } = renderHook(() => + useCurrentActivePage({ + pages: mockPages, + defaultPage: 'Page 2', + activePages: [], + evaluatedPagesVisibility: true, + }), + ); + + expect(result.current.currentActivePage).toBe('page-2'); + expect(scrollIntoView).toHaveBeenCalledWith('page-2'); + }); + + it('should initialize with first visible page when default page is hidden', () => { + const { result } = renderHook(() => + useCurrentActivePage({ + pages: mockPages, + defaultPage: 'Hidden Page', + activePages: [], + evaluatedPagesVisibility: true, + }), + ); + + expect(result.current.currentActivePage).toBe('page-1'); + }); + + it('should not initialize until evaluatedPagesVisibility is true', () => { + const { result, rerender } = renderHook( + ({ evaluated }) => + useCurrentActivePage({ + pages: mockPages, + defaultPage: 'Page 1', + activePages: [], + evaluatedPagesVisibility: evaluated, + }), + { initialProps: { evaluated: false } }, + ); + + expect(result.current.currentActivePage).toBeNull(); + + rerender({ evaluated: true }); + expect(result.current.currentActivePage).toBe('page-1'); + }); + + it('should handle empty pages array', () => { + const { result } = renderHook(() => + useCurrentActivePage({ + pages: [], + defaultPage: 'Page 1', + activePages: [], + evaluatedPagesVisibility: true, + }), + ); + + expect(result.current.currentActivePage).toBeNull(); + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it('should handle all hidden pages', () => { + const allHiddenPages = mockPages.map((page) => ({ ...page, isHidden: true })); + const { result } = renderHook(() => + useCurrentActivePage({ + pages: allHiddenPages, + defaultPage: 'Page 1', + activePages: [], + evaluatedPagesVisibility: true, + }), + ); + + expect(result.current.currentActivePage).toBeNull(); + }); + }); + + describe('Waypoint Interaction', () => { + it('should ignore Waypoint updates during initial phase', () => { + const { result } = renderHook(() => + useCurrentActivePage({ + pages: mockPages, + defaultPage: 'Page 1', + activePages: ['page-2'], + evaluatedPagesVisibility: true, + }), + ); + + expect(result.current.currentActivePage).toBe('page-1'); + + // Fast-forward halfway through the lock timeout + act(() => { + jest.advanceTimersByTime(250); + }); + + // Should still be on initial page + expect(result.current.currentActivePage).toBe('page-1'); + }); + + it('should respect Waypoint updates after initial phase', () => { + const { result, rerender } = renderHook(() => + useCurrentActivePage({ + pages: mockPages, + defaultPage: 'Page 1', + activePages: ['page-2'], + evaluatedPagesVisibility: true, + }), + ); + + // Fast-forward past the lock timeout + act(() => { + jest.advanceTimersByTime(500); + }); + + // Update active pages + rerender({ + pages: mockPages, + defaultPage: 'Page 1', + activePages: ['page-2'], + evaluatedPagesVisibility: true, + }); + + expect(result.current.currentActivePage).toBe('page-2'); + }); + + it('should select topmost visible page when multiple pages are visible', () => { + const { result } = renderHook(() => + useCurrentActivePage({ + pages: mockPages, + defaultPage: 'Page 1', + activePages: ['page-2', 'page-1', 'page-3'], + evaluatedPagesVisibility: true, + }), + ); + + // Fast-forward past the lock timeout + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(result.current.currentActivePage).toBe('page-1'); + }); + }); + + describe('User Interaction', () => { + it('should handle page requests and scroll to requested page', () => { + const { result } = renderHook(() => + useCurrentActivePage({ + pages: mockPages, + defaultPage: 'Page 1', + activePages: ['page-1'], + evaluatedPagesVisibility: true, + }), + ); + + act(() => { + result.current.requestPage('page-2'); + }); + + expect(result.current.currentActivePage).toBe('page-2'); + expect(scrollIntoView).toHaveBeenCalledWith('page-2'); + }); + + it('should maintain requested page if visible, even when other pages become visible', () => { + const { result, rerender } = renderHook(() => + useCurrentActivePage({ + pages: mockPages, + defaultPage: 'Page 1', + activePages: ['page-2'], + evaluatedPagesVisibility: true, + }), + ); + + // Request a specific page + act(() => { + result.current.requestPage('page-2'); + }); + + // Update active pages to include multiple pages + rerender({ + pages: mockPages, + defaultPage: 'Page 1', + activePages: ['page-1', 'page-2', 'page-3'], + evaluatedPagesVisibility: true, + }); + + // Should maintain the requested page + expect(result.current.currentActivePage).toBe('page-2'); + }); + }); +}); diff --git a/src/components/sidebar/useCurrentActivePage.ts b/src/components/sidebar/useCurrentActivePage.ts new file mode 100644 index 000000000..61e224a64 --- /dev/null +++ b/src/components/sidebar/useCurrentActivePage.ts @@ -0,0 +1,132 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { type FormPage } from '../../types'; +import { scrollIntoView } from '../../utils/form-helper'; + +interface UseCurrentActivePageProps { + pages: FormPage[]; + defaultPage: string; + activePages: string[]; + evaluatedPagesVisibility: boolean; +} + +interface UseCurrentActivePageResult { + currentActivePage: string | null; + requestPage: (pageId: string) => void; +} + +/** + * Hook to manage the currently active page in a form sidebar. + * + * This implementation includes a locking mechanism to handle a specific limitation with Waypoint: + * When dealing with short forms where multiple pages are visible in the viewport simultaneously, + * Waypoint's initial visibility detection can be unpredictable. It might: + * 1. Report pages in a different order than their DOM position + * 2. Miss reporting some visible pages in the first few renders + * 3. Report visibility events before our desired initial scroll position is established + * + * The locking mechanism (isInitialPhaseRef) prevents these early Waypoint events from + * overriding our intended initial page selection. Without this lock: + * - The form might initially select the first page + * - But then immediately jump to a different page due to Waypoint's visibility events + * - This creates a jarring user experience where the form appears to "jump" during initialization + * + * The lock is released either: + * 1. Automatically after a timeout (allowing for initial render and scroll stabilization) + * 2. Immediately when the user explicitly interacts with the form + */ +export const useCurrentActivePage = ({ + pages, + defaultPage, + activePages, + evaluatedPagesVisibility, +}: UseCurrentActivePageProps): UseCurrentActivePageResult => { + const [currentActivePage, setCurrentActivePage] = useState(null); + const [isInitialized, setIsInitialized] = useState(false); + const [requestedPage, setRequestedPage] = useState(null); + // Use a ref to track if we're in the initial render phase + const initialLockTimeoutRef = useRef(null); + const isInitialPhaseRef = useRef(true); + + // Initialize the active page + useEffect(() => { + if (isInitialized || !evaluatedPagesVisibility) return; + + const initializePage = () => { + // Try to find and set the default page + const defaultPageObject = pages.find(({ label }) => label === defaultPage); + + if (defaultPageObject && !defaultPageObject.isHidden) { + setCurrentActivePage(defaultPageObject.id); + scrollIntoView(defaultPageObject.id); + } else { + // Fall back to first visible page + const firstVisiblePage = pages.find((page) => !page.isHidden); + if (firstVisiblePage) { + setCurrentActivePage(firstVisiblePage.id); + } + } + }; + + initializePage(); + setIsInitialized(true); + }, [pages, defaultPage, evaluatedPagesVisibility, isInitialized]); + + useEffect(() => { + // Lock out Waypoint updates for 200ms to allow for: + // 1. Initial render completion + // 2. Scroll position establishment + // 3. Waypoint to complete its initial visibility detection + if (isInitialized) { + initialLockTimeoutRef.current = setTimeout(() => { + isInitialPhaseRef.current = false; + }, 200); + } + + // Cleanup + return () => { + clearTimeout(initialLockTimeoutRef.current); + }; + }, [isInitialized]); + + // Handle active pages updates from viewport visibility + useEffect(() => { + if (isInitialPhaseRef.current) return; + + const updateActivePage = () => { + // If there's a requested page and it's visible, keep it active + if (requestedPage && activePages.includes(requestedPage)) { + setCurrentActivePage(requestedPage); + setTimeout(() => { + setRequestedPage(null); + }, 100); + return; + } + + // If there's no requested page, use the topmost visible page + if (!requestedPage && activePages.length > 0) { + const topVisiblePage = activePages.reduce((top, current) => { + const topIndex = pages.findIndex((page) => page.id === top); + const currentIndex = pages.findIndex((page) => page.id === current); + return topIndex < currentIndex ? top : current; + }); + + setCurrentActivePage(topVisiblePage); + } + }; + + updateActivePage(); + }, [activePages, requestedPage, pages]); + + // Handle page requests + const requestPage = useCallback((pageId: string) => { + isInitialPhaseRef.current = false; // Release the lock on explicit user interaction + setRequestedPage(pageId); + setCurrentActivePage(pageId); + scrollIntoView(pageId); + }, []); + + return { + currentActivePage, + requestPage, + }; +}; diff --git a/src/components/sidebar/usePageObserver.ts b/src/components/sidebar/usePageObserver.ts new file mode 100644 index 000000000..b900ba69b --- /dev/null +++ b/src/components/sidebar/usePageObserver.ts @@ -0,0 +1,45 @@ +import { useState, useEffect } from 'react'; +import { type FormPage } from '../../types'; +import { pageObserver } from './page-observer'; + +interface PageObserverState { + pages: FormPage[]; + pagesWithErrors: string[]; + activePages: string[]; + evaluatedPagesVisibility: boolean; + hasMultiplePages: boolean | null; +} + +export const usePageObserver = () => { + const [state, setState] = useState({ + pages: [], + pagesWithErrors: [], + activePages: [], + evaluatedPagesVisibility: false, + hasMultiplePages: null, + }); + + useEffect(() => { + const subscriptions = [ + pageObserver.getScrollablePagesObservable().subscribe((pages) => { + setState((prev) => ({ ...prev, pages, hasMultiplePages: pages.length > 1 })); + }), + + pageObserver.getPagesWithErrorsObservable().subscribe((errors) => { + setState((prev) => ({ ...prev, pagesWithErrors: Array.from(errors) })); + }), + + pageObserver.getActivePagesObservable().subscribe((activePages) => { + setState((prev) => ({ ...prev, activePages: Array.from(activePages) })); + }), + + pageObserver.getEvaluatedPagesVisibilityObservable().subscribe((evaluated) => { + setState((prev) => ({ ...prev, evaluatedPagesVisibility: evaluated })); + }), + ]; + + return () => subscriptions.forEach((sub) => sub.unsubscribe()); + }, []); + + return state; +}; diff --git a/src/form-engine.component.tsx b/src/form-engine.component.tsx index afe722d26..19ce19c67 100644 --- a/src/form-engine.component.tsx +++ b/src/form-engine.component.tsx @@ -5,7 +5,6 @@ import { isEmpty, useFormJson } from '.'; import FormProcessorFactory from './components/processor-factory/form-processor-factory.component'; import Loader from './components/loaders/loader.component'; import { usePatientData } from './hooks/usePatientData'; -import { useWorkspaceLayout } from './hooks/useWorkspaceLayout'; import { FormFactoryProvider } from './provider/form-factory-provider'; import classNames from 'classnames'; import styles from './form-engine.scss'; @@ -17,6 +16,9 @@ import { init, teardown } from './lifecycle'; import { reportError } from './utils/error-utils'; import { moduleName } from './globals'; import { useFormCollapse } from './hooks/useFormCollapse'; +import Sidebar from './components/sidebar/sidebar.component'; +import { useFormWorkspaceSize } from './hooks/useFormWorkspaceSize'; +import { usePageObserver } from './components/sidebar/usePageObserver'; interface FormEngineProps { patientUUID: string; @@ -33,9 +35,6 @@ interface FormEngineProps { markFormAsDirty?: (isDirty: boolean) => void; } -// TODOs: -// - Implement sidebar -// - Conditionally render the button set const FormEngine = ({ formJson, patientUUID, @@ -56,18 +55,15 @@ const FormEngine = ({ const sessionDate = useMemo(() => { return new Date(); }, []); - const workspaceLayout = useWorkspaceLayout(ref); + const workspaceSize = useFormWorkspaceSize(ref); const { patient, isLoadingPatient } = usePatientData(patientUUID); const [isLoadingDependencies, setIsLoadingDependencies] = useState(false); - const [showSidebar, setShowSidebar] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [isFormDirty, setIsFormDirty] = useState(false); const sessionMode = !isEmpty(mode) ? mode : !isEmpty(encounterUUID) ? 'edit' : 'enter'; const { isFormExpanded, hideFormCollapseToggle } = useFormCollapse(sessionMode); + const { hasMultiplePages } = usePageObserver(); - // TODO: Updating this prop triggers a rerender of the entire form. This means whenever we scroll into a new page, the form is rerendered. - // Figure out a way to avoid this. Maybe use a ref with an observer instead of a state? - const [currentPage, setCurrentPage] = useState(''); const { formJson: refinedFormJson, isLoading: isLoadingFormJson, @@ -75,16 +71,24 @@ const FormEngine = ({ } = useFormJson(formUUID, formJson, encounterUUID, formSessionIntent); const showPatientBanner = useMemo(() => { - return patient && workspaceLayout !== 'minimized' && mode !== 'embedded-view'; - }, [patient, mode, workspaceLayout]); + return patient && workspaceSize === 'ultra-wide' && mode !== 'embedded-view'; + }, [patient, mode, workspaceSize]); const showButtonSet = useMemo(() => { - // if (mode === 'embedded-view') { - // return false; - // } - // return workspaceLayout === 'minimized' || (workspaceLayout === 'maximized' && scrollablePages.size <= 1); - return true; - }, [mode, workspaceLayout]); + if (mode === 'embedded-view' || isLoadingDependencies || hasMultiplePages === null) { + return false; + } + + return ['narrow', 'wider'].includes(workspaceSize) || !hasMultiplePages; + }, [mode, workspaceSize, isLoadingDependencies, hasMultiplePages]); + + const showSidebar = useMemo(() => { + if (mode === 'embedded-view' || isLoadingDependencies || hasMultiplePages === null) { + return false; + } + + return ['extra-wide', 'ultra-wide'].includes(workspaceSize) && hasMultiplePages; + }, [workspaceSize, isLoadingDependencies, hasMultiplePages]); useEffect(() => { reportError(formError, t('errorLoadingFormSchema', 'Error loading form schema')); @@ -116,7 +120,7 @@ const FormEngine = ({ sessionMode={sessionMode} sessionDate={sessionDate} formJson={refinedFormJson} - workspaceLayout={workspaceLayout} + workspaceLayout={workspaceSize === 'ultra-wide' ? 'maximized' : 'minimized'} location={session?.sessionLocation} provider={session?.currentProvider} visit={visit} @@ -130,8 +134,7 @@ const FormEngine = ({ handleClose: () => {}, }} hideFormCollapseToggle={hideFormCollapseToggle} - setIsFormDirty={setIsFormDirty} - setCurrentPage={setCurrentPage}> + setIsFormDirty={setIsFormDirty}>
{isLoadingDependencies && (
@@ -139,7 +142,16 @@ const FormEngine = ({
)}
- {showSidebar &&
{/* Side bar goes here */}
} + {showSidebar && ( + + )}
{showPatientBanner && } {refinedFormJson.markdown && ( @@ -160,7 +172,7 @@ const FormEngine = ({ onClick={() => { onCancel && onCancel(); handleClose && handleClose(); - // TODO: hideFormCollapseToggle(); + hideFormCollapseToggle(); }}> {mode === 'view' ? t('close', 'Close') : t('cancel', 'Cancel')} diff --git a/src/hooks/useEvaluateFormFieldExpressions.ts b/src/hooks/useEvaluateFormFieldExpressions.ts index 42a231117..d260d6199 100644 --- a/src/hooks/useEvaluateFormFieldExpressions.ts +++ b/src/hooks/useEvaluateFormFieldExpressions.ts @@ -13,6 +13,8 @@ export const useEvaluateFormFieldExpressions = ( ) => { const { formFields, patient, sessionMode } = factoryContext; const [evaluatedFormJson, setEvaluatedFormJson] = useState(factoryContext.formJson); + const [evaluatedPagesVisibility, setEvaluatedPagesVisibility] = useState(false); + const evaluatedFields = useMemo(() => { return formFields?.map((field) => { const fieldNode: FormNode = { value: field, type: 'field' }; @@ -127,9 +129,10 @@ export const useEvaluateFormFieldExpressions = ( }); }); setEvaluatedFormJson(updateFormSectionReferences(factoryContext.formJson)); + setEvaluatedPagesVisibility(true); }, [factoryContext.formJson, formFields]); - return { evaluatedFormJson, evaluatedFields }; + return { evaluatedFormJson, evaluatedFields, evaluatedPagesVisibility }; }; // helpers diff --git a/src/hooks/useFormStateHelpers.ts b/src/hooks/useFormStateHelpers.ts index d5aa88c2e..8a14aa4a3 100644 --- a/src/hooks/useFormStateHelpers.ts +++ b/src/hooks/useFormStateHelpers.ts @@ -1,7 +1,7 @@ import { type Dispatch, useCallback } from 'react'; import { type FormField, type FormSchema } from '../types'; import { type Action } from '../components/renderer/form/state'; -import cloneDeep from 'lodash/cloneDeep'; +import { cloneDeep } from 'lodash-es'; import { updateFormSectionReferences } from '../utils/common-utils'; export function useFormStateHelpers(dispatch: Dispatch, formFields: FormField[]) { diff --git a/src/hooks/useFormWorkspaceSize.test.ts b/src/hooks/useFormWorkspaceSize.test.ts new file mode 100644 index 000000000..5b18adc15 --- /dev/null +++ b/src/hooks/useFormWorkspaceSize.test.ts @@ -0,0 +1,117 @@ +import { renderHook } from '@testing-library/react'; +import { useFormWorkspaceSize } from './useFormWorkspaceSize'; +import { act } from 'react'; + +// Mock the pxToRem utility +jest.mock('../utils/common-utils', () => ({ + pxToRem: (px: number) => px / 16, // Simulate px to rem conversion (1rem = 16px) +})); + +// Mock ResizeObserver with callback ref +let resizeCallback: (entries: any[]) => void; +class ResizeObserverMock { + constructor(callback: (entries: any[]) => void) { + resizeCallback = callback; + } + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); +} + +global.ResizeObserver = ResizeObserverMock as any; + +describe('useFormWorkspaceSize', () => { + let ref: { current: HTMLDivElement | null }; + let parentElement: HTMLDivElement; + + beforeEach(() => { + // Create DOM elements + parentElement = document.createElement('div'); + const element = document.createElement('div'); + parentElement.appendChild(element); + // ref + ref = { current: element }; + + // Mock offsetWidth getter + Object.defineProperty(parentElement, 'offsetWidth', { + configurable: true, + value: 400, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const setParentWidth = (width: number) => { + Object.defineProperty(parentElement, 'offsetWidth', { + configurable: true, + value: width, + }); + if (typeof resizeCallback !== 'function') { + return; + } + // Trigger resize callback + act(() => { + resizeCallback([{ target: parentElement }]); + }); + }; + + it('should return "narrow" for width <= 26.25rem (420px)', () => { + setParentWidth(420); + const { result } = renderHook(() => useFormWorkspaceSize(ref)); + expect(result.current).toBe('narrow'); + }); + + it('should return "wider" for width <= 32.25rem (516px)', () => { + setParentWidth(516); + const { result } = renderHook(() => useFormWorkspaceSize(ref)); + expect(result.current).toBe('wider'); + }); + + it('should return "extra-wide" for width <= 48.25rem (772px)', () => { + setParentWidth(772); + const { result } = renderHook(() => useFormWorkspaceSize(ref)); + expect(result.current).toBe('extra-wide'); + }); + + it('should return "ultra-wide" for width > 48.25rem (772px)', () => { + setParentWidth(1000); + const { result } = renderHook(() => useFormWorkspaceSize(ref)); + expect(result.current).toBe('ultra-wide'); + }); + + it('should handle null ref', () => { + const nullRef = { current: null }; + const { result } = renderHook(() => useFormWorkspaceSize(nullRef)); + expect(result.current).toBe('narrow'); + }); + + it('should update size when container width changes', () => { + const { result } = renderHook(() => useFormWorkspaceSize(ref)); + + // Start with narrow + act(() => { + setParentWidth(400); + }); + expect(result.current).toBe('narrow'); + + // Change to wider + act(() => { + setParentWidth(516); + }); + expect(result.current).toBe('wider'); + + // Change to extra-wide + act(() => { + setParentWidth(772); + }); + expect(result.current).toBe('extra-wide'); + + // Change to ultra-wide + act(() => { + setParentWidth(1000); + }); + expect(result.current).toBe('ultra-wide'); + }); +}); diff --git a/src/hooks/useFormWorkspaceSize.ts b/src/hooks/useFormWorkspaceSize.ts new file mode 100644 index 000000000..0a18e4e93 --- /dev/null +++ b/src/hooks/useFormWorkspaceSize.ts @@ -0,0 +1,51 @@ +import { useLayoutEffect, useMemo, useState } from 'react'; +import { pxToRem } from '../utils/common-utils'; + +/** + * The width of the supported workspace variants in rem + */ +const narrowWorkspaceWidth = 26.25; +const widerWorkspaceWidth = 32.25; +const extraWideWorkspaceWidth = 48.25; + +type WorkspaceSize = 'narrow' | 'wider' | 'extra-wide' | 'ultra-wide'; + +/** + * This hook evaluates the size of the current workspace based on the width of the container element + */ +export function useFormWorkspaceSize(rootRef: React.RefObject): WorkspaceSize { + // width in rem + const [containerWidth, setContainerWidth] = useState(0); + const size = useMemo(() => { + if (containerWidth <= narrowWorkspaceWidth) { + return 'narrow'; + } else if (containerWidth <= widerWorkspaceWidth) { + return 'wider'; + } else if (containerWidth <= extraWideWorkspaceWidth) { + return 'extra-wide'; + } else { + return 'ultra-wide'; + } + }, [containerWidth]); + + useLayoutEffect(() => { + const handleResize = () => { + const containerWidth = rootRef.current?.parentElement?.offsetWidth; + containerWidth && setContainerWidth(pxToRem(containerWidth)); + }; + handleResize(); + const resizeObserver = new ResizeObserver((entries) => { + handleResize(); + }); + + if (rootRef.current) { + resizeObserver.observe(rootRef.current?.parentElement); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [rootRef]); + + return size; +} diff --git a/src/hooks/useWorkspaceLayout.ts b/src/hooks/useWorkspaceLayout.ts deleted file mode 100644 index 14a8eb878..000000000 --- a/src/hooks/useWorkspaceLayout.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useLayoutEffect, useState } from 'react'; - -/** - * This hook evaluates the layout of the current workspace based on the width of the container element - */ -export function useWorkspaceLayout(rootRef): 'minimized' | 'maximized' { - const [layout, setLayout] = useState<'minimized' | 'maximized'>('minimized'); - const TABLET_MAX = 1023; - useLayoutEffect(() => { - const handleResize = () => { - const containerWidth = rootRef.current?.parentElement?.offsetWidth; - containerWidth && setLayout(containerWidth > TABLET_MAX ? 'maximized' : 'minimized'); - }; - handleResize(); - const resizeObserver = new ResizeObserver((entries) => { - handleResize(); - }); - - if (rootRef.current) { - resizeObserver.observe(rootRef.current?.parentElement); - } - - return () => { - resizeObserver.disconnect(); - }; - }, [rootRef]); - - return layout; -} diff --git a/src/lifecycle.ts b/src/lifecycle.ts index 4194cbc36..185e8e11e 100644 --- a/src/lifecycle.ts +++ b/src/lifecycle.ts @@ -1,3 +1,4 @@ +import { pageObserver } from './components/sidebar/page-observer'; import setupFormEngineLibI18n from './setupI18n'; import { type FormFieldValueAdapter } from './types'; @@ -30,4 +31,5 @@ export function teardown() { } }); formFieldAdapters.clear(); + pageObserver.clear(); } diff --git a/src/provider/form-factory-provider.tsx b/src/provider/form-factory-provider.tsx index f5ade440c..019387f8c 100644 --- a/src/provider/form-factory-provider.tsx +++ b/src/provider/form-factory-provider.tsx @@ -28,7 +28,6 @@ interface FormFactoryProviderContextProps { provider: OpenmrsResource; isFormExpanded: boolean; registerForm: (formId: string, isSubForm: boolean, context: FormContextProps) => void; - setCurrentPage: (page: string) => void; handleConfirmQuestionDeletion?: (question: Readonly) => Promise; setIsFormDirty: (isFormDirty: boolean) => void; } @@ -52,7 +51,6 @@ interface FormFactoryProviderProps { handleClose: () => void; }; hideFormCollapseToggle: () => void; - setCurrentPage: (page: string) => void; handleConfirmQuestionDeletion?: (question: Readonly) => Promise; setIsFormDirty: (isFormDirty: boolean) => void; } @@ -72,7 +70,6 @@ export const FormFactoryProvider: React.FC = ({ children, formSubmissionProps, hideFormCollapseToggle, - setCurrentPage, handleConfirmQuestionDeletion, setIsFormDirty, }) => { @@ -170,7 +167,6 @@ export const FormFactoryProvider: React.FC = ({ provider, isFormExpanded, registerForm, - setCurrentPage, handleConfirmQuestionDeletion, setIsFormDirty, }}> diff --git a/src/transformers/default-schema-transformer.test.ts b/src/transformers/default-schema-transformer.test.ts index d9d4d5e78..7ec73a7be 100644 --- a/src/transformers/default-schema-transformer.test.ts +++ b/src/transformers/default-schema-transformer.test.ts @@ -9,6 +9,7 @@ const expectedTransformedSchema = { { label: 'Page 1', readonly: false, + id: 'page-Page1-0', sections: [ { label: 'Section 1', @@ -33,6 +34,7 @@ const expectedTransformedSchema = { ], meta: { submission: null, + pageId: 'page-Page1-0', }, }, { @@ -53,6 +55,7 @@ const expectedTransformedSchema = { ], meta: { submission: null, + pageId: 'page-Page1-0', }, }, { @@ -73,6 +76,7 @@ const expectedTransformedSchema = { ], meta: { submission: null, + pageId: 'page-Page1-0', }, }, { @@ -100,11 +104,13 @@ const expectedTransformedSchema = { ], meta: { submission: null, + pageId: 'page-Page1-0', }, }, ], meta: { submission: null, + pageId: 'page-Page1-0', }, }, { @@ -141,6 +147,7 @@ const expectedTransformedSchema = { ], meta: { submission: null, + pageId: 'page-Page1-0', }, }, ], diff --git a/src/transformers/default-schema-transformer.ts b/src/transformers/default-schema-transformer.ts index 0433ae339..bea41d79b 100644 --- a/src/transformers/default-schema-transformer.ts +++ b/src/transformers/default-schema-transformer.ts @@ -1,4 +1,4 @@ -import { type FormField, type FormSchemaTransformer, type FormSchema, type RenderType } from '../types'; +import { type FormField, type FormSchemaTransformer, type FormSchema, type RenderType, type FormPage } from '../types'; import { isTrue } from '../utils/boolean-utils'; import { hasRendering } from '../utils/common-utils'; @@ -7,7 +7,9 @@ export type RenderTypeExtended = 'multiCheckbox' | 'numeric' | RenderType; export const DefaultFormSchemaTransformer: FormSchemaTransformer = { transform: (form: FormSchema) => { parseBooleanTokenIfPresent(form, 'readonly'); - form.pages.forEach((page) => { + form.pages.forEach((page, index) => { + const label = page.label ?? ''; + page.id = `page-${label.replace(/\s/g, '')}-${index}`; parseBooleanTokenIfPresent(page, 'readonly'); if (page.sections) { page.sections.forEach((section) => { @@ -15,7 +17,7 @@ export const DefaultFormSchemaTransformer: FormSchemaTransformer = { section.questions = handleQuestionsWithObsComments(section.questions); parseBooleanTokenIfPresent(section, 'readonly'); parseBooleanTokenIfPresent(section, 'isExpanded'); - section?.questions?.forEach((question, index) => handleQuestion(question, form)); + section?.questions?.forEach((question, index) => handleQuestion(question, page, form)); }); } }); @@ -26,7 +28,7 @@ export const DefaultFormSchemaTransformer: FormSchemaTransformer = { }, }; -function handleQuestion(question: FormField, form: FormSchema) { +function handleQuestion(question: FormField, page: FormPage, form: FormSchema) { if (question.type === 'programState') { const formMeta = form.meta ?? {}; formMeta.programs = formMeta.programs @@ -40,8 +42,9 @@ function handleQuestion(question: FormField, form: FormSchema) { transformByType(question); transformByRendering(question); if (question?.questions?.length) { - question.questions.forEach((question) => handleQuestion(question, form)); + question.questions.forEach((question) => handleQuestion(question, page, form)); } + question.meta.pageId = page.id; } catch (error) { console.error(error); } diff --git a/src/types/schema.ts b/src/types/schema.ts index 424f15bad..3226e39e7 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -42,6 +42,7 @@ export interface FormPage { behaviours?: Array; form: Omit; }; + id?: string; } export interface FormSection { @@ -125,6 +126,7 @@ export interface QuestionMetaProps { wasDeleted?: boolean; }; groupId?: string; + pageId?: string; [anythingElse: string]: any; } diff --git a/src/utils/common-utils.ts b/src/utils/common-utils.ts index 15a0ede8a..c089be6e7 100644 --- a/src/utils/common-utils.ts +++ b/src/utils/common-utils.ts @@ -89,3 +89,13 @@ export function updateFormSectionReferences(formJson: FormSchema) { }); return { ...formJson }; } + +/** + * Converts a px value to a rem value + * @param px - The px value to convert + * @param fontSize - The font size to use for the conversion + * @returns The rem value + */ +export function pxToRem(px: number, fontSize: number = 16) { + return px / fontSize; +} diff --git a/src/utils/form-helper.test.ts b/src/utils/form-helper.test.ts index 391b0c9e2..c0e2e05d7 100644 --- a/src/utils/form-helper.test.ts +++ b/src/utils/form-helper.test.ts @@ -3,11 +3,12 @@ import { evaluateConditionalAnswered, evaluateFieldReadonlyProp, evaluateDisabled, + isPageContentVisible, } from './form-helper'; import { DefaultValueValidator } from '../validators/default-value-validator'; import { type LayoutType } from '@openmrs/esm-framework'; import { ConceptTrue } from '../constants'; -import { type FormField, type OpenmrsEncounter, type SessionMode } from '../types'; +import { type FormPage, type FormField, type OpenmrsEncounter, type SessionMode } from '../types'; jest.mock('../validators/default-value-validator'); @@ -444,4 +445,66 @@ describe('Form Engine Helper', () => { ); }); }); + + describe('isPageContentVisible', () => { + it('should return false if the page is hidden', () => { + const page = { isHidden: true, sections: [] } as FormPage; + expect(isPageContentVisible(page)).toBe(false); + }); + + it('should return false if all sections are hidden', () => { + const page = { + isHidden: false, + sections: [ + { isHidden: true, questions: [] }, + { isHidden: true, questions: [] }, + ], + } as FormPage; + expect(isPageContentVisible(page)).toBe(false); + }); + + it('should return false if all questions in all sections are hidden', () => { + const page = { + isHidden: false, + sections: [ + { isHidden: false, questions: [{ isHidden: true }, { isHidden: true }] }, + { isHidden: false, questions: [{ isHidden: true }] }, + ], + } as FormPage; + expect(isPageContentVisible(page)).toBe(false); + }); + + it('should return false when there are no form fields', () => { + const page = { + isHidden: false, + sections: [ + { isHidden: true, questions: [] }, + { isHidden: false, questions: [] }, + ], + } as FormPage; + expect(isPageContentVisible(page)).toBe(false); + }); + + it('should return true if at least one question in a section is visible', () => { + const page = { + isHidden: false, + sections: [ + { + isHidden: false, + questions: [{ isHidden: true }, { isHidden: false }], + }, + { + isHidden: true, + questions: [{ isHidden: true }], + }, + ], + } as FormPage; + expect(isPageContentVisible(page)).toBe(true); + }); + + it('should return false for an empty page with no sections', () => { + const page = { isHidden: false, sections: [] } as FormPage; + expect(isPageContentVisible(page)).toBe(false); + }); + }); }); diff --git a/src/utils/form-helper.ts b/src/utils/form-helper.ts index ebcb9e046..87d2ee090 100644 --- a/src/utils/form-helper.ts +++ b/src/utils/form-helper.ts @@ -163,7 +163,7 @@ export function scrollIntoView(viewId: string, shouldFocus: boolean = false) { const currentElement = document.getElementById(viewId); currentElement?.scrollIntoView({ behavior: 'smooth', - block: 'center', + block: 'start', inline: 'center', }); @@ -193,3 +193,22 @@ export const extractObsValueAndDisplay = (field: FormField, obs: OpenmrsObs) => }; } }; + +/** + * Checks if a given form page has visible content. + * + * A page is considered to have visible content if: + * - The page itself is not hidden. + * - At least one section within the page is visible. + * - At least one question within each section is visible. + */ +export function isPageContentVisible(page: FormPage) { + if (page.isHidden) { + return false; + } + return ( + page.sections?.some((section) => { + return !section.isHidden && section.questions?.some((question) => !question.isHidden); + }) ?? false + ); +}