ui-soxo-bootstrap-core 2.4.25-dev.18 → 2.4.25-dev.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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';
@@ -614,6 +614,8 @@ function GuestList({
614
614
  {
615
615
  title: '#',
616
616
  dataIndex: 'index',
617
+ key: 'ColumnIndex',
618
+ width: 60,
617
619
  render: (value, item, index) => index + 1,
618
620
  key: 'ColumnIndex',
619
621
  fixed: isFixedIndex ? 'left' : null,
@@ -777,6 +779,8 @@ function GuestList({
777
779
  field: entry.field,
778
780
  title: entry.title,
779
781
  key: entry.field,
782
+ width: entry.width || 160,
783
+ ellipsis: true,
780
784
  fixed: entry.isFixedColumn ? entry.isFixedColumn : null, // Conditionally setting the 'fixed' key to 'left' if 'isColumnStatic' is true; otherwise, setting it to null.
781
785
  // Check if filtering is enabled and patients is an array
782
786
  filters:
@@ -1042,7 +1046,8 @@ function GuestList({
1042
1046
  ) : (
1043
1047
  <TableComponent
1044
1048
  size="small"
1045
- scroll={{ x: true, y: '60vh' }}
1049
+ scroll={{ x: 'max-content', y: '60vh' }}
1050
+ tableLayout="fixed"
1046
1051
  sticky
1047
1052
  rowKey={(record) => record.OpNo}
1048
1053
  dataSource={filtered ? filtered : patients} // In case if there is no filtered values we can use patient data
@@ -1,5 +1,10 @@
1
+ /**
2
+ * ActionButtons
3
+ * Handles navigation and action controls for a multi-step process,
4
+ * including dynamic content rendering and process completion actions.
5
+ */
1
6
  import React from 'react';
2
- import { 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,15 +1,24 @@
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
23
  export default function ProcessStepsPage({ processId, match, CustomComponents = {}, ...props }) {
15
24
  const allComponents = { ...genericComponents, ...CustomComponents };
@@ -24,9 +33,10 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
24
33
  const [processStartTime, setProcessStartTime] = useState(null);
25
34
  const [processTimings, setProcessTimings] = useState([]);
26
35
  const [timelineCollapsed, setTimelineCollapsed] = useState(true);
27
-
36
+ const [showExternalWindow, setShowExternalWindow] = useState(false);
28
37
  const urlParams = Location.search();
29
38
 
39
+ // Load process details based on the current process ID
30
40
  useEffect(() => {
31
41
  loadProcess(currentProcessId);
32
42
 
@@ -37,30 +47,40 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
37
47
  setStepStartTime(Date.now());
38
48
  }, [currentProcessId]);
39
49
 
50
+ //// Reset step start time whenever the active step changes
51
+
40
52
  useEffect(() => {
41
53
  setStepStartTime(Date.now());
42
54
  }, [activeStep]);
43
55
 
56
+ // Check whether the current step is completed or mandatory
44
57
  useEffect(() => {
45
58
  if (steps.length > 0) {
46
59
  setIsStepCompleted(steps[activeStep]?.is_mandatory !== true);
47
60
  }
48
61
  }, [activeStep, steps]);
49
62
 
63
+ // Save updated process timings to state and localStorage
50
64
  const saveTimings = (updated) => {
51
65
  setProcessTimings(updated);
52
66
  localStorage.setItem(`processTimings_${currentProcessId}`, JSON.stringify(updated));
53
67
  };
68
+ // Record time spent on the current step
54
69
 
55
70
  const recordStepTime = (status = 'completed') => {
71
+ // Exit if step start time or step data is missing
72
+
56
73
  if (!stepStartTime || !steps[activeStep]) return processTimings;
74
+ // Capture end time and calculate duration
57
75
 
58
76
  const endTime = Date.now();
59
77
  const duration = endTime - stepStartTime;
60
78
  const stepId = steps[activeStep].step_id;
79
+ // Clone existing timings
61
80
 
62
81
  const updated = [...processTimings];
63
82
  const index = updated.findIndex((t) => t.step_id === stepId);
83
+ // Create timing entry for the step
64
84
 
65
85
  const entry = {
66
86
  step_id: stepId,
@@ -69,7 +89,7 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
69
89
  duration,
70
90
  status,
71
91
  };
72
-
92
+ // Update existing entry or add a new one
73
93
  if (index > -1) {
74
94
  updated[index] = { ...updated[index], ...entry, duration: updated[index].duration + duration };
75
95
  } else {
@@ -79,6 +99,15 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
79
99
  return updated;
80
100
  };
81
101
 
102
+ /**
103
+ * @param {*} processId
104
+ *
105
+ * Process Loading
106
+ * - Fetches process details and step configuration using the process ID.
107
+ * - Manages loading state during the API call.
108
+ * - Stores step data and prepares next process details if available.
109
+ * - Handles API errors and maintains UI stability.
110
+ */
82
111
  async function loadProcess(processId) {
83
112
  setLoading(true);
84
113
  setNextProcessId(null);
@@ -94,7 +123,15 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
94
123
  setLoading(false);
95
124
  }
96
125
  }
97
-
126
+ /**
127
+ * @param {*} finalTimings
128
+ *
129
+ * Process Submission
130
+ * - Builds payload with process metadata, reference details, and step timings.
131
+ * - Submits process completion data to the backend.
132
+ * - Clears stored timings on successful submission.
133
+ * - Persists timing data locally if submission fails.
134
+ */
98
135
  const handleProcessSubmit = async (finalTimings) => {
99
136
  const payload = {
100
137
  process_id: currentProcessId,
@@ -123,32 +160,73 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
123
160
  }
124
161
  return false;
125
162
  };
126
-
163
+ /**
164
+ * @param {number} index
165
+ * @param {string} status
166
+ *
167
+ * Step Navigation
168
+ * - Records time spent on the current step.
169
+ * - Saves updated step timing data.
170
+ * - Navigates to the specified step index.
171
+ */
127
172
  const gotoStep = (index, status = 'completed') => {
128
173
  const updated = recordStepTime(status);
129
174
  saveTimings(updated);
130
175
  setActiveStep(index);
131
176
  };
132
-
177
+ /**
178
+ * Navigate to the next step
179
+ * - Records timing data and advances step index by one.
180
+ */
133
181
  const handleNext = () => gotoStep(activeStep + 1);
182
+ /**
183
+ * Navigate to the previous step
184
+ * - Records timing data and moves to the previous step.
185
+ */
134
186
  const handlePrevious = () => gotoStep(activeStep - 1);
187
+ /**
188
+ * Skip current step
189
+ * - Records timing with skipped status.
190
+ * - Moves to the next step.
191
+ */
135
192
  const handleSkip = () => gotoStep(activeStep + 1, 'skipped');
193
+ /**
194
+ * Timeline Navigation
195
+ * - Navigates directly to the selected step.
196
+ * - Records timing data for the current step.
197
+ */
136
198
  const handleTimelineClick = (i) => gotoStep(i);
137
-
199
+ /**
200
+ * Process Completion
201
+ * - Records final step timing.
202
+ * - Submits process completion data.
203
+ * - Navigates back on successful completion.
204
+ */
138
205
  const handleFinish = async () => {
139
206
  const final = recordStepTime();
140
207
  if (await handleProcessSubmit(final)) props.history?.goBack();
141
208
  };
142
-
209
+ /**
210
+ * Start Next Process
211
+ * - Records final timing of the current process.
212
+ * - Submits current process data.
213
+ * - Loads and initializes the next linked process.
214
+ */
143
215
  const handleStartNextProcess = async () => {
144
216
  const final = recordStepTime();
145
217
  if (await handleProcessSubmit(final)) {
146
218
  await loadProcess(nextProcessId.next_process_id);
147
219
  setCurrentProcessId(nextProcessId.next_process_id);
148
220
  setActiveStep(0);
221
+ setShowExternalWindow(true);
149
222
  }
150
223
  };
151
-
224
+ /**
225
+ * Dynamic Step Renderer
226
+ * - Resolves and renders step-specific components dynamically.
227
+ * - Passes configuration, parameters, and handlers to the component.
228
+ * - Handles missing steps or components gracefully.
229
+ */
152
230
  const DynamicComponent = () => {
153
231
  const step = steps[activeStep];
154
232
  if (!step) return <Empty description="No step selected" />;
@@ -158,7 +236,12 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
158
236
 
159
237
  return <Component {...step.config} {...props} step={step} params={urlParams} onStepComplete={() => setIsStepCompleted(true)} />;
160
238
  };
161
-
239
+ /**
240
+ * Keyboard Navigation
241
+ * - Enables left and right arrow keys for step navigation.
242
+ * - Prevents navigation beyond step boundaries.
243
+ * - Cleans up event listeners on unmount.
244
+ */
162
245
  useEffect(() => {
163
246
  const handleKeyDown = (event) => {
164
247
  // Handle Left Arrow key press to go to the previous step
@@ -179,13 +262,15 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
179
262
  return () => window.removeEventListener('keydown', handleKeyDown);
180
263
  }, [activeStep, steps, handlePrevious, handleNext]);
181
264
 
182
- 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 = () => (
183
271
  <Card>
184
272
  <Row gutter={20}>
185
273
  <Col xs={24} sm={24} lg={timelineCollapsed ? 2 : 6}>
186
-
187
-
188
-
189
274
  <TimelinePanel
190
275
  loading={loading}
191
276
  steps={steps}
@@ -196,8 +281,7 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
196
281
  />
197
282
  </Col>
198
283
 
199
- <Col xs={24} sm={24} lg={timelineCollapsed ? 21 : 18}>
200
-
284
+ <Col xs={24} sm={24} lg={timelineCollapsed ? 21 : 18}>
201
285
  <div style={{ marginBottom: 20 }}>
202
286
  <h2 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{steps[activeStep]?.step_name}</h2>
203
287
  <p style={{ margin: 0, color: '#666' }}>{steps[activeStep]?.step_description}</p>
@@ -220,4 +304,29 @@ export default function ProcessStepsPage({ processId, match, CustomComponents =
220
304
  </Row>
221
305
  </Card>
222
306
  );
307
+ /**
308
+ * Renders content in both the main window and an external window
309
+ * when external window mode is enabled.
310
+ */
311
+ if (showExternalWindow && props.showExternalWindow) {
312
+ return (
313
+ <>
314
+ <ExternalWindow
315
+ title={steps[activeStep]?.step_name || 'Process Step'}
316
+ onClose={() => setShowExternalWindow(false)}
317
+ // left={window.screenX + window.outerWidth}
318
+ // top={window.screenY}
319
+ width={props.ExternalWindowWidth || 1000}
320
+ height={props.ExternalWindowHeight || 1000}
321
+ >
322
+ {renderContent()}
323
+ </ExternalWindow>
324
+ {renderContent()}
325
+ </>
326
+ );
327
+ }
328
+ /**
329
+ * Default render when external window mode is disabled.
330
+ */
331
+ return renderContent();
223
332
  }
@@ -1,28 +1,33 @@
1
- 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.18",
3
+ "version": "2.4.25-dev.22",
4
4
  "description": "All the Core Components for you to start",
5
5
  "keywords": [
6
6
  "all in one"
@@ -13,7 +13,7 @@
13
13
  "main": "index.js",
14
14
  "scripts": {
15
15
  "size": "size-limit",
16
- "test": "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",