ui-soxo-bootstrap-core 2.4.25-dev.17 → 2.4.25-dev.21

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.
@@ -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
+ });
@@ -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
 
@@ -559,6 +559,7 @@ function NestedMenu({
559
559
  extra={panelActions(child, model, setSelectedRecord, setDrawerTitle, setDrawerVisible, deleteRecord)}
560
560
  >
561
561
  <NestedMenu
562
+ model={model}
562
563
  parentId={child.id}
563
564
  step={step + 1}
564
565
  items={child.sub_menus || []}
@@ -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
- ProcessStepsPage,
30
- GenericList,
31
- GenericAdd,
32
-
33
- GenericEdit,
34
-
35
- GenericDetail,
36
-
37
- ModuleRoutes,
38
-
39
-
40
- DashboardCard,
41
-
42
- PopQueryDashboard,
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';
@@ -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 { Card, Skeleton } from 'antd';
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
- <div style={{ minHeight: 300 }}>
22
- {loading ? <Skeleton active /> : renderDynamicComponent()}
23
-
24
- </div>
25
- <>
26
- <div style={{ marginTop: 20, display: 'flex', justifyContent: 'flex-start', gap: '10px' }}>
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
- {/* Next / Finish / Start Next */}
44
- {steps[activeStep]?.order_seqtype === 'E' ? (
45
- nextProcessId?.next_process_id ? (
46
- <Button type="primary"
47
- style={{
48
- borderRadius: 4,
49
- }}
50
- onClick={handleStartNextProcess}>
51
- Start Next {nextProcessId.next_process_name}
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 type="primary"
55
- style={{
56
- borderRadius: 4,
57
- }}
58
- onClick={handleFinish}>
59
- Finish
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,347 +1,26 @@
1
- import React, { useEffect, useState, useCallback } from 'react';
2
- import { Skeleton, Row, Col, Empty } from 'antd';
3
- import {Card} from './../../lib';
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
- /**
15
- * ProcessStepsPage Component
16
- *
17
- * This component manages a multi-step process workflow. It dynamically loads steps from an API,
18
- * renders the appropriate component for each step, tracks user progress and timings,
19
- * and submits the final data upon completion. It features a collapsible timeline for navigation.
20
- *
21
- * @param {object} props - The component props.
22
- * @returns {JSX.Element} The rendered ProcessStepsPage component.
23
- */
24
- // export default function ProcessStepsPage({ processId = 1, match, CustomComponents = {}, ...props }) {
25
- // const allComponents = { ...genericComponents, ...CustomComponents }; // CustomComponents will default to an empty object
26
- // const [loading, setLoading] = useState(false);
27
- // const [steps, setSteps] = useState([]);
28
- // const [activeStep, setActiveStep] = useState(0);
29
- // const [isStepCompleted, setIsStepCompleted] = useState(false);
30
- // const [currentProcessId, setCurrentProcessId] = useState(processId);
31
- // const [nextProcessId, setNextProcessId] = useState(null);
32
- // const [stepStartTime, setStepStartTime] = useState(null);
33
- // const [processStartTime, setProcessStartTime] = useState(null);
34
- // const [processTimings, setProcessTimings] = useState([]);
35
- // const [timelineCollapsed, setTimelineCollapsed] = useState(true);
36
- // const urlParams = Location.search();
37
-
38
- // /**
39
- // * useEffect: Load process data on initial mount or when `currentProcessId` changes.
40
- // * This hook is responsible for fetching the process steps, initializing timings from localStorage,
41
- // * and setting the start time for the process and the first step.
42
- // */
43
- // useEffect(() => {
44
- // loadProcess(currentProcessId);
45
- // // Load existing timings from localStorage or initialize
46
- // const savedTimings = localStorage.getItem(`processTimings_${currentProcessId}`);
47
- // setProcessTimings(savedTimings ? JSON.parse(savedTimings) : []);
48
- // setProcessStartTime(Date.now()); // Record the start time of the process
49
- // // setActiveStep(0); // Reset to the first step
50
- // setStepStartTime(Date.now()); // Start timer for the first step
51
- // }, [currentProcessId]);
52
-
53
- // /**
54
- // * useEffect: Reset the step timer whenever the active step changes.
55
- // * This ensures that the duration for each step is calculated accurately from the moment it becomes active.
56
- // */
57
- // useEffect(() => {
58
- // setStepStartTime(Date.now());
59
- // }, [activeStep]);
60
-
61
- // /**
62
- // * useEffect: Manage the completion status of the current step.
63
- // * If a step is not mandatory, it is considered "completed" by default,
64
- // * allowing the user to proceed. For mandatory steps, the component waits for
65
- // * a signal from the child component via `onStepComplete`.
66
- // */
67
- // useEffect(() => {
68
- // if (steps.length > 0) {
69
- // const currentStep = steps[activeStep];
70
- // // If the new step is not mandatory, we can proceed.
71
- // // Otherwise, we must wait for the child component to signal completion.
72
- // setIsStepCompleted(currentStep?.is_mandatory !== true);
73
- // }
74
- // }, [activeStep, steps]);
75
-
76
- // /**
77
- // * Callback passed to child components to signal completion of a mandatory step.
78
- // * This allows the parent to enable the "Next" button.
79
- // */
80
- // const handleStepCompletion = () => {
81
- // setIsStepCompleted(true);
82
- // };
83
-
84
- // /**
85
- // * Records the timing information for the current step.
86
- // * Calculates duration, formats start/end times, and adds or updates the step's
87
- // * timing data in the `processTimings` array.
88
- // * @param {Array} currentTimings - The existing array of step timings.
89
- // * @param {string} [status='completed'] - The status of the step ('completed' or 'skipped').
90
- // * @returns {Array} The updated array of step timings.
91
- // */
92
- // const recordStepTime = useCallback(
93
- // (currentTimings, status = 'completed') => {
94
- // if (stepStartTime && steps[activeStep]) {
95
- // const endTime = Date.now();
96
- // const duration = endTime - stepStartTime;
97
- // const currentStepId = steps[activeStep].step_id;
98
-
99
- // const existingEntryIndex = currentTimings.findIndex((t) => t.step_id === currentStepId);
100
- // const newTimings = [...currentTimings];
101
-
102
- // const newEntry = {
103
- // step_id: currentStepId,
104
- // start_time: moment(stepStartTime).format('DD-MM-YYYY HH:mm:ss'),
105
- // end_time: moment(endTime).format('DD-MM-YYYY HH:mm:ss'),
106
- // duration: duration,
107
- // status: status,
108
- // };
109
-
110
- // if (existingEntryIndex > -1) {
111
- // // If re-visiting a step, add to duration and update end_time/status
112
- // newTimings[existingEntryIndex].duration += duration;
113
- // newTimings[existingEntryIndex].start_time = newEntry.start_time;
114
- // newTimings[existingEntryIndex].end_time = newEntry.end_time;
115
- // newTimings[existingEntryIndex].status = newEntry.status;
116
- // } else {
117
- // newTimings.push(newEntry);
118
- // }
119
- // return newTimings;
120
- // }
121
- // return currentTimings; // Return original if no update
122
- // },
123
- // [activeStep, steps, stepStartTime]
124
- // );
125
-
126
- // /**
127
- // * Fetches the process data, including steps and next process information, from the API.
128
- // * @param {number} processId - The ID of the process to load.
129
- // * @returns {Promise<object|undefined>} A promise that resolves with the process data.
130
- // */
131
- // async function loadProcess(processId) {
132
- // setLoading(true);
133
- // setNextProcessId(null); // Reset on each new process load
134
-
135
- // try {
136
- // const response = await fetch(`http://localhost:8002/dev/process/${processId}`, {
137
- // method: 'GET',
138
- // headers: {
139
- // 'Content-Type': 'application/json',
140
- // },
141
- // });
142
-
143
- // if (!response.ok) {
144
- // throw new Error('API request failed');
145
- // }
146
-
147
- // const result = await response.json();
148
-
149
- // const fetchedSteps = result?.data?.steps || [];
150
-
151
- // if (result?.data?.next_process_id) {
152
- // setNextProcessId(result.data);
153
- // //next_process_id
154
- // }
155
- // setLoading(false);
156
-
157
- // setSteps(fetchedSteps);
158
-
159
- // return result.data;
160
- // } catch (e) {
161
- // console.error('Error loading process steps:', e);
162
- // } finally {
163
- // setLoading(false);
164
- // }
165
- // }
166
-
167
- // /**
168
- // * Submits the collected process and step timings to the backend API.
169
- // * On success, it clears the timings from localStorage.
170
- // * On failure, it saves the latest timings to localStorage for a potential retry.
171
- // * @param {Array} finalStepTimings - The complete array of timings for all steps.
172
- // * @returns {Promise<boolean>} A promise that resolves to `true` on success and `false` on failure.
173
- // */
174
- // const submitProcessTimings = useCallback(
175
- // async (finalStepTimings) => {
176
- // const payload = {
177
- // process_id: currentProcessId,
178
- // status: 'completed',
179
- // reference_id: urlParams?.opb_id || urlParams?.reference_id,
180
- // reference_number: urlParams?.opno || urlParams?.reference_number,
181
- // mode: urlParams?.mode,
182
- // process: {
183
- // process_start_time: moment(processStartTime).format('DD-MM-YYYY HH:mm'),
184
- // process_end_time: moment().format('DD-MM-YYYY HH:mm'),
185
- // steps: finalStepTimings,
186
- // },
187
- // };
188
-
189
- // console.log('Submitting process timings:', payload);
190
-
191
- // try {
192
- // const response = await fetch(`http://localhost:8002/dev/process/process-log`, {
193
- // method: 'POST',
194
- // headers: { 'Content-Type': 'application/json' },
195
- // body: JSON.stringify(payload),
196
- // });
197
-
198
- // if (!response.ok) {
199
- // throw new Error('API request to save timings failed');
200
- // }
201
-
202
- // console.log('Process timings saved successfully.'); // Clear timings from state and localStorage after successful submission
203
- // localStorage.removeItem(`processTimings_${currentProcessId}`);
204
- // setProcessTimings([]);
205
- // return true; // Indicate success
206
- // } catch (e) {
207
- // console.error('Error saving process timings:', e);
208
- // // If API fails, save the latest timings to state and localStorage to allow for a retry.
209
- // setProcessTimings(finalStepTimings);
210
- // localStorage.setItem(`processTimings_${currentProcessId}`, JSON.stringify(finalStepTimings));
211
- // return false; // Indicate failure
212
- // }
213
- // },
214
- // [processStartTime, currentProcessId]
215
- // );
216
-
217
- // /**
218
- // * Handles the transition to the next process.
219
- // * It submits the timings for the current process and, upon success,
220
- // * loads and transitions to the next process.
221
- // */
222
- // const handleStartNextProcess = async () => {
223
- // const finalTimings = recordStepTime(processTimings, 'completed');
224
- // const success = await submitProcessTimings(finalTimings);
225
- // if (success) {
226
- // const nextProcess = await loadProcess(nextProcessId.next_process_id);
227
- // setCurrentProcessId(nextProcessId.next_process_id); // Trigger the useEffect to load the next process
228
- // setActiveStep(0);
229
- // }
230
- // };
231
-
232
- // /**
233
- // * Handles the final submission when the entire workflow is finished.
234
- // * Submits the final timings and navigates the user back to the previous page
235
- // * (e.g., a listing page).
236
- // */
237
- // const handleFinish = useCallback(async () => {
238
- // const finalTimings = recordStepTime(processTimings, 'completed');
239
- // const success = await submitProcessTimings(finalTimings);
240
- // if (success && props.history) {
241
- // props.history.goBack(); // Navigate to the previous page
242
- // }
243
- // }, [processTimings, recordStepTime, submitProcessTimings, props.history]);
244
-
245
- // /**
246
- // * Dynamically renders the component for the active step.
247
- // * It looks up the component name from the step's configuration and passes
248
- // * the necessary props to it.
249
- // * @returns {JSX.Element} The component for the current step or an Empty state.
250
- // */
251
- // const renderDynamicComponent = () => {
252
- // const step = steps[activeStep];
253
- // if (!step) return <Empty description="No step selected" />;
254
-
255
- // const componentName = step.related_page;
256
- // const LoadedComponent = allComponents[componentName];
257
-
258
- // if (!LoadedComponent) {
259
- // return <Empty description={`Component "${componentName}" not found`} />;
260
- // }
261
-
262
- // return <LoadedComponent {...step.config} {...props} step={step} params={urlParams} refresh={loadProcess} onStepComplete={handleStepCompletion} />;
263
- // };
264
-
265
- // /**
266
- // * Handles the "Next" button click. Records the current step's time and advances to the next step.
267
- // */
268
- // const handleNext = () => {
269
- // const updatedTimings = recordStepTime(processTimings, 'completed');
270
- // setProcessTimings(updatedTimings);
271
- // localStorage.setItem(`processTimings_${currentProcessId}`, JSON.stringify(updatedTimings));
272
- // setActiveStep(activeStep + 1);
273
- // };
274
-
275
- // /**
276
- // * Handles the "Back" button click. Records the current step's time and moves to the previous step.
277
- // */
278
- // const handlePrevious = () => {
279
- // const updatedTimings = recordStepTime(processTimings, 'completed');
280
- // setProcessTimings(updatedTimings);
281
- // localStorage.setItem(`processTimings_${currentProcessId}`, JSON.stringify(updatedTimings));
282
- // setActiveStep(activeStep - 1);
283
- // };
284
-
285
- // /**
286
- // * Handles clicks on the timeline steps. Records the current step's time and navigates to the clicked step.
287
- // * @param {number} index - The index of the step that was clicked.
288
- // */
289
- // const handleTimelineClick = (index) => {
290
- // const updatedTimings = recordStepTime(processTimings, 'completed');
291
- // setProcessTimings(updatedTimings);
292
- // localStorage.setItem(`processTimings_${currentProcessId}`, JSON.stringify(updatedTimings));
293
- // setActiveStep(index);
294
- // };
295
-
296
- // /**
297
- // * Handles the "Skip" button click. Records the current step with a 'skipped' status and advances to the next step.
298
- // */
299
- // const handleSkip = () => {
300
- // const updatedTimings = recordStepTime(processTimings, 'skipped');
301
- // setProcessTimings(updatedTimings);
302
- // localStorage.setItem(`processTimings_${currentProcessId}`, JSON.stringify(updatedTimings));
303
- // setActiveStep(activeStep + 1);
304
- // };
305
-
306
- // return (
307
- // <Card style={{ padding: 20 }}>
308
- // <Row gutter={20}>
309
- // {/* LEFT PANEL – Step Timeline */}
310
- // <Col span={timelineCollapsed ? 2 : 6}>
311
- // <LeftTimelinePanel
312
- // loading={loading}
313
- // steps={steps}
314
- // activeStep={activeStep}
315
- // timelineCollapsed={timelineCollapsed}
316
- // handleTimelineClick={handleTimelineClick}
317
- // setTimelineCollapsed={setTimelineCollapsed}
318
- // />
319
- // </Col>
320
-
321
- // {/* RIGHT PANEL – Dynamic Component Loading */}
322
- // <Col span={timelineCollapsed ? 22 : 18}>
323
- // <Col span={timelineCollapsed ? 22 : 18}>
324
- // <RenderDynamicComponent
325
- // loading={loading}
326
- // steps={steps}
327
- // activeStep={activeStep}
328
- // isStepCompleted={isStepCompleted}
329
- // renderDynamicComponent={renderDynamicComponent}
330
- // handlePrevious={handlePrevious}
331
- // handleNext={handleNext}
332
- // handleSkip={handleSkip}
333
- // handleFinish={handleFinish}
334
- // handleStartNextProcess={handleStartNextProcess}
335
- // nextProcessId={nextProcessId}
336
- // timelineCollapsed={timelineCollapsed}
337
- // />
338
- // </Col>
339
- // </Col>
340
- // </Row>
341
- // </Card>
342
- // );
343
- // }
344
- export default function ProcessStepsPage({ processId = 1, match, CustomComponents = {}, ...props }) {
23
+ export default function ProcessStepsPage({ processId, match, CustomComponents = {}, ...props }) {
345
24
  const allComponents = { ...genericComponents, ...CustomComponents };
346
25
 
347
26
  const [loading, setLoading] = useState(false);
@@ -354,9 +33,10 @@ export default function ProcessStepsPage({ processId = 1, match, CustomComponent
354
33
  const [processStartTime, setProcessStartTime] = useState(null);
355
34
  const [processTimings, setProcessTimings] = useState([]);
356
35
  const [timelineCollapsed, setTimelineCollapsed] = useState(true);
357
-
36
+ const [showExternalWindow, setShowExternalWindow] = useState(false);
358
37
  const urlParams = Location.search();
359
38
 
39
+ // Load process details based on the current process ID
360
40
  useEffect(() => {
361
41
  loadProcess(currentProcessId);
362
42
 
@@ -367,30 +47,40 @@ export default function ProcessStepsPage({ processId = 1, match, CustomComponent
367
47
  setStepStartTime(Date.now());
368
48
  }, [currentProcessId]);
369
49
 
50
+ //// Reset step start time whenever the active step changes
51
+
370
52
  useEffect(() => {
371
53
  setStepStartTime(Date.now());
372
54
  }, [activeStep]);
373
55
 
56
+ // Check whether the current step is completed or mandatory
374
57
  useEffect(() => {
375
58
  if (steps.length > 0) {
376
59
  setIsStepCompleted(steps[activeStep]?.is_mandatory !== true);
377
60
  }
378
61
  }, [activeStep, steps]);
379
62
 
63
+ // Save updated process timings to state and localStorage
380
64
  const saveTimings = (updated) => {
381
65
  setProcessTimings(updated);
382
66
  localStorage.setItem(`processTimings_${currentProcessId}`, JSON.stringify(updated));
383
67
  };
68
+ // Record time spent on the current step
384
69
 
385
70
  const recordStepTime = (status = 'completed') => {
71
+ // Exit if step start time or step data is missing
72
+
386
73
  if (!stepStartTime || !steps[activeStep]) return processTimings;
74
+ // Capture end time and calculate duration
387
75
 
388
76
  const endTime = Date.now();
389
77
  const duration = endTime - stepStartTime;
390
78
  const stepId = steps[activeStep].step_id;
79
+ // Clone existing timings
391
80
 
392
81
  const updated = [...processTimings];
393
82
  const index = updated.findIndex((t) => t.step_id === stepId);
83
+ // Create timing entry for the step
394
84
 
395
85
  const entry = {
396
86
  step_id: stepId,
@@ -399,7 +89,7 @@ export default function ProcessStepsPage({ processId = 1, match, CustomComponent
399
89
  duration,
400
90
  status,
401
91
  };
402
-
92
+ // Update existing entry or add a new one
403
93
  if (index > -1) {
404
94
  updated[index] = { ...updated[index], ...entry, duration: updated[index].duration + duration };
405
95
  } else {
@@ -409,6 +99,15 @@ export default function ProcessStepsPage({ processId = 1, match, CustomComponent
409
99
  return updated;
410
100
  };
411
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
+ */
412
111
  async function loadProcess(processId) {
413
112
  setLoading(true);
414
113
  setNextProcessId(null);
@@ -424,7 +123,15 @@ export default function ProcessStepsPage({ processId = 1, match, CustomComponent
424
123
  setLoading(false);
425
124
  }
426
125
  }
427
-
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
+ */
428
135
  const handleProcessSubmit = async (finalTimings) => {
429
136
  const payload = {
430
137
  process_id: currentProcessId,
@@ -453,32 +160,73 @@ export default function ProcessStepsPage({ processId = 1, match, CustomComponent
453
160
  }
454
161
  return false;
455
162
  };
456
-
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
+ */
457
172
  const gotoStep = (index, status = 'completed') => {
458
173
  const updated = recordStepTime(status);
459
174
  saveTimings(updated);
460
175
  setActiveStep(index);
461
176
  };
462
-
177
+ /**
178
+ * Navigate to the next step
179
+ * - Records timing data and advances step index by one.
180
+ */
463
181
  const handleNext = () => gotoStep(activeStep + 1);
182
+ /**
183
+ * Navigate to the previous step
184
+ * - Records timing data and moves to the previous step.
185
+ */
464
186
  const handlePrevious = () => gotoStep(activeStep - 1);
187
+ /**
188
+ * Skip current step
189
+ * - Records timing with skipped status.
190
+ * - Moves to the next step.
191
+ */
465
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
+ */
466
198
  const handleTimelineClick = (i) => gotoStep(i);
467
-
199
+ /**
200
+ * Process Completion
201
+ * - Records final step timing.
202
+ * - Submits process completion data.
203
+ * - Navigates back on successful completion.
204
+ */
468
205
  const handleFinish = async () => {
469
206
  const final = recordStepTime();
470
207
  if (await handleProcessSubmit(final)) props.history?.goBack();
471
208
  };
472
-
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
+ */
473
215
  const handleStartNextProcess = async () => {
474
216
  const final = recordStepTime();
475
217
  if (await handleProcessSubmit(final)) {
476
218
  await loadProcess(nextProcessId.next_process_id);
477
219
  setCurrentProcessId(nextProcessId.next_process_id);
478
220
  setActiveStep(0);
221
+ setShowExternalWindow(true);
479
222
  }
480
223
  };
481
-
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
+ */
482
230
  const DynamicComponent = () => {
483
231
  const step = steps[activeStep];
484
232
  if (!step) return <Empty description="No step selected" />;
@@ -488,7 +236,12 @@ export default function ProcessStepsPage({ processId = 1, match, CustomComponent
488
236
 
489
237
  return <Component {...step.config} {...props} step={step} params={urlParams} onStepComplete={() => setIsStepCompleted(true)} />;
490
238
  };
491
-
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
+ */
492
245
  useEffect(() => {
493
246
  const handleKeyDown = (event) => {
494
247
  // Handle Left Arrow key press to go to the previous step
@@ -509,13 +262,15 @@ export default function ProcessStepsPage({ processId = 1, match, CustomComponent
509
262
  return () => window.removeEventListener('keydown', handleKeyDown);
510
263
  }, [activeStep, steps, handlePrevious, handleNext]);
511
264
 
512
- return (
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 = () => (
513
271
  <Card>
514
272
  <Row gutter={20}>
515
273
  <Col xs={24} sm={24} lg={timelineCollapsed ? 2 : 6}>
516
-
517
-
518
-
519
274
  <TimelinePanel
520
275
  loading={loading}
521
276
  steps={steps}
@@ -526,8 +281,7 @@ export default function ProcessStepsPage({ processId = 1, match, CustomComponent
526
281
  />
527
282
  </Col>
528
283
 
529
- <Col xs={24} sm={24} lg={timelineCollapsed ? 21 : 18}>
530
-
284
+ <Col xs={24} sm={24} lg={timelineCollapsed ? 21 : 18}>
531
285
  <div style={{ marginBottom: 20 }}>
532
286
  <h2 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{steps[activeStep]?.step_name}</h2>
533
287
  <p style={{ margin: 0, color: '#666' }}>{steps[activeStep]?.step_description}</p>
@@ -550,4 +304,29 @@ export default function ProcessStepsPage({ processId = 1, match, CustomComponent
550
304
  </Row>
551
305
  </Card>
552
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();
553
332
  }
@@ -1,7 +1,7 @@
1
1
  .timeline-card .ant-card-body {
2
2
  padding: 20px;
3
3
  min-height: 400px;
4
- position: relative; /* For positioning the arrow */
4
+ position: fixed; /* For positioning the arrow */
5
5
  }
6
6
 
7
7
  .timeline-sidebar {
@@ -1,28 +1,33 @@
1
- import React from "react";
2
- import { Skeleton } from "antd";
3
- import { LeftOutlined, RightOutlined } from "@ant-design/icons";
4
- import { Card } from "../../lib";
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
- export default function TimelinePanel({
7
- loading,
8
- steps,
9
- activeStep,
10
- timelineCollapsed,
11
- handleTimelineClick,
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 ? "collapsed" : ""}`}>
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 ? "active" : ""}
25
- ${index < activeStep ? "completed" : ""}`}
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
@@ -0,0 +1,8 @@
1
+ module.exports = {
2
+ testEnvironment: 'jsdom',
3
+ setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
4
+ transform: {
5
+ '^.+\\.js$': 'babel-jest',
6
+ '\\.(css|scss)$': 'jest-transform-stub',
7
+ },
8
+ };
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.17",
3
+ "version": "2.4.25-dev.21",
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": "echo \"Error: no test specified\" && exit 1",
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.10.4",
86
- "@babel/preset-react": "^7.10.4",
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",