ui-soxo-bootstrap-core 2.4.25 → 2.5.0

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,332 @@
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({ processId, 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
+ 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 [showExternalWindow, setShowExternalWindow] = useState(false);
37
+ const urlParams = Location.search();
38
+
39
+ // Load process details based on the current process ID
40
+ useEffect(() => {
41
+ loadProcess(currentProcessId);
42
+
43
+ const saved = localStorage.getItem(`processTimings_${currentProcessId}`);
44
+ setProcessTimings(saved ? JSON.parse(saved) : []);
45
+
46
+ setProcessStartTime(Date.now());
47
+ setStepStartTime(Date.now());
48
+ }, [currentProcessId]);
49
+
50
+ //// Reset step start time whenever the active step changes
51
+
52
+ useEffect(() => {
53
+ setStepStartTime(Date.now());
54
+ }, [activeStep]);
55
+
56
+ // Check whether the current step is completed or mandatory
57
+ useEffect(() => {
58
+ if (steps.length > 0) {
59
+ setIsStepCompleted(steps[activeStep]?.is_mandatory !== true);
60
+ }
61
+ }, [activeStep, steps]);
62
+
63
+ // Save updated process timings to state and localStorage
64
+ const saveTimings = (updated) => {
65
+ setProcessTimings(updated);
66
+ localStorage.setItem(`processTimings_${currentProcessId}`, JSON.stringify(updated));
67
+ };
68
+ // Record time spent on the current step
69
+
70
+ const recordStepTime = (status = 'completed') => {
71
+ // Exit if step start time or step data is missing
72
+
73
+ if (!stepStartTime || !steps[activeStep]) return processTimings;
74
+ // Capture end time and calculate duration
75
+
76
+ const endTime = Date.now();
77
+ const duration = endTime - stepStartTime;
78
+ const stepId = steps[activeStep].step_id;
79
+ // Clone existing timings
80
+
81
+ const updated = [...processTimings];
82
+ const index = updated.findIndex((t) => t.step_id === stepId);
83
+ // Create timing entry for the step
84
+
85
+ const entry = {
86
+ step_id: stepId,
87
+ start_time: moment(stepStartTime).format('DD-MM-YYYY HH:mm:ss'),
88
+ end_time: moment(endTime).format('DD-MM-YYYY HH:mm:ss'),
89
+ duration,
90
+ status,
91
+ };
92
+ // Update existing entry or add a new one
93
+ if (index > -1) {
94
+ updated[index] = { ...updated[index], ...entry, duration: updated[index].duration + duration };
95
+ } else {
96
+ updated.push(entry);
97
+ }
98
+
99
+ return updated;
100
+ };
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
+ */
111
+ async function loadProcess(processId) {
112
+ setLoading(true);
113
+ setNextProcessId(null);
114
+
115
+ try {
116
+ const result = await Dashboard.loadProcess(processId);
117
+
118
+ setSteps(result?.data?.steps || []);
119
+ if (result?.data?.next_process_id) setNextProcessId(result.data);
120
+ } catch (e) {
121
+ console.error('Error loading process steps:', e);
122
+ } finally {
123
+ setLoading(false);
124
+ }
125
+ }
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
+ */
135
+ const handleProcessSubmit = async (finalTimings) => {
136
+ const payload = {
137
+ process_id: currentProcessId,
138
+ status: 'completed',
139
+ reference_id: urlParams?.opb_id || urlParams?.reference_id,
140
+ reference_number: urlParams?.opno || urlParams?.reference_number,
141
+ mode: urlParams?.mode,
142
+ process: {
143
+ process_start_time: moment(processStartTime).format('DD-MM-YYYY HH:mm'),
144
+ process_end_time: moment().format('DD-MM-YYYY HH:mm'),
145
+ steps: finalTimings,
146
+ },
147
+ };
148
+
149
+ try {
150
+ const response = await Dashboard.processLog(payload);
151
+
152
+ if (response.success) {
153
+ localStorage.removeItem(`processTimings_${currentProcessId}`);
154
+ setProcessTimings([]);
155
+ return true;
156
+ }
157
+ } catch (e) {
158
+ console.error('Error:', e);
159
+ saveTimings(finalTimings);
160
+ }
161
+ return false;
162
+ };
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
+ */
172
+ const gotoStep = (index, status = 'completed') => {
173
+ const updated = recordStepTime(status);
174
+ saveTimings(updated);
175
+ setActiveStep(index);
176
+ };
177
+ /**
178
+ * Navigate to the next step
179
+ * - Records timing data and advances step index by one.
180
+ */
181
+ const handleNext = () => gotoStep(activeStep + 1);
182
+ /**
183
+ * Navigate to the previous step
184
+ * - Records timing data and moves to the previous step.
185
+ */
186
+ const handlePrevious = () => gotoStep(activeStep - 1);
187
+ /**
188
+ * Skip current step
189
+ * - Records timing with skipped status.
190
+ * - Moves to the next step.
191
+ */
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
+ */
198
+ const handleTimelineClick = (i) => gotoStep(i);
199
+ /**
200
+ * Process Completion
201
+ * - Records final step timing.
202
+ * - Submits process completion data.
203
+ * - Navigates back on successful completion.
204
+ */
205
+ const handleFinish = async () => {
206
+ const final = recordStepTime();
207
+ if (await handleProcessSubmit(final)) props.history?.goBack();
208
+ };
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
+ */
215
+ const handleStartNextProcess = async () => {
216
+ const final = recordStepTime();
217
+ if (await handleProcessSubmit(final)) {
218
+ await loadProcess(nextProcessId.next_process_id);
219
+ setCurrentProcessId(nextProcessId.next_process_id);
220
+ setActiveStep(0);
221
+ setShowExternalWindow(true);
222
+ }
223
+ };
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
+ */
230
+ const DynamicComponent = () => {
231
+ const step = steps[activeStep];
232
+ if (!step) return <Empty description="No step selected" />;
233
+
234
+ const Component = allComponents[step.related_page];
235
+ if (!Component) return <Empty description={`Component "${step.related_page}" not found`} />;
236
+
237
+ return <Component {...step.config} {...props} step={step} params={urlParams} onStepComplete={() => setIsStepCompleted(true)} />;
238
+ };
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
+ */
245
+ useEffect(() => {
246
+ const handleKeyDown = (event) => {
247
+ // Handle Left Arrow key press to go to the previous step
248
+ if (event.key === 'ArrowLeft') {
249
+ if (activeStep > 0) {
250
+ handlePrevious();
251
+ }
252
+ }
253
+ // Handle Right Arrow key press to go to the next step
254
+ else if (event.key === 'ArrowRight') {
255
+ if (activeStep < steps.length - 1) {
256
+ handleNext();
257
+ }
258
+ }
259
+ };
260
+
261
+ window.addEventListener('keydown', handleKeyDown);
262
+ return () => window.removeEventListener('keydown', handleKeyDown);
263
+ }, [activeStep, steps, handlePrevious, handleNext]);
264
+
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 = () => (
271
+ <Card>
272
+ <Row gutter={20}>
273
+ <Col xs={24} sm={24} lg={timelineCollapsed ? 2 : 6}>
274
+ <TimelinePanel
275
+ loading={loading}
276
+ steps={steps}
277
+ activeStep={activeStep}
278
+ timelineCollapsed={timelineCollapsed}
279
+ handleTimelineClick={handleTimelineClick}
280
+ setTimelineCollapsed={setTimelineCollapsed}
281
+ />
282
+ </Col>
283
+
284
+ <Col xs={24} sm={24} lg={timelineCollapsed ? 21 : 18}>
285
+ <div style={{ marginBottom: 20 }}>
286
+ <h2 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{steps[activeStep]?.step_name}</h2>
287
+ <p style={{ margin: 0, color: '#666' }}>{steps[activeStep]?.step_description}</p>
288
+ </div>
289
+ <ActionButtons
290
+ loading={loading}
291
+ steps={steps}
292
+ activeStep={activeStep}
293
+ isStepCompleted={isStepCompleted}
294
+ renderDynamicComponent={DynamicComponent}
295
+ handlePrevious={handlePrevious}
296
+ handleNext={handleNext}
297
+ handleSkip={handleSkip}
298
+ handleFinish={handleFinish}
299
+ handleStartNextProcess={handleStartNextProcess}
300
+ nextProcessId={nextProcessId}
301
+ timelineCollapsed={timelineCollapsed}
302
+ />
303
+ </Col>
304
+ </Row>
305
+ </Card>
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();
332
+ }
@@ -0,0 +1,158 @@
1
+ .timeline-card .ant-card-body {
2
+ padding: 20px;
3
+ min-height: 400px;
4
+ position: fixed; /* For positioning the arrow */
5
+ }
6
+
7
+ .timeline-sidebar {
8
+ display: flex;
9
+ flex-direction: column;
10
+ gap: 20px;
11
+ transition: all 0.3s ease;
12
+ }
13
+
14
+ .timeline-step {
15
+ display: flex;
16
+ cursor: pointer;
17
+ }
18
+
19
+ .timeline-step.active .step-number {
20
+ background: #3b82f6;
21
+ color: white;
22
+ }
23
+
24
+ .timeline-step.completed .step-number {
25
+ background: #22c55e;
26
+ color: white;
27
+ }
28
+
29
+ .step-marker {
30
+ display: flex;
31
+ flex-direction: column;
32
+ align-items: center;
33
+ margin-right: 14px;
34
+ }
35
+
36
+ .step-number {
37
+ width: 28px;
38
+ height: 28px;
39
+ border-radius: 50%;
40
+ background: #d9d9d9;
41
+ display: flex;
42
+ align-items: center;
43
+ justify-content: center;
44
+ font-weight: 600;
45
+ }
46
+
47
+ .vertical-line {
48
+ width: 2px;
49
+ height: 40px;
50
+ background: #d9d9d9;
51
+ }
52
+
53
+ .step-info {
54
+ display: flex;
55
+ flex-direction: column;
56
+ justify-content: center;
57
+ }
58
+
59
+ .step-title {
60
+ font-size: 13px;
61
+ font-weight: 500;
62
+ }
63
+
64
+ .step-main {
65
+ font-size: 15px;
66
+ font-weight: 600;
67
+ }
68
+
69
+ .toggle-arrow {
70
+ position: absolute;
71
+ top: 50%;
72
+ right: -12px; /* Position it just outside the card body padding */
73
+ transform: translateY(-50%);
74
+ width: 24px;
75
+ height: 24px;
76
+ background: #fff;
77
+ border: 1px solid #f0f0f0;
78
+ border-radius: 50%;
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: center;
82
+ cursor: pointer;
83
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
84
+ z-index: 10;
85
+ transition: all 0.3s ease;
86
+
87
+ &:hover {
88
+ background: #f5f5f5;
89
+ }
90
+ }
91
+
92
+ /* ============================
93
+ MOBILE & TABLET VIEW FIXES
94
+ ============================ */
95
+
96
+
97
+
98
+ @media (max-width: 992px) { // iPad & tablets
99
+ .timeline-card .ant-card-body {
100
+ padding: 12px;
101
+ min-height: auto;
102
+ }
103
+
104
+ .timeline-sidebar {
105
+ flex-direction: row !important;
106
+ overflow-x: auto;
107
+ gap: 12px;
108
+ padding-bottom: 10px;
109
+ border-bottom: 1px solid #eee;
110
+ }
111
+
112
+ .timeline-step {
113
+ flex-direction: column;
114
+ align-items: center;
115
+ }
116
+
117
+ .step-marker {
118
+ margin-right: 0;
119
+ }
120
+
121
+ .step-info {
122
+ text-align: center;
123
+ }
124
+
125
+ .toggle-arrow {
126
+ display: none !important; /* Hide collapse icon */
127
+ }
128
+ }
129
+
130
+ @media (max-width: 768px) { // mobile screens
131
+ .timeline-sidebar {
132
+ gap: 8px;
133
+ }
134
+
135
+ .step-number {
136
+ width: 24px;
137
+ height: 24px;
138
+ font-size: 12px;
139
+ }
140
+
141
+ .step-title {
142
+ font-size: 11px;
143
+ }
144
+
145
+ .step-main {
146
+ font-size: 13px;
147
+ }
148
+
149
+ .vertical-line {
150
+ display: none;
151
+ }
152
+
153
+ /* Card layout full width */
154
+ .timeline-card .ant-card-body {
155
+ padding: 10px;
156
+ }
157
+ }
158
+
@@ -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.25",
3
+ "version": "2.5.0",
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",