@openmrs/esm-form-engine-lib 2.1.0-pre.1565 → 2.1.0-pre.1577
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.
- package/b5c5a7c4dc0a8acf/b5c5a7c4dc0a8acf.gz +0 -0
- package/d7b6803241c9380f/d7b6803241c9380f.gz +0 -0
- package/dist/openmrs-esm-form-engine-lib.js +1 -1
- package/package.json +1 -1
- package/src/components/renderer/form/form-renderer.component.tsx +20 -6
- package/src/components/renderer/page/page.renderer.component.tsx +12 -5
- package/src/components/repeat/repeat.component.tsx +2 -0
- package/src/components/sidebar/page-observer.ts +58 -0
- package/src/components/sidebar/sidebar.component.tsx +68 -94
- package/src/components/sidebar/sidebar.scss +56 -72
- package/src/components/sidebar/useCurrentActivePage.test.ts +222 -0
- package/src/components/sidebar/useCurrentActivePage.ts +137 -0
- package/src/components/sidebar/usePageObserver.ts +45 -0
- package/src/form-engine.component.tsx +34 -22
- package/src/hooks/useEvaluateFormFieldExpressions.ts +4 -1
- package/src/hooks/useFormStateHelpers.ts +1 -1
- package/src/hooks/useFormWorkspaceSize.test.ts +117 -0
- package/src/hooks/useFormWorkspaceSize.ts +52 -0
- package/src/lifecycle.ts +2 -0
- package/src/provider/form-factory-provider.tsx +0 -4
- package/src/transformers/default-schema-transformer.test.ts +7 -0
- package/src/transformers/default-schema-transformer.ts +8 -5
- package/src/types/schema.ts +2 -0
- package/src/utils/common-utils.ts +10 -0
- package/src/utils/form-helper.test.ts +64 -1
- package/src/utils/form-helper.ts +20 -1
- package/d356d971fe6c1116/d356d971fe6c1116.gz +0 -0
- package/fa0d48f72865e16b/fa0d48f72865e16b.gz +0 -0
- package/src/hooks/useWorkspaceLayout.ts +0 -29
- /package/{f2738e729719827e/f2738e729719827e.gz → 63ee6b397e426495/63ee6b397e426495.gz} +0 -0
- /package/{c0c82ed82c8a1f6d/c0c82ed82c8a1f6d.gz → 96c41d63e46e82a5/96c41d63e46e82a5.gz} +0 -0
@@ -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
|
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 &&
|
79
|
-
}, [patient, mode,
|
74
|
+
return patient && workspaceSize === 'ultra-wide' && mode !== 'embedded-view';
|
75
|
+
}, [patient, mode, workspaceSize]);
|
80
76
|
|
81
77
|
const showButtonSet = useMemo(() => {
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
return
|
87
|
-
}, [mode,
|
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={
|
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 &&
|
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
|
-
|
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
|
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
|
+
});
|