ui-soxo-bootstrap-core 2.4.26 → 2.5.1

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,346 @@
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';
14
+ import * as genericComponents from './../../lib';
15
+ import moment from 'moment';
16
+ import { Location } from './../../lib';
17
+ import ActionButtons from './action-buttons';
18
+ import { Dashboard } from '../../models';
19
+ import './steps.scss';
20
+ import TimelinePanel from './timeline';
21
+ import { ExternalWindow } from '../../components';
22
+
23
+ export default function ProcessStepsPage({ match, CustomComponents = {}, ...props }) {
24
+ const allComponents = { ...genericComponents, ...CustomComponents };
25
+
26
+ const [loading, setLoading] = useState(false);
27
+ const [steps, setSteps] = useState([]);
28
+ const [activeStep, setActiveStep] = useState(0);
29
+ const [isStepCompleted, setIsStepCompleted] = useState(false);
30
+
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 [showExternalWindow, setShowExternalWindow] = useState(false);
37
+ const [externalWin, setExternalWin] = useState(null);
38
+
39
+ const urlParams = Location.search();
40
+ let processId = urlParams.processId;
41
+ const [currentProcessId, setCurrentProcessId] = useState(processId);
42
+ // Load process details based on the current process ID
43
+ useEffect(() => {
44
+ loadProcess(currentProcessId);
45
+
46
+ const saved = localStorage.getItem(`processTimings_${currentProcessId}`);
47
+ setProcessTimings(saved ? JSON.parse(saved) : []);
48
+
49
+ setProcessStartTime(Date.now());
50
+ setStepStartTime(Date.now());
51
+ }, [currentProcessId]);
52
+
53
+ //// Reset step start time whenever the active step changes
54
+
55
+ useEffect(() => {
56
+ setStepStartTime(Date.now());
57
+ }, [activeStep]);
58
+
59
+ // Check whether the current step is completed or mandatory
60
+ useEffect(() => {
61
+ if (steps.length > 0) {
62
+ setIsStepCompleted(steps[activeStep]?.is_mandatory !== true);
63
+ }
64
+ }, [activeStep, steps]);
65
+
66
+ // Save updated process timings to state and localStorage
67
+ const saveTimings = (updated) => {
68
+ setProcessTimings(updated);
69
+ localStorage.setItem(`processTimings_${currentProcessId}`, JSON.stringify(updated));
70
+ };
71
+ // Record time spent on the current step
72
+
73
+ const recordStepTime = (status = 'completed') => {
74
+ // Exit if step start time or step data is missing
75
+
76
+ if (!stepStartTime || !steps[activeStep]) return processTimings;
77
+ // Capture end time and calculate duration
78
+
79
+ const endTime = Date.now();
80
+ const duration = endTime - stepStartTime;
81
+ const stepId = steps[activeStep].step_id;
82
+ // Clone existing timings
83
+
84
+ const updated = [...processTimings];
85
+ const index = updated.findIndex((t) => t.step_id === stepId);
86
+ // Create timing entry for the step
87
+
88
+ const entry = {
89
+ step_id: stepId,
90
+ start_time: moment(stepStartTime).format('DD-MM-YYYY HH:mm:ss'),
91
+ end_time: moment(endTime).format('DD-MM-YYYY HH:mm:ss'),
92
+ duration,
93
+ status,
94
+ };
95
+ // Update existing entry or add a new one
96
+ if (index > -1) {
97
+ updated[index] = { ...updated[index], ...entry, duration: updated[index].duration + duration };
98
+ } else {
99
+ updated.push(entry);
100
+ }
101
+
102
+ return updated;
103
+ };
104
+
105
+ /**
106
+ * @param {*} processId
107
+ *
108
+ * Process Loading
109
+ * - Fetches process details and step configuration using the process ID.
110
+ * - Manages loading state during the API call.
111
+ * - Stores step data and prepares next process details if available.
112
+ * - Handles API errors and maintains UI stability.
113
+ */
114
+ async function loadProcess(processId) {
115
+ setLoading(true);
116
+ setNextProcessId(null);
117
+
118
+ try {
119
+ const result = await Dashboard.loadProcess(processId);
120
+
121
+ setSteps(result?.data?.steps || []);
122
+ if (result?.data?.next_process_id) setNextProcessId(result.data);
123
+ } catch (e) {
124
+ console.error('Error loading process steps:', e);
125
+ } finally {
126
+ setLoading(false);
127
+ }
128
+ }
129
+ /**
130
+ * @param {*} finalTimings
131
+ *
132
+ * Process Submission
133
+ * - Builds payload with process metadata, reference details, and step timings.
134
+ * - Submits process completion data to the backend.
135
+ * - Clears stored timings on successful submission.
136
+ * - Persists timing data locally if submission fails.
137
+ */
138
+ const handleProcessSubmit = async (finalTimings) => {
139
+ const payload = {
140
+ process_id: currentProcessId,
141
+ status: 'completed',
142
+ reference_id: urlParams?.opb_id || urlParams?.reference_id,
143
+ reference_number: urlParams?.opno || urlParams?.reference_number,
144
+ mode: urlParams?.mode,
145
+ process: {
146
+ process_start_time: moment(processStartTime).format('DD-MM-YYYY HH:mm'),
147
+ process_end_time: moment().format('DD-MM-YYYY HH:mm'),
148
+ steps: finalTimings,
149
+ },
150
+ };
151
+
152
+ try {
153
+ const response = await Dashboard.processLog(payload);
154
+
155
+ if (response.success) {
156
+ localStorage.removeItem(`processTimings_${currentProcessId}`);
157
+ setProcessTimings([]);
158
+ return true;
159
+ }
160
+ } catch (e) {
161
+ console.error('Error:', e);
162
+ saveTimings(finalTimings);
163
+ }
164
+ return false;
165
+ };
166
+ /**
167
+ * @param {number} index
168
+ * @param {string} status
169
+ *
170
+ * Step Navigation
171
+ * - Records time spent on the current step.
172
+ * - Saves updated step timing data.
173
+ * - Navigates to the specified step index.
174
+ */
175
+ const gotoStep = (index, status = 'completed') => {
176
+ const updated = recordStepTime(status);
177
+ saveTimings(updated);
178
+ setActiveStep(index);
179
+ };
180
+ /**
181
+ * Navigate to the next step
182
+ * - Records timing data and advances step index by one.
183
+ */
184
+ const handleNext = () => gotoStep(activeStep + 1);
185
+ /**
186
+ * Navigate to the previous step
187
+ * - Records timing data and moves to the previous step.
188
+ */
189
+ const handlePrevious = () => gotoStep(activeStep - 1);
190
+ /**
191
+ * Skip current step
192
+ * - Records timing with skipped status.
193
+ * - Moves to the next step.
194
+ */
195
+ const handleSkip = () => gotoStep(activeStep + 1, 'skipped');
196
+ /**
197
+ * Timeline Navigation
198
+ * - Navigates directly to the selected step.
199
+ * - Records timing data for the current step.
200
+ */
201
+ const handleTimelineClick = (i) => gotoStep(i);
202
+ /**
203
+ * Process Completion
204
+ * - Records final step timing.
205
+ * - Submits process completion data.
206
+ * - Navigates back on successful completion.
207
+ */
208
+ const handleFinish = async () => {
209
+ const final = recordStepTime();
210
+ const success = await handleProcessSubmit(final);
211
+ if (success && !nextProcessId) props.history?.goBack();
212
+ return success;
213
+ };
214
+ /**
215
+ * Start Next Process
216
+ * - Records final timing of the current process.
217
+ * - Submits current process data.
218
+ * - Loads and initializes the next linked process.
219
+ */
220
+ const handleStartNextProcess = async () => {
221
+ const final = recordStepTime();
222
+ if (await handleProcessSubmit(final)) {
223
+ await loadProcess(nextProcessId.next_process_id);
224
+ setCurrentProcessId(nextProcessId.next_process_id);
225
+ setActiveStep(0);
226
+ setShowExternalWindow(true);
227
+ }
228
+ };
229
+ /**
230
+ * Dynamic Step Renderer
231
+ * - Resolves and renders step-specific components dynamically.
232
+ * - Passes configuration, parameters, and handlers to the component.
233
+ * - Handles missing steps or components gracefully.
234
+ */
235
+ const DynamicComponent = () => {
236
+ const step = steps[activeStep];
237
+ if (!step) return <Empty description="No step selected" />;
238
+
239
+ const Component = allComponents[step.related_page];
240
+ if (!Component) return <Empty description={`Component "${step.related_page}" not found`} />;
241
+
242
+ return <Component {...step.config} {...props} step={step} params={urlParams} onStepComplete={() => setIsStepCompleted(true)} />;
243
+ };
244
+
245
+ useEffect(() => {
246
+ const handleKeyDown = (event) => {
247
+ if (event.key === 'ArrowLeft' && activeStep > 0) {
248
+ handlePrevious();
249
+ }
250
+
251
+ if (event.key === 'ArrowRight' && activeStep < steps.length - 1) {
252
+ handleNext();
253
+ }
254
+ };
255
+
256
+ // main window (document!)
257
+ document.addEventListener('keydown', handleKeyDown);
258
+
259
+ // external window (document!)
260
+ if (externalWin && externalWin.document) {
261
+ externalWin.document.addEventListener('keydown', handleKeyDown);
262
+ }
263
+
264
+ return () => {
265
+ document.removeEventListener('keydown', handleKeyDown);
266
+
267
+ if (externalWin && externalWin.document) {
268
+ externalWin.document.removeEventListener('keydown', handleKeyDown);
269
+ }
270
+ };
271
+ }, [activeStep, steps, externalWin]);
272
+
273
+ /**
274
+ * Renders the main process UI including timeline, step details,
275
+ * and action buttons. This content is reused in both normal view
276
+ * and external window view.
277
+ */
278
+ const renderContent = () => (
279
+ <div>
280
+ <Card>
281
+ <Row gutter={20}>
282
+ <Col xs={24} sm={24} lg={timelineCollapsed ? 2 : 6}>
283
+ <TimelinePanel
284
+ loading={loading}
285
+ steps={steps}
286
+ activeStep={activeStep}
287
+ timelineCollapsed={timelineCollapsed}
288
+ handleTimelineClick={handleTimelineClick}
289
+ setTimelineCollapsed={setTimelineCollapsed}
290
+ />
291
+ </Col>
292
+
293
+ <Col xs={24} sm={24} lg={timelineCollapsed ? 21 : 18}>
294
+ <div style={{ marginBottom: 20 }}>
295
+ <h2 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{steps[activeStep]?.step_name}</h2>
296
+ <p style={{ margin: 0, color: '#666' }}>{steps[activeStep]?.step_description}</p>
297
+ </div>
298
+ <ActionButtons
299
+ loading={loading}
300
+ steps={steps}
301
+ activeStep={activeStep}
302
+ isStepCompleted={isStepCompleted}
303
+ renderDynamicComponent={DynamicComponent}
304
+ handlePrevious={handlePrevious}
305
+ handleNext={handleNext}
306
+ handleSkip={handleSkip}
307
+ handleFinish={handleFinish}
308
+ handleStartNextProcess={handleStartNextProcess}
309
+ nextProcessId={nextProcessId}
310
+ timelineCollapsed={timelineCollapsed}
311
+ />
312
+ </Col>
313
+ </Row>
314
+ </Card>
315
+ </div>
316
+ );
317
+ /**
318
+ * Renders content in both the main window and an external window
319
+ * when external window mode is enabled.
320
+ */
321
+ if (showExternalWindow && props.showExternalWindow) {
322
+ return (
323
+ <>
324
+ <ExternalWindow
325
+ onWindowReady={(win) => {
326
+ setExternalWin(win);
327
+ win.focus();
328
+ }}
329
+ title={steps[activeStep]?.step_name || 'Process Step'}
330
+ onClose={() => setShowExternalWindow(false)}
331
+ // left={window.screenX + window.outerWidth}
332
+ // top={window.screenY}
333
+ width={props.ExternalWindowWidth || 1000}
334
+ height={props.ExternalWindowHeight || 1000}
335
+ >
336
+ {renderContent()}
337
+ </ExternalWindow>
338
+ {renderContent()}
339
+ </>
340
+ );
341
+ }
342
+ /**
343
+ * Default render when external window mode is disabled.
344
+ */
345
+ return renderContent();
346
+ }
@@ -0,0 +1,159 @@
1
+ .timeline-card .ant-card-body {
2
+ // padding: 20px;
3
+ border: none;
4
+ min-height: 400px;
5
+ // position: fixed; /* For positioning the arrow */
6
+ }
7
+
8
+ .timeline-sidebar {
9
+ display: flex;
10
+ flex-direction: column;
11
+ gap: 20px;
12
+ transition: all 0.3s ease;
13
+ }
14
+
15
+ .timeline-step {
16
+ display: flex;
17
+ cursor: pointer;
18
+ }
19
+
20
+ .timeline-step.active .step-number {
21
+ background: #3b82f6;
22
+ color: white;
23
+ }
24
+
25
+ .timeline-step.completed .step-number {
26
+ background: #22c55e;
27
+ color: white;
28
+ }
29
+
30
+ .step-marker {
31
+ display: flex;
32
+ flex-direction: column;
33
+ align-items: center;
34
+ margin-right: 14px;
35
+ }
36
+
37
+ .step-number {
38
+ width: 28px;
39
+ height: 28px;
40
+ border-radius: 50%;
41
+ background: #d9d9d9;
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: center;
45
+ font-weight: 600;
46
+ }
47
+
48
+ .vertical-line {
49
+ width: 2px;
50
+ height: 20px;
51
+ background: #d9d9d9;
52
+ }
53
+
54
+ .step-info {
55
+ display: flex;
56
+ flex-direction: column;
57
+ justify-content: center;
58
+ }
59
+
60
+ .step-title {
61
+ font-size: 13px;
62
+ font-weight: 500;
63
+ }
64
+
65
+ .step-main {
66
+ font-size: 15px;
67
+ font-weight: 600;
68
+ }
69
+
70
+ .toggle-arrow {
71
+ position: absolute;
72
+ top: 50%;
73
+ right: -12px; /* Position it just outside the card body padding */
74
+ transform: translateY(-50%);
75
+ width: 24px;
76
+ height: 24px;
77
+ background: #fff;
78
+ border: 1px solid #f0f0f0;
79
+ border-radius: 50%;
80
+ display: flex;
81
+ align-items: center;
82
+ justify-content: center;
83
+ cursor: pointer;
84
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
85
+ z-index: 10;
86
+ transition: all 0.3s ease;
87
+
88
+ &:hover {
89
+ background: #f5f5f5;
90
+ }
91
+ }
92
+
93
+ /* ============================
94
+ MOBILE & TABLET VIEW FIXES
95
+ ============================ */
96
+
97
+
98
+
99
+ @media (max-width: 992px) { // iPad & tablets
100
+ .timeline-card .ant-card-body {
101
+ padding: 12px;
102
+ min-height: auto;
103
+ }
104
+
105
+ .timeline-sidebar {
106
+ flex-direction: row !important;
107
+ overflow-x: auto;
108
+ gap: 12px;
109
+ padding-bottom: 10px;
110
+ border-bottom: 1px solid #eee;
111
+ }
112
+
113
+ .timeline-step {
114
+ flex-direction: column;
115
+ align-items: center;
116
+ }
117
+
118
+ .step-marker {
119
+ margin-right: 0;
120
+ }
121
+
122
+ .step-info {
123
+ text-align: center;
124
+ }
125
+
126
+ .toggle-arrow {
127
+ display: none !important; /* Hide collapse icon */
128
+ }
129
+ }
130
+
131
+ @media (max-width: 768px) { // mobile screens
132
+ .timeline-sidebar {
133
+ gap: 8px;
134
+ }
135
+
136
+ .step-number {
137
+ width: 24px;
138
+ height: 24px;
139
+ font-size: 12px;
140
+ }
141
+
142
+ .step-title {
143
+ font-size: 11px;
144
+ }
145
+
146
+ .step-main {
147
+ font-size: 13px;
148
+ }
149
+
150
+ .vertical-line {
151
+ display: none;
152
+ }
153
+
154
+ /* Card layout full width */
155
+ .timeline-card .ant-card-body {
156
+ padding: 10px;
157
+ }
158
+ }
159
+
@@ -0,0 +1,54 @@
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
+ */
12
+
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 }) {
19
+ return (
20
+ <Card className="timeline-card">
21
+ {loading ? (
22
+ <Skeleton active />
23
+ ) : (
24
+ <div className={`timeline-sidebar ${timelineCollapsed ? 'collapsed' : ''}`}>
25
+ {steps.map((step, index) => (
26
+ <div
27
+ key={step.step_id}
28
+ className={`timeline-step
29
+ ${index === activeStep ? 'active' : ''}
30
+ ${index < activeStep ? 'completed' : ''}`}
31
+ onClick={() => handleTimelineClick(index)}
32
+ >
33
+ <div className="step-marker">
34
+ <div className="step-number">{index + 1}</div>
35
+ {index < steps.length - 1 && <div className="vertical-line"></div>}
36
+ </div>
37
+
38
+ {!timelineCollapsed && (
39
+ <div className="step-info">
40
+ <div className="step-title">Step {index + 1}</div>
41
+ <div className="step-main">{step.step_name}</div>
42
+ </div>
43
+ )}
44
+ </div>
45
+ ))}
46
+
47
+ <div className="toggle-arrow" onClick={() => setTimelineCollapsed(!timelineCollapsed)}>
48
+ {timelineCollapsed ? <RightOutlined /> : <LeftOutlined />}
49
+ </div>
50
+ </div>
51
+ )}
52
+ </Card>
53
+ );
54
+ }
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.26",
3
+ "version": "2.5.1",
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",