@openmrs/esm-form-engine-lib 2.1.0-pre.1575 → 2.1.0-pre.1581

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/openmrs-esm-form-engine-lib.js +1 -1
  2. package/package.json +1 -1
  3. package/src/adapters/obs-adapter.ts +1 -1
  4. package/src/components/renderer/field/fieldLogic.ts +3 -0
  5. package/src/components/renderer/field/form-field-renderer.component.tsx +0 -2
  6. package/src/components/renderer/form/form-renderer.component.tsx +20 -6
  7. package/src/components/renderer/page/page.renderer.component.tsx +12 -5
  8. package/src/components/sidebar/page-observer.ts +58 -0
  9. package/src/components/sidebar/sidebar.component.tsx +68 -94
  10. package/src/components/sidebar/sidebar.scss +56 -72
  11. package/src/components/sidebar/useCurrentActivePage.test.ts +222 -0
  12. package/src/components/sidebar/useCurrentActivePage.ts +137 -0
  13. package/src/components/sidebar/usePageObserver.ts +45 -0
  14. package/src/form-engine.component.tsx +34 -22
  15. package/src/hooks/useEvaluateFormFieldExpressions.ts +4 -1
  16. package/src/hooks/useFormStateHelpers.ts +1 -1
  17. package/src/hooks/useFormWorkspaceSize.test.ts +117 -0
  18. package/src/hooks/useFormWorkspaceSize.ts +52 -0
  19. package/src/lifecycle.ts +2 -0
  20. package/src/processors/encounter/encounter-form-processor.ts +1 -1
  21. package/src/provider/form-factory-provider.tsx +0 -4
  22. package/src/transformers/default-schema-transformer.test.ts +7 -0
  23. package/src/transformers/default-schema-transformer.ts +8 -5
  24. package/src/types/schema.ts +2 -0
  25. package/src/utils/common-utils.ts +10 -0
  26. package/src/utils/form-helper.test.ts +64 -1
  27. package/src/utils/form-helper.ts +20 -1
  28. package/src/hooks/useWorkspaceLayout.ts +0 -29
