ui-soxo-bootstrap-core 2.4.25-dev.18 → 2.4.25-dev.22
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/core/components/external-window/index.js +225 -0
- package/core/components/external-window/index.test.js +80 -0
- package/core/components/index.js +4 -1
- package/core/components/landing-api/landing-api.js +14 -6
- package/core/models/menus/components/menu-lists/menu-lists.js +1 -0
- package/core/modules/index.js +14 -22
- package/core/modules/reporting/components/reporting-dashboard/reporting-dashboard.js +6 -1
- package/core/modules/steps/action-buttons.js +61 -52
- package/core/modules/steps/steps.js +129 -20
- package/core/modules/steps/timeline.js +20 -15
- package/jest.config.js +8 -0
- package/jest.setup.js +1 -0
- package/package.json +9 -4
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { message } from 'antd';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders children in a separate browser window
|
|
7
|
+
* @param {ReactNode} children - Content to render in new window
|
|
8
|
+
* @param {Function} onClose - Callback when window closes
|
|
9
|
+
* @param {string} title - Window title
|
|
10
|
+
* @param {number} width - Window width
|
|
11
|
+
* @param {number} height - Window height
|
|
12
|
+
* @param {number} left - Window left position
|
|
13
|
+
* @param {number} top - Window top position
|
|
14
|
+
* @param {boolean} copyStyles - Whether to copy parent styles (default: true)
|
|
15
|
+
* @param {string} centerScreen - Center window on screen ('horizontal', 'vertical', 'both', or false)
|
|
16
|
+
* @param {Object} shortcuts - Keyboard shortcuts config { close: 'Escape', focus: 'Ctrl+Shift+F', ... }
|
|
17
|
+
* @param {Function} onMinimize - Callback when window is minimized
|
|
18
|
+
* @param {Function} onMaximize - Callback when window is maximized
|
|
19
|
+
*/
|
|
20
|
+
export function ExternalWindow({
|
|
21
|
+
children,
|
|
22
|
+
onClose,
|
|
23
|
+
title = 'New Window',
|
|
24
|
+
width = 600,
|
|
25
|
+
height = 400,
|
|
26
|
+
left,
|
|
27
|
+
top,
|
|
28
|
+
copyStyles = true,
|
|
29
|
+
centerScreen = false,
|
|
30
|
+
shortcuts = {},
|
|
31
|
+
onMinimize,
|
|
32
|
+
onMaximize
|
|
33
|
+
}) {
|
|
34
|
+
const [container, setContainer] = useState(null);
|
|
35
|
+
const windowRef = useRef(null);
|
|
36
|
+
|
|
37
|
+
// Default shortcuts
|
|
38
|
+
const defaultShortcuts = useMemo(() => ({
|
|
39
|
+
close: 'Escape',
|
|
40
|
+
focus: 'Ctrl+Shift+F',
|
|
41
|
+
...shortcuts
|
|
42
|
+
}), [shortcuts]);
|
|
43
|
+
|
|
44
|
+
// Calculate window position
|
|
45
|
+
const { posX, posY } = useMemo(() => {
|
|
46
|
+
let posX = left;
|
|
47
|
+
let posY = top;
|
|
48
|
+
|
|
49
|
+
if (centerScreen) {
|
|
50
|
+
const screenWidth = window.screen.availWidth;
|
|
51
|
+
const screenHeight = window.screen.availHeight;
|
|
52
|
+
|
|
53
|
+
if (centerScreen === 'both' || centerScreen === 'horizontal') {
|
|
54
|
+
posX = (screenWidth - width) / 2;
|
|
55
|
+
}
|
|
56
|
+
if (centerScreen === 'both' || centerScreen === 'vertical') {
|
|
57
|
+
posY = (screenHeight - height) / 2;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
posX: posX ?? 200,
|
|
63
|
+
posY: posY ?? 200
|
|
64
|
+
};
|
|
65
|
+
}, [left, top, width, height, centerScreen]);
|
|
66
|
+
|
|
67
|
+
const windowFeatures = useMemo(() =>
|
|
68
|
+
`width=${width},height=${height},left=${posX},top=${posY},resizable=yes,scrollbars=yes`,
|
|
69
|
+
[width, height, posX, posY]
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Initialize window document
|
|
73
|
+
const initializeWindow = useCallback((win) => {
|
|
74
|
+
const doc = win.document;
|
|
75
|
+
|
|
76
|
+
// Write minimal HTML structure (like your working example)
|
|
77
|
+
doc.open();
|
|
78
|
+
doc.write(`
|
|
79
|
+
<!DOCTYPE html>
|
|
80
|
+
<html>
|
|
81
|
+
<head>
|
|
82
|
+
<meta charset="UTF-8">
|
|
83
|
+
<title>${title}</title>
|
|
84
|
+
<style>
|
|
85
|
+
* {
|
|
86
|
+
box-sizing: border-box;
|
|
87
|
+
}
|
|
88
|
+
html, body {
|
|
89
|
+
width: 100%;
|
|
90
|
+
height: 100%;
|
|
91
|
+
margin: 0 !important;
|
|
92
|
+
padding: 0 !important;
|
|
93
|
+
overflow: auto;
|
|
94
|
+
background: ${getComputedStyle(document.body).backgroundColor || '#ffffff'};
|
|
95
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
|
96
|
+
color: ${getComputedStyle(document.body).color || '#000000'};
|
|
97
|
+
}
|
|
98
|
+
body {
|
|
99
|
+
display: block;
|
|
100
|
+
min-height: 100vh;
|
|
101
|
+
}
|
|
102
|
+
#root {
|
|
103
|
+
width: 100%;
|
|
104
|
+
min-height: 100%;
|
|
105
|
+
margin: 0;
|
|
106
|
+
padding: 0;
|
|
107
|
+
}
|
|
108
|
+
</style>
|
|
109
|
+
</head>
|
|
110
|
+
<body>
|
|
111
|
+
<div id="root"></div>
|
|
112
|
+
</body>
|
|
113
|
+
</html>
|
|
114
|
+
`);
|
|
115
|
+
doc.close();
|
|
116
|
+
|
|
117
|
+
return doc.getElementById('root');
|
|
118
|
+
}, [title]);
|
|
119
|
+
|
|
120
|
+
// Copy styles from parent window
|
|
121
|
+
const copyStylesToWindow = useCallback((targetWindow) => {
|
|
122
|
+
if (!copyStyles) return;
|
|
123
|
+
|
|
124
|
+
const fragment = targetWindow.document.createDocumentFragment();
|
|
125
|
+
|
|
126
|
+
// Copy all stylesheets from parent
|
|
127
|
+
Array.from(document.styleSheets).forEach(sheet => {
|
|
128
|
+
try {
|
|
129
|
+
if (sheet.href) {
|
|
130
|
+
// External stylesheet
|
|
131
|
+
const link = targetWindow.document.createElement('link');
|
|
132
|
+
link.rel = 'stylesheet';
|
|
133
|
+
link.href = sheet.href;
|
|
134
|
+
fragment.appendChild(link);
|
|
135
|
+
} else if (sheet.cssRules) {
|
|
136
|
+
// Inline stylesheet
|
|
137
|
+
const style = targetWindow.document.createElement('style');
|
|
138
|
+
const cssText = Array.from(sheet.cssRules).map(r => r.cssText).join('\n');
|
|
139
|
+
style.textContent = cssText;
|
|
140
|
+
fragment.appendChild(style);
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
// Silently ignore cross-origin stylesheet errors
|
|
144
|
+
console.warn('Could not copy stylesheet:', e);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
targetWindow.document.head.appendChild(fragment);
|
|
149
|
+
}, [copyStyles]);
|
|
150
|
+
|
|
151
|
+
// Keyboard shortcut handler
|
|
152
|
+
const createKeyDownHandler = useCallback((win) => (e) => {
|
|
153
|
+
const key = [
|
|
154
|
+
e.ctrlKey && 'Ctrl',
|
|
155
|
+
e.shiftKey && 'Shift',
|
|
156
|
+
e.altKey && 'Alt',
|
|
157
|
+
e.metaKey && 'Meta',
|
|
158
|
+
e.key
|
|
159
|
+
].filter(Boolean).join('+');
|
|
160
|
+
|
|
161
|
+
if (defaultShortcuts.close && key === defaultShortcuts.close) {
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
onClose();
|
|
164
|
+
} else if (defaultShortcuts.focus && key === defaultShortcuts.focus) {
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
win.focus();
|
|
167
|
+
} else if (defaultShortcuts.minimize && key === defaultShortcuts.minimize) {
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
win.blur();
|
|
170
|
+
onMinimize?.();
|
|
171
|
+
} else if (defaultShortcuts.maximize && key === defaultShortcuts.maximize) {
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
win.moveTo(0, 0);
|
|
174
|
+
win.resizeTo(screen.availWidth, screen.availHeight);
|
|
175
|
+
onMaximize?.();
|
|
176
|
+
}
|
|
177
|
+
}, [defaultShortcuts, onClose, onMinimize, onMaximize]);
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
const win = window.open('', '', windowFeatures);
|
|
181
|
+
|
|
182
|
+
if (!win) {
|
|
183
|
+
message.error('Please allow popups for this site');
|
|
184
|
+
onClose();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
windowRef.current = win;
|
|
189
|
+
|
|
190
|
+
// Initialize window document structure
|
|
191
|
+
const root = initializeWindow(win);
|
|
192
|
+
|
|
193
|
+
// Copy styles from parent (async to not block)
|
|
194
|
+
requestAnimationFrame(() => {
|
|
195
|
+
copyStylesToWindow(win);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Set container for portal immediately
|
|
199
|
+
setContainer(root);
|
|
200
|
+
|
|
201
|
+
// Setup keyboard shortcuts
|
|
202
|
+
const handleKeyDown = createKeyDownHandler(win);
|
|
203
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
204
|
+
win.addEventListener('keydown', handleKeyDown);
|
|
205
|
+
|
|
206
|
+
// Handle window close
|
|
207
|
+
const handleClose = () => {
|
|
208
|
+
setContainer(null);
|
|
209
|
+
onClose();
|
|
210
|
+
};
|
|
211
|
+
win.addEventListener('beforeunload', handleClose);
|
|
212
|
+
|
|
213
|
+
// Cleanup
|
|
214
|
+
return () => {
|
|
215
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
216
|
+
win.removeEventListener('keydown', handleKeyDown);
|
|
217
|
+
win.removeEventListener('beforeunload', handleClose);
|
|
218
|
+
if (!win.closed) win.close();
|
|
219
|
+
};
|
|
220
|
+
// Only re-run if window features change, not on every render
|
|
221
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
222
|
+
}, [windowFeatures]);
|
|
223
|
+
|
|
224
|
+
return container ? createPortal(children, container) : null;
|
|
225
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, act } from '@testing-library/react';
|
|
3
|
+
import { ExternalWindow } from './index';
|
|
4
|
+
|
|
5
|
+
// Mock window.open
|
|
6
|
+
global.open = jest.fn(() => {
|
|
7
|
+
const newWindow = {
|
|
8
|
+
document: {
|
|
9
|
+
open: jest.fn(),
|
|
10
|
+
write: jest.fn(),
|
|
11
|
+
close: jest.fn(),
|
|
12
|
+
getElementById: jest.fn().mockReturnValue(document.createElement('div')),
|
|
13
|
+
head: {
|
|
14
|
+
appendChild: jest.fn(),
|
|
15
|
+
},
|
|
16
|
+
createDocumentFragment: jest.fn().mockReturnThis(),
|
|
17
|
+
createElement: jest.fn().mockReturnThis(),
|
|
18
|
+
body: document.createElement('body'),
|
|
19
|
+
},
|
|
20
|
+
addEventListener: jest.fn(),
|
|
21
|
+
removeEventListener: jest.fn(),
|
|
22
|
+
close: jest.fn(),
|
|
23
|
+
screen: {
|
|
24
|
+
availWidth: 1920,
|
|
25
|
+
availHeight: 1080,
|
|
26
|
+
},
|
|
27
|
+
getComputedStyle: jest.fn().mockReturnValue({}),
|
|
28
|
+
};
|
|
29
|
+
return newWindow;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Mock getComputedStyle
|
|
33
|
+
global.getComputedStyle = jest.fn(() => ({
|
|
34
|
+
backgroundColor: '#ffffff',
|
|
35
|
+
color: '#000000'
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
// Mock message.error
|
|
39
|
+
jest.mock('antd', () => ({
|
|
40
|
+
...jest.requireActual('antd'),
|
|
41
|
+
message: {
|
|
42
|
+
error: jest.fn(),
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
describe('ExternalWindow', () => {
|
|
47
|
+
it('renders children in a portal', () => {
|
|
48
|
+
const handleClose = jest.fn();
|
|
49
|
+
render(
|
|
50
|
+
<ExternalWindow onClose={handleClose}>
|
|
51
|
+
<div>External Content</div>
|
|
52
|
+
</ExternalWindow>
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// The content is rendered via a portal, so it won't be in the main container.
|
|
56
|
+
// Instead, we can check if window.open was called, which is the primary
|
|
57
|
+
// effect of this component.
|
|
58
|
+
expect(global.open).toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('calls window.close on unmount', () => {
|
|
62
|
+
const handleClose = jest.fn();
|
|
63
|
+
const { unmount } = render(<ExternalWindow onClose={handleClose} />);
|
|
64
|
+
|
|
65
|
+
const newWindow = global.open.mock.results[0].value;
|
|
66
|
+
|
|
67
|
+
unmount(); // cleanup
|
|
68
|
+
|
|
69
|
+
expect(newWindow.close).toHaveBeenCalledTimes(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should not open a window if popups are blocked', () => {
|
|
73
|
+
// block popups
|
|
74
|
+
global.open.mockReturnValueOnce(null);
|
|
75
|
+
const handleClose = jest.fn();
|
|
76
|
+
|
|
77
|
+
render(<ExternalWindow onClose={handleClose} />);
|
|
78
|
+
expect(handleClose).toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
});
|
package/core/components/index.js
CHANGED
|
@@ -10,9 +10,12 @@ import RootApplicationAPI from './root-application-api/root-application-api';
|
|
|
10
10
|
|
|
11
11
|
import { HomePageAPI } from '../modules';
|
|
12
12
|
|
|
13
|
+
import { ExternalWindow } from './external-window';
|
|
14
|
+
|
|
13
15
|
export {
|
|
14
16
|
LandingAPI,
|
|
15
17
|
RootApplicationAPI,
|
|
16
18
|
ExtraInfoDetail,
|
|
17
|
-
HomePageAPI
|
|
19
|
+
HomePageAPI,
|
|
20
|
+
ExternalWindow
|
|
18
21
|
}
|
|
@@ -96,6 +96,7 @@ export default function LandingApi({ history, CustomComponents, CustomModels, ap
|
|
|
96
96
|
* @param reports
|
|
97
97
|
*/
|
|
98
98
|
async function loadMenus(reports) {
|
|
99
|
+
|
|
99
100
|
setLoader(true);
|
|
100
101
|
|
|
101
102
|
// setReports(report)
|
|
@@ -105,12 +106,16 @@ export default function LandingApi({ history, CustomComponents, CustomModels, ap
|
|
|
105
106
|
// console.log(result);
|
|
106
107
|
|
|
107
108
|
if (result && Array.isArray(result.result) && result.result.length) {
|
|
109
|
+
|
|
108
110
|
// setModules(result.result);
|
|
111
|
+
|
|
109
112
|
// result.result.map((ele) => {
|
|
110
113
|
// let languageString = JSON.parse(ele.attributes)
|
|
111
114
|
// console.log('language_string', languageString);
|
|
112
115
|
// if (languageString && languageString.languages) {
|
|
116
|
+
|
|
113
117
|
// const language = i18n.language;
|
|
118
|
+
|
|
114
119
|
// i18n.addResourceBundle(language, 'translation', languageString.languages[i18n.language]);
|
|
115
120
|
// }
|
|
116
121
|
// })
|
|
@@ -121,6 +126,7 @@ export default function LandingApi({ history, CustomComponents, CustomModels, ap
|
|
|
121
126
|
dispatch({ type: 'settings', payload: result.result.settings });
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
|
|
124
130
|
// Reports length
|
|
125
131
|
if (reports.length) {
|
|
126
132
|
reportMenus = [
|
|
@@ -155,6 +161,7 @@ export default function LandingApi({ history, CustomComponents, CustomModels, ap
|
|
|
155
161
|
//If there is no roles assigned to the user
|
|
156
162
|
setAllModules([...coreModules]);
|
|
157
163
|
}
|
|
164
|
+
|
|
158
165
|
} else {
|
|
159
166
|
// for nura
|
|
160
167
|
if (result && result.result.menus && reportMenus) {
|
|
@@ -163,10 +170,14 @@ export default function LandingApi({ history, CustomComponents, CustomModels, ap
|
|
|
163
170
|
//If there is no roles assigned to the user
|
|
164
171
|
setAllModules([...coreModules]);
|
|
165
172
|
}
|
|
173
|
+
|
|
174
|
+
|
|
166
175
|
}
|
|
167
176
|
setLoader(false);
|
|
177
|
+
|
|
168
178
|
}
|
|
169
179
|
|
|
180
|
+
|
|
170
181
|
/**
|
|
171
182
|
* Load the scripts
|
|
172
183
|
*
|
|
@@ -246,12 +257,6 @@ export default function LandingApi({ history, CustomComponents, CustomModels, ap
|
|
|
246
257
|
|
|
247
258
|
<Route exact key={'profile'} path={'/profile'} render={(props) => <Profile {...props} />} />
|
|
248
259
|
|
|
249
|
-
{/* More specific routes should come before general/dynamic routes */}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
<Route path={'/reports/:id'} render={(props) => <ReportingDashboard CustomComponents={CustomComponents} {...props} />} />
|
|
253
|
-
|
|
254
|
-
|
|
255
260
|
<Route path={'/menus/:id'} render={() => <ModuleRoutes model={MenusAPI} />} />
|
|
256
261
|
|
|
257
262
|
{/* <Switch> */}
|
|
@@ -268,6 +273,9 @@ export default function LandingApi({ history, CustomComponents, CustomModels, ap
|
|
|
268
273
|
}}
|
|
269
274
|
/>
|
|
270
275
|
|
|
276
|
+
{/* <Route path={'/users'} render={() => <ModuleRoutes model={UsersAPI} />} /> */}
|
|
277
|
+
|
|
278
|
+
<Route path={'/reports/:id'} render={(props) => <ReportingDashboard CustomComponents={CustomComponents} {...props} />} />
|
|
271
279
|
|
|
272
280
|
<Route exact key={'change-password'} path={'/change-password'} render={(props) => <ChangePassword {...props} />} />
|
|
273
281
|
|
package/core/modules/index.js
CHANGED
|
@@ -24,28 +24,20 @@ import ChangeInfo from './Informations/change-info/change-info';
|
|
|
24
24
|
// All Dashboard Components Ends
|
|
25
25
|
import ProcessStepsPage from './steps/steps';
|
|
26
26
|
|
|
27
|
-
|
|
28
27
|
export {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
HomePageAPI,
|
|
44
|
-
Profile,
|
|
45
|
-
ReportingDashboard,
|
|
46
|
-
ChangePassword,
|
|
47
|
-
ChangeInfo
|
|
48
|
-
}
|
|
49
|
-
|
|
28
|
+
ProcessStepsPage,
|
|
29
|
+
GenericList,
|
|
30
|
+
GenericAdd,
|
|
31
|
+
GenericEdit,
|
|
32
|
+
GenericDetail,
|
|
33
|
+
ModuleRoutes,
|
|
34
|
+
DashboardCard,
|
|
35
|
+
PopQueryDashboard,
|
|
36
|
+
HomePageAPI,
|
|
37
|
+
Profile,
|
|
38
|
+
ReportingDashboard,
|
|
39
|
+
ChangePassword,
|
|
40
|
+
ChangeInfo,
|
|
41
|
+
};
|
|
50
42
|
|
|
51
43
|
// export { Generic } from './generic/generic-detail/generic-detail';
|
|
@@ -614,6 +614,8 @@ function GuestList({
|
|
|
614
614
|
{
|
|
615
615
|
title: '#',
|
|
616
616
|
dataIndex: 'index',
|
|
617
|
+
key: 'ColumnIndex',
|
|
618
|
+
width: 60,
|
|
617
619
|
render: (value, item, index) => index + 1,
|
|
618
620
|
key: 'ColumnIndex',
|
|
619
621
|
fixed: isFixedIndex ? 'left' : null,
|
|
@@ -777,6 +779,8 @@ function GuestList({
|
|
|
777
779
|
field: entry.field,
|
|
778
780
|
title: entry.title,
|
|
779
781
|
key: entry.field,
|
|
782
|
+
width: entry.width || 160,
|
|
783
|
+
ellipsis: true,
|
|
780
784
|
fixed: entry.isFixedColumn ? entry.isFixedColumn : null, // Conditionally setting the 'fixed' key to 'left' if 'isColumnStatic' is true; otherwise, setting it to null.
|
|
781
785
|
// Check if filtering is enabled and patients is an array
|
|
782
786
|
filters:
|
|
@@ -1042,7 +1046,8 @@ function GuestList({
|
|
|
1042
1046
|
) : (
|
|
1043
1047
|
<TableComponent
|
|
1044
1048
|
size="small"
|
|
1045
|
-
scroll={{ x:
|
|
1049
|
+
scroll={{ x: 'max-content', y: '60vh' }}
|
|
1050
|
+
tableLayout="fixed"
|
|
1046
1051
|
sticky
|
|
1047
1052
|
rowKey={(record) => record.OpNo}
|
|
1048
1053
|
dataSource={filtered ? filtered : patients} // In case if there is no filtered values we can use patient data
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActionButtons
|
|
3
|
+
* Handles navigation and action controls for a multi-step process,
|
|
4
|
+
* including dynamic content rendering and process completion actions.
|
|
5
|
+
*/
|
|
1
6
|
import React from 'react';
|
|
2
|
-
import {
|
|
7
|
+
import { Skeleton } from 'antd';
|
|
3
8
|
import { Button } from '../../lib';
|
|
4
9
|
|
|
5
10
|
export default function ActionButtons({
|
|
@@ -18,62 +23,66 @@ export default function ActionButtons({
|
|
|
18
23
|
}) {
|
|
19
24
|
return (
|
|
20
25
|
<>
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{/* Back button */}
|
|
28
|
-
<Button disabled={activeStep === 0} onClick={handlePrevious} style={{ marginRight: 8 ,borderRadius: 4, }}>
|
|
29
|
-
Back
|
|
30
|
-
</Button>
|
|
31
|
-
|
|
32
|
-
{/* Skip button */}
|
|
33
|
-
{steps.length > 0 && steps[activeStep]?.allow_skip === 'Y' && (
|
|
34
|
-
<Button type="default" onClick={handleSkip}
|
|
35
|
-
style={{
|
|
36
|
-
borderRadius: 4,
|
|
37
|
-
}}
|
|
38
|
-
disabled={activeStep === steps.length - 1}>
|
|
39
|
-
Skip
|
|
26
|
+
<div style={{ minHeight: 300 }}>{loading ? <Skeleton active /> : renderDynamicComponent()}</div>
|
|
27
|
+
<>
|
|
28
|
+
<div style={{ marginTop: 20, display: 'flex', justifyContent: 'flex-start', gap: '10px' }}>
|
|
29
|
+
{/* Back button */}
|
|
30
|
+
<Button disabled={activeStep === 0} onClick={handlePrevious} style={{ marginRight: 8, borderRadius: 4 }}>
|
|
31
|
+
Back
|
|
40
32
|
</Button>
|
|
41
|
-
)}
|
|
42
33
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
34
|
+
{/* Skip button */}
|
|
35
|
+
{steps.length > 0 && steps[activeStep]?.allow_skip === 'Y' && (
|
|
36
|
+
<Button
|
|
37
|
+
type="default"
|
|
38
|
+
onClick={handleSkip}
|
|
39
|
+
style={{
|
|
40
|
+
borderRadius: 4,
|
|
41
|
+
}}
|
|
42
|
+
disabled={activeStep === steps.length - 1}
|
|
43
|
+
>
|
|
44
|
+
Skip
|
|
52
45
|
</Button>
|
|
46
|
+
)}
|
|
47
|
+
|
|
48
|
+
{/* Next / Finish / Start Next */}
|
|
49
|
+
{steps[activeStep]?.order_seqtype === 'E' ? (
|
|
50
|
+
nextProcessId?.next_process_id ? (
|
|
51
|
+
<Button
|
|
52
|
+
type="primary"
|
|
53
|
+
style={{
|
|
54
|
+
borderRadius: 4,
|
|
55
|
+
}}
|
|
56
|
+
onClick={handleStartNextProcess}
|
|
57
|
+
>
|
|
58
|
+
Start Next {nextProcessId.next_process_name}
|
|
59
|
+
</Button>
|
|
60
|
+
) : (
|
|
61
|
+
<Button
|
|
62
|
+
type="primary"
|
|
63
|
+
style={{
|
|
64
|
+
borderRadius: 4,
|
|
65
|
+
}}
|
|
66
|
+
onClick={handleFinish}
|
|
67
|
+
>
|
|
68
|
+
Finish
|
|
69
|
+
</Button>
|
|
70
|
+
)
|
|
53
71
|
) : (
|
|
54
|
-
<Button
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
72
|
+
<Button
|
|
73
|
+
type="primary"
|
|
74
|
+
// shape="round"
|
|
75
|
+
style={{
|
|
76
|
+
borderRadius: 4,
|
|
77
|
+
}}
|
|
78
|
+
disabled={activeStep === steps.length - 1 || !isStepCompleted}
|
|
79
|
+
onClick={handleNext}
|
|
80
|
+
>
|
|
81
|
+
Next →
|
|
60
82
|
</Button>
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
<Button
|
|
64
|
-
type="primary"
|
|
65
|
-
// shape="round"
|
|
66
|
-
style={{
|
|
67
|
-
borderRadius: 4,
|
|
68
|
-
}}
|
|
69
|
-
disabled={activeStep === steps.length - 1 || !isStepCompleted}
|
|
70
|
-
onClick={handleNext}
|
|
71
|
-
>
|
|
72
|
-
Next →
|
|
73
|
-
</Button>
|
|
74
|
-
)}
|
|
75
|
-
</div>
|
|
76
|
-
</>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
77
85
|
</>
|
|
86
|
+
</>
|
|
78
87
|
);
|
|
79
88
|
}
|
|
@@ -1,15 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* ProcessStepsPage Component
|
|
3
|
+
*
|
|
4
|
+
* - Manages a multi-step, time-tracked process workflow.
|
|
5
|
+
* - Dynamically renders step-specific components based on configuration.
|
|
6
|
+
* - Tracks step and process durations with local persistence support.
|
|
7
|
+
* - Supports step navigation (next, previous, skip, timeline, keyboard).
|
|
8
|
+
* - Handles process submission and optional chaining to the next process.
|
|
9
|
+
* - Provides a collapsible timeline view and action controls.
|
|
10
|
+
*/
|
|
11
|
+
import React, { useEffect, useState } from 'react';
|
|
12
|
+
import { Row, Col, Empty } from 'antd';
|
|
13
|
+
import { Card } from './../../lib';
|
|
4
14
|
import * as genericComponents from './../../lib';
|
|
5
|
-
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
|
6
15
|
import moment from 'moment';
|
|
7
16
|
import { Location } from './../../lib';
|
|
8
17
|
import ActionButtons from './action-buttons';
|
|
9
18
|
import { Dashboard } from '../../models';
|
|
10
|
-
|
|
11
19
|
import './steps.scss';
|
|
12
20
|
import TimelinePanel from './timeline';
|
|
21
|
+
import { ExternalWindow } from '../../components';
|
|
13
22
|
|
|
14
23
|
export default function ProcessStepsPage({ processId, match, CustomComponents = {}, ...props }) {
|
|
15
24
|
const allComponents = { ...genericComponents, ...CustomComponents };
|
|
@@ -24,9 +33,10 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
|
|
|
24
33
|
const [processStartTime, setProcessStartTime] = useState(null);
|
|
25
34
|
const [processTimings, setProcessTimings] = useState([]);
|
|
26
35
|
const [timelineCollapsed, setTimelineCollapsed] = useState(true);
|
|
27
|
-
|
|
36
|
+
const [showExternalWindow, setShowExternalWindow] = useState(false);
|
|
28
37
|
const urlParams = Location.search();
|
|
29
38
|
|
|
39
|
+
// Load process details based on the current process ID
|
|
30
40
|
useEffect(() => {
|
|
31
41
|
loadProcess(currentProcessId);
|
|
32
42
|
|
|
@@ -37,30 +47,40 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
|
|
|
37
47
|
setStepStartTime(Date.now());
|
|
38
48
|
}, [currentProcessId]);
|
|
39
49
|
|
|
50
|
+
//// Reset step start time whenever the active step changes
|
|
51
|
+
|
|
40
52
|
useEffect(() => {
|
|
41
53
|
setStepStartTime(Date.now());
|
|
42
54
|
}, [activeStep]);
|
|
43
55
|
|
|
56
|
+
// Check whether the current step is completed or mandatory
|
|
44
57
|
useEffect(() => {
|
|
45
58
|
if (steps.length > 0) {
|
|
46
59
|
setIsStepCompleted(steps[activeStep]?.is_mandatory !== true);
|
|
47
60
|
}
|
|
48
61
|
}, [activeStep, steps]);
|
|
49
62
|
|
|
63
|
+
// Save updated process timings to state and localStorage
|
|
50
64
|
const saveTimings = (updated) => {
|
|
51
65
|
setProcessTimings(updated);
|
|
52
66
|
localStorage.setItem(`processTimings_${currentProcessId}`, JSON.stringify(updated));
|
|
53
67
|
};
|
|
68
|
+
// Record time spent on the current step
|
|
54
69
|
|
|
55
70
|
const recordStepTime = (status = 'completed') => {
|
|
71
|
+
// Exit if step start time or step data is missing
|
|
72
|
+
|
|
56
73
|
if (!stepStartTime || !steps[activeStep]) return processTimings;
|
|
74
|
+
// Capture end time and calculate duration
|
|
57
75
|
|
|
58
76
|
const endTime = Date.now();
|
|
59
77
|
const duration = endTime - stepStartTime;
|
|
60
78
|
const stepId = steps[activeStep].step_id;
|
|
79
|
+
// Clone existing timings
|
|
61
80
|
|
|
62
81
|
const updated = [...processTimings];
|
|
63
82
|
const index = updated.findIndex((t) => t.step_id === stepId);
|
|
83
|
+
// Create timing entry for the step
|
|
64
84
|
|
|
65
85
|
const entry = {
|
|
66
86
|
step_id: stepId,
|
|
@@ -69,7 +89,7 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
|
|
|
69
89
|
duration,
|
|
70
90
|
status,
|
|
71
91
|
};
|
|
72
|
-
|
|
92
|
+
// Update existing entry or add a new one
|
|
73
93
|
if (index > -1) {
|
|
74
94
|
updated[index] = { ...updated[index], ...entry, duration: updated[index].duration + duration };
|
|
75
95
|
} else {
|
|
@@ -79,6 +99,15 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
|
|
|
79
99
|
return updated;
|
|
80
100
|
};
|
|
81
101
|
|
|
102
|
+
/**
|
|
103
|
+
* @param {*} processId
|
|
104
|
+
*
|
|
105
|
+
* Process Loading
|
|
106
|
+
* - Fetches process details and step configuration using the process ID.
|
|
107
|
+
* - Manages loading state during the API call.
|
|
108
|
+
* - Stores step data and prepares next process details if available.
|
|
109
|
+
* - Handles API errors and maintains UI stability.
|
|
110
|
+
*/
|
|
82
111
|
async function loadProcess(processId) {
|
|
83
112
|
setLoading(true);
|
|
84
113
|
setNextProcessId(null);
|
|
@@ -94,7 +123,15 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
|
|
|
94
123
|
setLoading(false);
|
|
95
124
|
}
|
|
96
125
|
}
|
|
97
|
-
|
|
126
|
+
/**
|
|
127
|
+
* @param {*} finalTimings
|
|
128
|
+
*
|
|
129
|
+
* Process Submission
|
|
130
|
+
* - Builds payload with process metadata, reference details, and step timings.
|
|
131
|
+
* - Submits process completion data to the backend.
|
|
132
|
+
* - Clears stored timings on successful submission.
|
|
133
|
+
* - Persists timing data locally if submission fails.
|
|
134
|
+
*/
|
|
98
135
|
const handleProcessSubmit = async (finalTimings) => {
|
|
99
136
|
const payload = {
|
|
100
137
|
process_id: currentProcessId,
|
|
@@ -123,32 +160,73 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
|
|
|
123
160
|
}
|
|
124
161
|
return false;
|
|
125
162
|
};
|
|
126
|
-
|
|
163
|
+
/**
|
|
164
|
+
* @param {number} index
|
|
165
|
+
* @param {string} status
|
|
166
|
+
*
|
|
167
|
+
* Step Navigation
|
|
168
|
+
* - Records time spent on the current step.
|
|
169
|
+
* - Saves updated step timing data.
|
|
170
|
+
* - Navigates to the specified step index.
|
|
171
|
+
*/
|
|
127
172
|
const gotoStep = (index, status = 'completed') => {
|
|
128
173
|
const updated = recordStepTime(status);
|
|
129
174
|
saveTimings(updated);
|
|
130
175
|
setActiveStep(index);
|
|
131
176
|
};
|
|
132
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Navigate to the next step
|
|
179
|
+
* - Records timing data and advances step index by one.
|
|
180
|
+
*/
|
|
133
181
|
const handleNext = () => gotoStep(activeStep + 1);
|
|
182
|
+
/**
|
|
183
|
+
* Navigate to the previous step
|
|
184
|
+
* - Records timing data and moves to the previous step.
|
|
185
|
+
*/
|
|
134
186
|
const handlePrevious = () => gotoStep(activeStep - 1);
|
|
187
|
+
/**
|
|
188
|
+
* Skip current step
|
|
189
|
+
* - Records timing with skipped status.
|
|
190
|
+
* - Moves to the next step.
|
|
191
|
+
*/
|
|
135
192
|
const handleSkip = () => gotoStep(activeStep + 1, 'skipped');
|
|
193
|
+
/**
|
|
194
|
+
* Timeline Navigation
|
|
195
|
+
* - Navigates directly to the selected step.
|
|
196
|
+
* - Records timing data for the current step.
|
|
197
|
+
*/
|
|
136
198
|
const handleTimelineClick = (i) => gotoStep(i);
|
|
137
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Process Completion
|
|
201
|
+
* - Records final step timing.
|
|
202
|
+
* - Submits process completion data.
|
|
203
|
+
* - Navigates back on successful completion.
|
|
204
|
+
*/
|
|
138
205
|
const handleFinish = async () => {
|
|
139
206
|
const final = recordStepTime();
|
|
140
207
|
if (await handleProcessSubmit(final)) props.history?.goBack();
|
|
141
208
|
};
|
|
142
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Start Next Process
|
|
211
|
+
* - Records final timing of the current process.
|
|
212
|
+
* - Submits current process data.
|
|
213
|
+
* - Loads and initializes the next linked process.
|
|
214
|
+
*/
|
|
143
215
|
const handleStartNextProcess = async () => {
|
|
144
216
|
const final = recordStepTime();
|
|
145
217
|
if (await handleProcessSubmit(final)) {
|
|
146
218
|
await loadProcess(nextProcessId.next_process_id);
|
|
147
219
|
setCurrentProcessId(nextProcessId.next_process_id);
|
|
148
220
|
setActiveStep(0);
|
|
221
|
+
setShowExternalWindow(true);
|
|
149
222
|
}
|
|
150
223
|
};
|
|
151
|
-
|
|
224
|
+
/**
|
|
225
|
+
* Dynamic Step Renderer
|
|
226
|
+
* - Resolves and renders step-specific components dynamically.
|
|
227
|
+
* - Passes configuration, parameters, and handlers to the component.
|
|
228
|
+
* - Handles missing steps or components gracefully.
|
|
229
|
+
*/
|
|
152
230
|
const DynamicComponent = () => {
|
|
153
231
|
const step = steps[activeStep];
|
|
154
232
|
if (!step) return <Empty description="No step selected" />;
|
|
@@ -158,7 +236,12 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
|
|
|
158
236
|
|
|
159
237
|
return <Component {...step.config} {...props} step={step} params={urlParams} onStepComplete={() => setIsStepCompleted(true)} />;
|
|
160
238
|
};
|
|
161
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Keyboard Navigation
|
|
241
|
+
* - Enables left and right arrow keys for step navigation.
|
|
242
|
+
* - Prevents navigation beyond step boundaries.
|
|
243
|
+
* - Cleans up event listeners on unmount.
|
|
244
|
+
*/
|
|
162
245
|
useEffect(() => {
|
|
163
246
|
const handleKeyDown = (event) => {
|
|
164
247
|
// Handle Left Arrow key press to go to the previous step
|
|
@@ -179,13 +262,15 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
|
|
|
179
262
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
180
263
|
}, [activeStep, steps, handlePrevious, handleNext]);
|
|
181
264
|
|
|
182
|
-
|
|
265
|
+
/**
|
|
266
|
+
* Renders the main process UI including timeline, step details,
|
|
267
|
+
* and action buttons. This content is reused in both normal view
|
|
268
|
+
* and external window view.
|
|
269
|
+
*/
|
|
270
|
+
const renderContent = () => (
|
|
183
271
|
<Card>
|
|
184
272
|
<Row gutter={20}>
|
|
185
273
|
<Col xs={24} sm={24} lg={timelineCollapsed ? 2 : 6}>
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
274
|
<TimelinePanel
|
|
190
275
|
loading={loading}
|
|
191
276
|
steps={steps}
|
|
@@ -196,8 +281,7 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
|
|
|
196
281
|
/>
|
|
197
282
|
</Col>
|
|
198
283
|
|
|
199
|
-
|
|
200
|
-
|
|
284
|
+
<Col xs={24} sm={24} lg={timelineCollapsed ? 21 : 18}>
|
|
201
285
|
<div style={{ marginBottom: 20 }}>
|
|
202
286
|
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{steps[activeStep]?.step_name}</h2>
|
|
203
287
|
<p style={{ margin: 0, color: '#666' }}>{steps[activeStep]?.step_description}</p>
|
|
@@ -220,4 +304,29 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
|
|
|
220
304
|
</Row>
|
|
221
305
|
</Card>
|
|
222
306
|
);
|
|
307
|
+
/**
|
|
308
|
+
* Renders content in both the main window and an external window
|
|
309
|
+
* when external window mode is enabled.
|
|
310
|
+
*/
|
|
311
|
+
if (showExternalWindow && props.showExternalWindow) {
|
|
312
|
+
return (
|
|
313
|
+
<>
|
|
314
|
+
<ExternalWindow
|
|
315
|
+
title={steps[activeStep]?.step_name || 'Process Step'}
|
|
316
|
+
onClose={() => setShowExternalWindow(false)}
|
|
317
|
+
// left={window.screenX + window.outerWidth}
|
|
318
|
+
// top={window.screenY}
|
|
319
|
+
width={props.ExternalWindowWidth || 1000}
|
|
320
|
+
height={props.ExternalWindowHeight || 1000}
|
|
321
|
+
>
|
|
322
|
+
{renderContent()}
|
|
323
|
+
</ExternalWindow>
|
|
324
|
+
{renderContent()}
|
|
325
|
+
</>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Default render when external window mode is disabled.
|
|
330
|
+
*/
|
|
331
|
+
return renderContent();
|
|
223
332
|
}
|
|
@@ -1,28 +1,33 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/**
|
|
2
|
+
* TimelinePanel Component
|
|
3
|
+
*
|
|
4
|
+
* - Displays a vertical timeline for a multi-step process.
|
|
5
|
+
* - Highlights active and completed steps visually.
|
|
6
|
+
* - Allows direct navigation by clicking on timeline steps.
|
|
7
|
+
* - Supports collapsing and expanding the timeline view.
|
|
8
|
+
* - Shows a loading skeleton while step data is being fetched.
|
|
9
|
+
*
|
|
10
|
+
* Used as a visual step navigator within a step-based workflow.
|
|
11
|
+
*/
|
|
5
12
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
setTimelineCollapsed
|
|
13
|
-
}) {
|
|
13
|
+
import React from 'react';
|
|
14
|
+
import { Skeleton } from 'antd';
|
|
15
|
+
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
|
16
|
+
import { Card } from '../../lib';
|
|
17
|
+
|
|
18
|
+
export default function TimelinePanel({ loading, steps, activeStep, timelineCollapsed, handleTimelineClick, setTimelineCollapsed }) {
|
|
14
19
|
return (
|
|
15
20
|
<Card className="timeline-card">
|
|
16
21
|
{loading ? (
|
|
17
22
|
<Skeleton active />
|
|
18
23
|
) : (
|
|
19
|
-
<div className={`timeline-sidebar ${timelineCollapsed ?
|
|
24
|
+
<div className={`timeline-sidebar ${timelineCollapsed ? 'collapsed' : ''}`}>
|
|
20
25
|
{steps.map((step, index) => (
|
|
21
26
|
<div
|
|
22
27
|
key={step.step_id}
|
|
23
28
|
className={`timeline-step
|
|
24
|
-
${index === activeStep ?
|
|
25
|
-
${index < activeStep ?
|
|
29
|
+
${index === activeStep ? 'active' : ''}
|
|
30
|
+
${index < activeStep ? 'completed' : ''}`}
|
|
26
31
|
onClick={() => handleTimelineClick(index)}
|
|
27
32
|
>
|
|
28
33
|
<div className="step-marker">
|
package/jest.config.js
ADDED
package/jest.setup.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ui-soxo-bootstrap-core",
|
|
3
|
-
"version": "2.4.25-dev.
|
|
3
|
+
"version": "2.4.25-dev.22",
|
|
4
4
|
"description": "All the Core Components for you to start",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"all in one"
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"main": "index.js",
|
|
14
14
|
"scripts": {
|
|
15
15
|
"size": "size-limit",
|
|
16
|
-
"test": "
|
|
16
|
+
"test": "jest",
|
|
17
17
|
"start": "webpack-dev-server --mode development",
|
|
18
18
|
"transpile": "babel / -d build --copy-files",
|
|
19
19
|
"build": "webpack --mode production",
|
|
@@ -82,10 +82,13 @@
|
|
|
82
82
|
"devDependencies": {
|
|
83
83
|
"@babel/core": "^7.16.5",
|
|
84
84
|
"@babel/plugin-proposal-class-properties": "^7.10.4",
|
|
85
|
-
"@babel/preset-env": "^7.
|
|
86
|
-
"@babel/preset-react": "^7.
|
|
85
|
+
"@babel/preset-env": "^7.28.5",
|
|
86
|
+
"@babel/preset-react": "^7.28.5",
|
|
87
87
|
"@eslint/compat": "^1.1.1",
|
|
88
88
|
"@eslint/js": "^9.9.1",
|
|
89
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
90
|
+
"@testing-library/react": "^12.1.5",
|
|
91
|
+
"babel-jest": "^27.5.1",
|
|
89
92
|
"babel-loader": "^8.3.0",
|
|
90
93
|
"babel-plugin-transform-class-properties": "^6.24.1",
|
|
91
94
|
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
|
@@ -100,6 +103,8 @@
|
|
|
100
103
|
"eslint-plugin-unicorn": "^55.0.0",
|
|
101
104
|
"globals": "^15.9.0",
|
|
102
105
|
"husky": "^9.1.5",
|
|
106
|
+
"jest": "^27.5.1",
|
|
107
|
+
"jest-transform-stub": "^2.0.0",
|
|
103
108
|
"prettier": "^3.3.3",
|
|
104
109
|
"sass-loader": "^10.5.2",
|
|
105
110
|
"style-loader": "^1.3.0",
|