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.
- 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/steps/action-buttons.js +61 -52
- package/core/modules/steps/steps.js +130 -351
- package/core/modules/steps/steps.scss +1 -1
- 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';
|
|
@@ -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,347 +1,26 @@
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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.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": "
|
|
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",
|