@@ -0,0 +1,222 @@
1
+ import { useCurrentActivePage } from './useCurrentActivePage';
2
+ import { scrollIntoView } from '../../utils/form-helper';
3
+ import { renderHook } from '@testing-library/react';
4
+ import { act } from 'react';
5
+ import { type FormPage } from '../../types';
6
+
7
+ jest.mock('../../utils/form-helper', () => ({
8
+ scrollIntoView: jest.fn(),
9
+ }));
10
+
11
+ describe('useCurrentActivePage', () => {
12
+ const mockPages = [
13
+ { id: 'page-1', label: 'Page 1', isHidden: false },
14
+ { id: 'page-2', label: 'Page 2', isHidden: false },
15
+ {
16
+ id: 'page-3',
17
+ label: 'Page 3',
18
+ isHidden: false,
19
+ },
20
+ { id: 'page-4', label: 'Page 4', isHidden: false },
21
+ { id: 'page-5', label: 'Hidden Page', isHidden: true },
22
+ ] as Array<FormPage>;
23
+
24
+ beforeEach(() => {
25
+ jest.clearAllMocks();
26
+ jest.useFakeTimers();
27
+ });
28
+
29
+ afterEach(() => {
30
+ jest.clearAllTimers();
31
+ jest.useRealTimers();
32
+ });
33
+
34
+ describe('Initialization', () => {
35
+ it('should initialize with default page when available and not hidden', () => {
36
+ const { result } = renderHook(() =>
37
+ useCurrentActivePage({
38
+ pages: mockPages,
39
+ defaultPage: 'Page 2',
40
+ activePages: [],
41
+ evaluatedPagesVisibility: true,
42
+ }),
43
+ );
44
+
45
+ expect(result.current.currentActivePage).toBe('page-2');
46
+ expect(scrollIntoView).toHaveBeenCalledWith('page-2');
47
+ });
48
+
49
+ it('should initialize with first visible page when default page is hidden', () => {
50
+ const { result } = renderHook(() =>
51
+ useCurrentActivePage({
52
+ pages: mockPages,
53
+ defaultPage: 'Hidden Page',
54
+ activePages: [],
55
+ evaluatedPagesVisibility: true,
56
+ }),
57
+ );
58
+
59
+ expect(result.current.currentActivePage).toBe('page-1');
60
+ });
61
+
62
+ it('should not initialize until evaluatedPagesVisibility is true', () => {
63
+ const { result, rerender } = renderHook(
64
+ ({ evaluated }) =>
65
+ useCurrentActivePage({
66
+ pages: mockPages,
67
+ defaultPage: 'Page 1',
68
+ activePages: [],
69
+ evaluatedPagesVisibility: evaluated,
70
+ }),
71
+ { initialProps: { evaluated: false } },
72
+ );
73
+
74
+ expect(result.current.currentActivePage).toBeNull();
75
+
76
+ rerender({ evaluated: true });
77
+ expect(result.current.currentActivePage).toBe('page-1');
78
+ });
79
+
80
+ it('should handle empty pages array', () => {
81
+ const { result } = renderHook(() =>
82
+ useCurrentActivePage({
83
+ pages: [],
84
+ defaultPage: 'Page 1',
85
+ activePages: [],
86
+ evaluatedPagesVisibility: true,
87
+ }),
88
+ );
89
+
90
+ expect(result.current.currentActivePage).toBeNull();
91
+ expect(scrollIntoView).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it('should handle all hidden pages', () => {
95
+ const allHiddenPages = mockPages.map((page) => ({ ...page, isHidden: true }));
96
+ const { result } = renderHook(() =>
97
+ useCurrentActivePage({
98
+ pages: allHiddenPages,
99
+ defaultPage: 'Page 1',
100
+ activePages: [],
101
+ evaluatedPagesVisibility: true,
102
+ }),
103
+ );
104
+
105
+ expect(result.current.currentActivePage).toBeNull();
106
+ });
107
+ });
108
+
109
+ describe('Waypoint Interaction', () => {
110
+ it('should ignore Waypoint updates during initial phase', () => {
111
+ const { result } = renderHook(() =>
112
+ useCurrentActivePage({
113
+ pages: mockPages,
114
+ defaultPage: 'Page 1',
115
+ activePages: ['page-2'],
116
+ evaluatedPagesVisibility: true,
117
+ }),
118
+ );
119
+
120
+ expect(result.current.currentActivePage).toBe('page-1');
121
+
122
+ // Fast-forward halfway through the lock timeout
123
+ act(() => {
124
+ jest.advanceTimersByTime(250);
125
+ });
126
+
127
+ // Should still be on initial page
128
+ expect(result.current.currentActivePage).toBe('page-1');
129
+ });
130
+
131
+ it('should respect Waypoint updates after initial phase', () => {
132
+ const { result, rerender } = renderHook(() =>
133
+ useCurrentActivePage({
134
+ pages: mockPages,
135
+ defaultPage: 'Page 1',
136
+ activePages: ['page-2'],
137
+ evaluatedPagesVisibility: true,
138
+ }),
139
+ );
140
+
141
+ // Fast-forward past the lock timeout
142
+ act(() => {
143
+ jest.advanceTimersByTime(500);
144
+ });
145
+
146
+ // Update active pages
147
+ rerender({
148
+ pages: mockPages,
149
+ defaultPage: 'Page 1',
150
+ activePages: ['page-2'],
151
+ evaluatedPagesVisibility: true,
152
+ });
153
+
154
+ expect(result.current.currentActivePage).toBe('page-2');
155
+ });
156
+
157
+ it('should select topmost visible page when multiple pages are visible', () => {
158
+ const { result } = renderHook(() =>
159
+ useCurrentActivePage({
160
+ pages: mockPages,
161
+ defaultPage: 'Page 1',
162
+ activePages: ['page-2', 'page-1', 'page-3'],
163
+ evaluatedPagesVisibility: true,
164
+ }),
165
+ );
166
+
167
+ // Fast-forward past the lock timeout
168
+ act(() => {
169
+ jest.advanceTimersByTime(500);
170
+ });
171
+
172
+ expect(result.current.currentActivePage).toBe('page-1');
173
+ });
174
+ });
175
+
176
+ describe('User Interaction', () => {
177
+ it('should handle page requests and scroll to requested page', () => {
178
+ const { result } = renderHook(() =>
179
+ useCurrentActivePage({
180
+ pages: mockPages,
181
+ defaultPage: 'Page 1',
182
+ activePages: ['page-1'],
183
+ evaluatedPagesVisibility: true,
184
+ }),
185
+ );
186
+
187
+ act(() => {
188
+ result.current.requestPage('page-2');
189
+ });
190
+
191
+ expect(result.current.currentActivePage).toBe('page-2');
192
+ expect(scrollIntoView).toHaveBeenCalledWith('page-2');
193
+ });
194
+
195
+ it('should maintain requested page if visible, even when other pages become visible', () => {
196
+ const { result, rerender } = renderHook(() =>
197
+ useCurrentActivePage({
198
+ pages: mockPages,
199
+ defaultPage: 'Page 1',
200
+ activePages: ['page-2'],
201
+ evaluatedPagesVisibility: true,
202
+ }),
203
+ );
204
+
205
+ // Request a specific page
206
+ act(() => {
207
+ result.current.requestPage('page-2');
208
+ });
209
+
210
+ // Update active pages to include multiple pages
211
+ rerender({
212
+ pages: mockPages,
213
+ defaultPage: 'Page 1',
214
+ activePages: ['page-1', 'page-2', 'page-3'],
215
+ evaluatedPagesVisibility: true,
216
+ });
217
+
218
+ // Should maintain the requested page
219
+ expect(result.current.currentActivePage).toBe('page-2');
220
+ });
221
+ });
222
+ });
@@ -0,0 +1,137 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { type FormPage } from '../../types';
3
+ import { scrollIntoView } from '../../utils/form-helper';
4
+
5
+ interface UseCurrentActivePageProps {
6
+ pages: FormPage[];
7
+ defaultPage: string;
8
+ activePages: string[];
9
+ evaluatedPagesVisibility: boolean;
10
+ }
11
+
12
+ interface UseCurrentActivePageResult {
13
+ currentActivePage: string | null;
14
+ requestPage: (pageId: string) => void;
15
+ }
16
+
17
+ /**
18
+ * Hook to manage the currently active page in a form sidebar.
19
+ *
20
+ * This implementation includes a locking mechanism to handle a specific limitation with Waypoint:
21
+ * When dealing with short forms where multiple pages are visible in the viewport simultaneously,
22
+ * Waypoint's initial visibility detection can be unpredictable. It might:
23
+ * 1. Report pages in a different order than their DOM position
24
+ * 2. Miss reporting some visible pages in the first few renders
25
+ * 3. Report visibility events before our desired initial scroll position is established
26
+ *
27
+ * The locking mechanism (isInitialPhaseRef) prevents these early Waypoint events from
28
+ * overriding our intended initial page selection. Without this lock:
29
+ * - The form might initially select the first page
30
+ * - But then immediately jump to a different page due to Waypoint's visibility events
31
+ * - This creates a jarring user experience where the form appears to "jump" during initialization
32
+ *
33
+ * The lock is released either:
34
+ * 1. Automatically after a timeout (allowing for initial render and scroll stabilization)
35
+ * 2. Immediately when the user explicitly interacts with the form
36
+ */
37
+ export const useCurrentActivePage = ({
38
+ pages,
39
+ defaultPage,
40
+ activePages,
41
+ evaluatedPagesVisibility,
42
+ }: UseCurrentActivePageProps): UseCurrentActivePageResult => {
43
+ const [currentActivePage, setCurrentActivePage] = useState<string | null>(null);
44
+ const [isInitialized, setIsInitialized] = useState(false);
45
+ const [requestedPage, setRequestedPage] = useState<string | null>(null);
46
+ const isInitialPhaseRef = useRef(true);
47
+
48
+ // Initialize the active page
49
+ useEffect(() => {
50
+ if (isInitialized || !evaluatedPagesVisibility) return;
51
+
52
+ const initializePage = () => {
53
+ // Try to find and set the default page
54
+ const defaultPageObject = pages.find(({ label }) => label === defaultPage);
55
+
56
+ if (defaultPageObject && !defaultPageObject.isHidden) {
57
+ setCurrentActivePage(defaultPageObject.id);
58
+ scrollIntoView(defaultPageObject.id);
59
+ } else {
60
+ // Fall back to first visible page
61
+ const firstVisiblePage = pages.find((page) => !page.isHidden);
62
+ if (firstVisiblePage) {
63
+ setCurrentActivePage(firstVisiblePage.id);
64
+ }
65
+ }
66
+ };
67
+
68
+ initializePage();
69
+ setIsInitialized(true);
70
+ }, [pages, defaultPage, evaluatedPagesVisibility, isInitialized]);
71
+
72
+ useEffect(() => {
73
+ let initialLockTimeout = null;
74
+ // Lock out Waypoint updates for 200ms to allow for:
75
+ // 1. Initial render completion
76
+ // 2. Scroll position establishment
77
+ // 3. Waypoint to complete its initial visibility detection
78
+ if (isInitialized) {
79
+ initialLockTimeout = setTimeout(() => {
80
+ isInitialPhaseRef.current = false;
81
+ }, 200);
82
+ }
83
+
84
+ // Cleanup
85
+ return () => {
86
+ if (initialLockTimeout) {
87
+ clearTimeout(initialLockTimeout);
88
+ }
89
+ };
90
+ }, [isInitialized]);
91
+
92
+ // Handle active pages updates from viewport visibility
93
+ useEffect(() => {
94
+ if (isInitialPhaseRef.current) return;
95
+
96
+ let clearRequestTimeout: NodeJS.Timeout | null = null;
97
+
98
+ // If there's a requested page and it's visible, keep it active
99
+ if (requestedPage && activePages.includes(requestedPage)) {
100
+ setCurrentActivePage(requestedPage);
101
+ clearRequestTimeout = setTimeout(() => {
102
+ setRequestedPage(null);
103
+ }, 100);
104
+ return;
105
+ }
106
+
107
+ // If there's no requested page, use the topmost visible page
108
+ if (!requestedPage && activePages.length > 0) {
109
+ const topVisiblePage = activePages.reduce((top, current) => {
110
+ const topIndex = pages.findIndex((page) => page.id === top);
111
+ const currentIndex = pages.findIndex((page) => page.id === current);
112
+ return topIndex < currentIndex ? top : current;
113
+ });
114
+
115
+ setCurrentActivePage(topVisiblePage);
116
+ }
117
+
118
+ return () => {
119
+ if (clearRequestTimeout) {
120
+ clearTimeout(clearRequestTimeout);
121
+ }
122
+ };
123
+ }, [activePages, requestedPage, pages]);
124
+
125
+ // Handle page requests
126
+ const requestPage = useCallback((pageId: string) => {
127
+ isInitialPhaseRef.current = false; // Release the lock on explicit user interaction
128
+ setRequestedPage(pageId);
129
+ setCurrentActivePage(pageId);
130
+ scrollIntoView(pageId);
131
+ }, []);
132
+
133
+ return {
134
+ currentActivePage,
135
+ requestPage,
136
+ };
137
+ };
@@ -0,0 +1,45 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { type FormPage } from '../../types';
3
+ import { pageObserver } from './page-observer';
4
+
5
+ interface PageObserverState {
6
+ pages: FormPage[];
7
+ pagesWithErrors: string[];
8
+ activePages: string[];
9
+ evaluatedPagesVisibility: boolean;
10
+ hasMultiplePages: boolean | null;
11
+ }
12
+
13
+ export const usePageObserver = () => {
14
+ const [state, setState] = useState<PageObserverState>({
15
+ pages: [],
16
+ pagesWithErrors: [],
17
+ activePages: [],
18
+ evaluatedPagesVisibility: false,
19
+ hasMultiplePages: null,
20
+ });
21
+
22
+ useEffect(() => {
23
+ const subscriptions = [
24
+ pageObserver.getScrollablePagesObservable().subscribe((pages) => {
25
+ setState((prev) => ({ ...prev, pages, hasMultiplePages: pages.length > 1 }));
26
+ }),
27
+
28
+ pageObserver.getPagesWithErrorsObservable().subscribe((errors) => {
29
+ setState((prev) => ({ ...prev, pagesWithErrors: Array.from(errors) }));
30
+ }),
31
+
32
+ pageObserver.getActivePagesObservable().subscribe((activePages) => {
33
+ setState((prev) => ({ ...prev, activePages: Array.from(activePages) }));
34
+ }),
35
+
36
+ pageObserver.getEvaluatedPagesVisibilityObservable().subscribe((evaluated) => {
37
+ setState((prev) => ({ ...prev, evaluatedPagesVisibility: evaluated }));
38
+ }),
39
+ ];
40
+
41
+ return () => subscriptions.forEach((sub) => sub.unsubscribe());
42
+ }, []);
43
+
44
+ return state;
45
+ };
@@ -5,7 +5,6 @@ import { isEmpty, useFormJson } from '.';
5
5
  import FormProcessorFactory from './components/processor-factory/form-processor-factory.component';
6
6
  import Loader from './components/loaders/loader.component';
7
7
  import { usePatientData } from './hooks/usePatientData';
8
- import { useWorkspaceLayout } from './hooks/useWorkspaceLayout';
9
8
  import { FormFactoryProvider } from './provider/form-factory-provider';
10
9
  import classNames from 'classnames';
11
10
  import styles from './form-engine.scss';
@@ -17,6 +16,9 @@ import { init, teardown } from './lifecycle';
17
16
  import { reportError } from './utils/error-utils';
18
17
  import { moduleName } from './globals';
19
18
  import { useFormCollapse } from './hooks/useFormCollapse';
19
+ import Sidebar from './components/sidebar/sidebar.component';
20
+ import { useFormWorkspaceSize } from './hooks/useFormWorkspaceSize';
21
+ import { usePageObserver } from './components/sidebar/usePageObserver';
20
22
 
21
23
  interface FormEngineProps {
22
24
  patientUUID: string;
@@ -33,9 +35,6 @@ interface FormEngineProps {
33
35
  markFormAsDirty?: (isDirty: boolean) => void;
34
36
  }
35
37
 
36
- // TODOs:
37
- // - Implement sidebar
38
- // - Conditionally render the button set
39
38
  const FormEngine = ({
40
39
  formJson,
41
40
  patientUUID,
@@ -56,18 +55,15 @@ const FormEngine = ({
56
55
  const sessionDate = useMemo(() => {
57
56
  return new Date();
58
57
  }, []);
59
- const workspaceLayout = useWorkspaceLayout(ref);
58
+ const workspaceSize = useFormWorkspaceSize(ref);
60
59
  const { patient, isLoadingPatient } = usePatientData(patientUUID);
61
60
  const [isLoadingDependencies, setIsLoadingDependencies] = useState(false);
62
- const [showSidebar, setShowSidebar] = useState(false);
63
61
  const [isSubmitting, setIsSubmitting] = useState(false);
64
62
  const [isFormDirty, setIsFormDirty] = useState(false);
65
63
  const sessionMode = !isEmpty(mode) ? mode : !isEmpty(encounterUUID) ? 'edit' : 'enter';
66
64
  const { isFormExpanded, hideFormCollapseToggle } = useFormCollapse(sessionMode);
65
+ const { hasMultiplePages } = usePageObserver();
67
66
 
68
- // TODO: Updating this prop triggers a rerender of the entire form. This means whenever we scroll into a new page, the form is rerendered.
69
- // Figure out a way to avoid this. Maybe use a ref with an observer instead of a state?
70
- const [currentPage, setCurrentPage] = useState('');
71
67
  const {
72
68
  formJson: refinedFormJson,
73
69
  isLoading: isLoadingFormJson,
@@ -75,16 +71,24 @@ const FormEngine = ({
75
71
  } = useFormJson(formUUID, formJson, encounterUUID, formSessionIntent);
76
72
 
77
73
  const showPatientBanner = useMemo(() => {
78
- return patient && workspaceLayout !== 'minimized' && mode !== 'embedded-view';
79
- }, [patient, mode, workspaceLayout]);
74
+ return patient && workspaceSize === 'ultra-wide' && mode !== 'embedded-view';
75
+ }, [patient, mode, workspaceSize]);
80
76
 
81
77
  const showButtonSet = useMemo(() => {
82
- // if (mode === 'embedded-view') {
83
- // return false;
84
- // }
85
- // return workspaceLayout === 'minimized' || (workspaceLayout === 'maximized' && scrollablePages.size <= 1);
86
- return true;
87
- }, [mode, workspaceLayout]);
78
+ if (mode === 'embedded-view' || isLoadingDependencies || hasMultiplePages === null) {
79
+ return false;
80
+ }
81
+
82
+ return ['narrow', 'wider'].includes(workspaceSize) || !hasMultiplePages;
83
+ }, [mode, workspaceSize, isLoadingDependencies, hasMultiplePages]);
84
+
85
+ const showSidebar = useMemo(() => {
86
+ if (mode === 'embedded-view' || isLoadingDependencies || hasMultiplePages === null) {
87
+ return false;
88
+ }
89
+
90
+ return ['extra-wide', 'ultra-wide'].includes(workspaceSize) && hasMultiplePages;
91
+ }, [workspaceSize, isLoadingDependencies, hasMultiplePages]);
88
92
 
89
93
  useEffect(() => {
90
94
  reportError(formError, t('errorLoadingFormSchema', 'Error loading form schema'));
@@ -116,7 +120,7 @@ const FormEngine = ({
116
120
  sessionMode={sessionMode}
117
121
  sessionDate={sessionDate}
118
122
  formJson={refinedFormJson}
119
- workspaceLayout={workspaceLayout}
123
+ workspaceLayout={workspaceSize === 'ultra-wide' ? 'maximized' : 'minimized'}
120
124
  location={session?.sessionLocation}
121
125
  provider={session?.currentProvider}
122
126
  visit={visit}
@@ -130,8 +134,7 @@ const FormEngine = ({
130
134
  handleClose: () => {},
131
135
  }}
132
136
  hideFormCollapseToggle={hideFormCollapseToggle}
133
- setIsFormDirty={setIsFormDirty}
134
- setCurrentPage={setCurrentPage}>
137
+ setIsFormDirty={setIsFormDirty}>
135
138
  <div className={styles.formContainer}>
136
139
  {isLoadingDependencies && (
137
140
  <div className={styles.linearActivity}>
@@ -139,7 +142,16 @@ const FormEngine = ({
139
142
  </div>
140
143
  )}
141
144
  <div className={styles.formContent}>
142
- {showSidebar && <div>{/* Side bar goes here */}</div>}
145
+ {showSidebar && (
146
+ <Sidebar
147
+ isFormSubmitting={isSubmitting}
148
+ sessionMode={mode}
149
+ defaultPage={formJson.defaultPage}
150
+ onCancel={onCancel}
151
+ handleClose={handleClose}
152
+ hideFormCollapseToggle={hideFormCollapseToggle}
153
+ />
154
+ )}
143
155
  <div className={styles.formContentInner}>
144
156
  {showPatientBanner && <PatientBanner patient={patient} hideActionsOverflow />}
145
157
  {refinedFormJson.markdown && (
@@ -160,7 +172,7 @@ const FormEngine = ({
160
172
  onClick={() => {
161
173
  onCancel && onCancel();
162
174
  handleClose && handleClose();
163
- // TODO: hideFormCollapseToggle();
175
+ hideFormCollapseToggle();
164
176
  }}>
165
177
  {mode === 'view' ? t('close', 'Close') : t('cancel', 'Cancel')}
166
178
  </Button>
@@ -13,6 +13,8 @@ export const useEvaluateFormFieldExpressions = (
13
13
  ) => {
14
14
  const { formFields, patient, sessionMode } = factoryContext;
15
15
  const [evaluatedFormJson, setEvaluatedFormJson] = useState(factoryContext.formJson);
16
+ const [evaluatedPagesVisibility, setEvaluatedPagesVisibility] = useState(false);
17
+
16
18
  const evaluatedFields = useMemo(() => {
17
19
  return formFields?.map((field) => {
18
20
  const fieldNode: FormNode = { value: field, type: 'field' };
@@ -127,9 +129,10 @@ export const useEvaluateFormFieldExpressions = (
127
129
  });
128
130
  });
129
131
  setEvaluatedFormJson(updateFormSectionReferences(factoryContext.formJson));
132
+ setEvaluatedPagesVisibility(true);
130
133
  }, [factoryContext.formJson, formFields]);
131
134
 
132
- return { evaluatedFormJson, evaluatedFields };
135
+ return { evaluatedFormJson, evaluatedFields, evaluatedPagesVisibility };
133
136
  };
134
137
 
135
138
  // helpers
@@ -1,7 +1,7 @@
1
1
  import { type Dispatch, useCallback } from 'react';
2
2
  import { type FormField, type FormSchema } from '../types';
3
3
  import { type Action } from '../components/renderer/form/state';
4
- import cloneDeep from 'lodash/cloneDeep';
4
+ import { cloneDeep } from 'lodash-es';
5
5
  import { updateFormSectionReferences } from '../utils/common-utils';
6
6
 
7
7
  export function useFormStateHelpers(dispatch: Dispatch<Action>, formFields: FormField[]) {
@@ -0,0 +1,117 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { useFormWorkspaceSize } from './useFormWorkspaceSize';
3
+ import { act } from 'react';
4
+
5
+ // Mock the pxToRem utility
6
+ jest.mock('../utils/common-utils', () => ({
7
+ pxToRem: (px: number) => px / 16, // Simulate px to rem conversion (1rem = 16px)
8
+ }));
9
+
10
+ // Mock ResizeObserver with callback ref
11
+ let resizeCallback: (entries: any[]) => void;
12
+ class ResizeObserverMock {
13
+ constructor(callback: (entries: any[]) => void) {
14
+ resizeCallback = callback;
15
+ }
16
+ observe = jest.fn();
17
+ unobserve = jest.fn();
18
+ disconnect = jest.fn();
19
+ }
20
+
21
+ global.ResizeObserver = ResizeObserverMock as any;
22
+
23
+ describe('useFormWorkspaceSize', () => {
24
+ let ref: { current: HTMLDivElement | null };
25
+ let parentElement: HTMLDivElement;
26
+
27
+ beforeEach(() => {
28
+ // Create DOM elements
29
+ parentElement = document.createElement('div');
30
+ const element = document.createElement('div');
31
+ parentElement.appendChild(element);
32
+ // ref
33
+ ref = { current: element };
34
+
35
+ // Mock offsetWidth getter
36
+ Object.defineProperty(parentElement, 'offsetWidth', {
37
+ configurable: true,
38
+ value: 400,
39
+ });
40
+ });
41
+
42
+ afterEach(() => {
43
+ jest.clearAllMocks();
44
+ });
45
+
46
+ const setParentWidth = (width: number) => {
47
+ Object.defineProperty(parentElement, 'offsetWidth', {
48
+ configurable: true,
49
+ value: width,
50
+ });
51
+ if (typeof resizeCallback !== 'function') {
52
+ return;
53
+ }
54
+ // Trigger resize callback
55
+ act(() => {
56
+ resizeCallback([{ target: parentElement }]);
57
+ });
58
+ };
59
+
60
+ it('should return "narrow" for width <= 26.25rem (420px)', () => {
61
+ setParentWidth(420);
62
+ const { result } = renderHook(() => useFormWorkspaceSize(ref));
63
+ expect(result.current).toBe('narrow');
64
+ });
65
+
66
+ it('should return "wider" for width <= 32.25rem (516px)', () => {
67
+ setParentWidth(516);
68
+ const { result } = renderHook(() => useFormWorkspaceSize(ref));
69
+ expect(result.current).toBe('wider');
70
+ });
71
+
72
+ it('should return "extra-wide" for width <= 48.25rem (772px)', () => {
73
+ setParentWidth(772);
74
+ const { result } = renderHook(() => useFormWorkspaceSize(ref));
75
+ expect(result.current).toBe('extra-wide');
76
+ });
77
+
78
+ it('should return "ultra-wide" for width > 48.25rem (772px)', () => {
79
+ setParentWidth(1000);
80
+ const { result } = renderHook(() => useFormWorkspaceSize(ref));
81
+ expect(result.current).toBe('ultra-wide');
82
+ });
83
+
84
+ it('should handle null ref', () => {
85
+ const nullRef = { current: null };
86
+ const { result } = renderHook(() => useFormWorkspaceSize(nullRef));
87
+ expect(result.current).toBe('narrow');
88
+ });
89
+
90
+ it('should update size when container width changes', () => {
91
+ const { result } = renderHook(() => useFormWorkspaceSize(ref));
92
+
93
+ // Start with narrow
94
+ act(() => {
95
+ setParentWidth(400);
96
+ });
97
+ expect(result.current).toBe('narrow');
98
+
99
+ // Change to wider
100
+ act(() => {
101
+ setParentWidth(516);
102
+ });
103
+ expect(result.current).toBe('wider');
104
+
105
+ // Change to extra-wide
106
+ act(() => {
107
+ setParentWidth(772);
108
+ });
109
+ expect(result.current).toBe('extra-wide');
110
+
111
+ // Change to ultra-wide
112
+ act(() => {
113
+ setParentWidth(1000);
114
+ });
115
+ expect(result.current).toBe('ultra-wide');
116
+ });
117
+ });