ui-soxo-bootstrap-core 2.4.26 → 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.
- package/core/components/external-window/DEVELOPER_GUIDE.md +705 -0
- package/core/components/external-window/external-window.js +225 -0
- package/core/components/external-window/external-window.test.js +80 -0
- package/core/components/index.js +4 -1
- package/core/components/landing-api/landing-api.js +18 -18
- package/core/lib/Store.js +20 -18
- package/core/lib/components/index.js +4 -1
- package/core/lib/elements/basic/rangepicker/rangepicker.js +118 -29
- package/core/lib/elements/basic/switch/switch.js +34 -24
- package/core/models/dashboard/dashboard.js +14 -0
- package/core/modules/index.js +2 -0
- package/core/modules/steps/action-buttons.js +88 -0
- package/core/modules/steps/steps.js +332 -0
- package/core/modules/steps/steps.scss +158 -0
- package/core/modules/steps/timeline.js +54 -0
- 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 './external-window';
|
|
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/external-window';
|
|
14
|
+
|
|
13
15
|
export {
|
|
14
16
|
LandingAPI,
|
|
15
17
|
RootApplicationAPI,
|
|
16
18
|
ExtraInfoDetail,
|
|
17
|
-
HomePageAPI
|
|
19
|
+
HomePageAPI,
|
|
20
|
+
ExternalWindow
|
|
18
21
|
}
|
|
@@ -153,30 +153,30 @@ export default function LandingApi({ history, CustomComponents, CustomModels, ap
|
|
|
153
153
|
|
|
154
154
|
/**If there is roles assigned to the user */
|
|
155
155
|
// for matria
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
156
|
+
const useCoreMenus = process.env.REACT_APP_USE_CORE_MENUS === 'true';
|
|
157
|
+
if (useCoreMenus) {
|
|
158
|
+
if (result && result.result && reportMenus) {
|
|
159
|
+
setAllModules([...result.result, ...reportMenus, ...coreModules]);
|
|
160
|
+
} else {
|
|
161
|
+
//If there is no roles assigned to the user
|
|
162
|
+
setAllModules([...coreModules]);
|
|
163
|
+
}
|
|
164
164
|
|
|
165
|
-
} else{
|
|
166
|
-
// for nura
|
|
167
|
-
if (result && result.result.menus && reportMenus) {
|
|
168
|
-
setAllModules([...result.result.menus, ...reportMenus, ...coreModules]);
|
|
169
165
|
} else {
|
|
170
|
-
//
|
|
171
|
-
|
|
166
|
+
// for nura
|
|
167
|
+
if (result && result.result.menus && reportMenus) {
|
|
168
|
+
setAllModules([...result.result.menus, ...reportMenus, ...coreModules]);
|
|
169
|
+
} else {
|
|
170
|
+
//If there is no roles assigned to the user
|
|
171
|
+
setAllModules([...coreModules]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
172
175
|
}
|
|
176
|
+
setLoader(false);
|
|
173
177
|
|
|
174
|
-
|
|
175
178
|
}
|
|
176
|
-
setLoader(false);
|
|
177
179
|
|
|
178
|
-
}
|
|
179
|
-
|
|
180
180
|
|
|
181
181
|
/**
|
|
182
182
|
* Load the scripts
|
package/core/lib/Store.js
CHANGED
|
@@ -48,25 +48,25 @@ import { ConfigProvider, theme } from 'antd';
|
|
|
48
48
|
const initialTheme = () => {
|
|
49
49
|
try {
|
|
50
50
|
// manage theme with env
|
|
51
|
-
|
|
51
|
+
const isEnvThemeTrue = process.env.REACT_APP_THEME;
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
console.log('REACT_APP_THEME:', isEnvThemeTrue);
|
|
54
|
+
const matchedTheme = themes.find((t) => t.name === isEnvThemeTrue);
|
|
55
55
|
|
|
56
56
|
if (matchedTheme) return matchedTheme;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Fallback to default
|
|
65
|
-
return themes[0];
|
|
66
|
-
} catch (e) {
|
|
67
|
-
console.error('Error loading theme:', e);
|
|
68
|
-
return themes[0];
|
|
57
|
+
|
|
58
|
+
// Check saved theme
|
|
59
|
+
const savedTheme = localStorage.getItem('selectedTheme');
|
|
60
|
+
if (savedTheme) {
|
|
61
|
+
return JSON.parse(savedTheme);
|
|
69
62
|
}
|
|
63
|
+
|
|
64
|
+
// Fallback to default
|
|
65
|
+
return themes[0];
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.error('Error loading theme:', e);
|
|
68
|
+
return themes[0];
|
|
69
|
+
}
|
|
70
70
|
};
|
|
71
71
|
|
|
72
72
|
const initialState = {
|
|
@@ -87,7 +87,7 @@ let app = {};
|
|
|
87
87
|
* @param {*} param0
|
|
88
88
|
* @returns
|
|
89
89
|
*/
|
|
90
|
-
export const GlobalProvider = ({ children, CustomModels, appSettings: settings }) => {
|
|
90
|
+
export const GlobalProvider = ({ children, CustomModels,CustomComponents, appSettings: settings }) => {
|
|
91
91
|
// console.log("Setting up store");
|
|
92
92
|
|
|
93
93
|
const [branches, setBranches] = useState([]);
|
|
@@ -226,13 +226,14 @@ export const GlobalProvider = ({ children, CustomModels, appSettings: settings }
|
|
|
226
226
|
let store = {
|
|
227
227
|
app: app,
|
|
228
228
|
user: state.user,
|
|
229
|
-
settings:state.settings,
|
|
229
|
+
settings: state.settings,
|
|
230
230
|
dispatch: dispatch,
|
|
231
231
|
twilio: state.twilio,
|
|
232
232
|
isMobile,
|
|
233
233
|
branches,
|
|
234
234
|
defaultBranch: state.defaultBranch,
|
|
235
235
|
CustomModels: CustomModels,
|
|
236
|
+
CustomComponents:CustomComponents,
|
|
236
237
|
selectedBranch: state.selectedBranch,
|
|
237
238
|
kiosk: state.kiosk,
|
|
238
239
|
state,
|
|
@@ -314,7 +315,8 @@ export const AppReducer = (state, action) => {
|
|
|
314
315
|
|
|
315
316
|
case 'CustomModels':
|
|
316
317
|
return { ...state, CustomModels: action.CustomModels };
|
|
317
|
-
|
|
318
|
+
case 'CustomComponents':
|
|
319
|
+
return { ...state, CustomComponents: action.CustomComponents };
|
|
318
320
|
// # TODO Below Variable might be removed
|
|
319
321
|
case 'selectedLocation':
|
|
320
322
|
localStorage.selectedLocation = JSON.stringify(action.payload);
|
|
@@ -109,6 +109,8 @@ import ConsentComponent from './consent/consent'
|
|
|
109
109
|
import TaskOverviewLegacy from './../models/process/components/task-overview-legacy/task-overview-legacy'
|
|
110
110
|
|
|
111
111
|
import ReportingDashboard from '../../modules/reporting/components/reporting-dashboard/reporting-dashboard';
|
|
112
|
+
|
|
113
|
+
import ProcessStepsPage from '../../modules/steps/steps'
|
|
112
114
|
export {
|
|
113
115
|
|
|
114
116
|
// Bootstrap Components
|
|
@@ -197,7 +199,8 @@ export {
|
|
|
197
199
|
// WebCamera,
|
|
198
200
|
ConsentComponent,
|
|
199
201
|
|
|
200
|
-
ReportingDashboard
|
|
202
|
+
ReportingDashboard,
|
|
203
|
+
ProcessStepsPage
|
|
201
204
|
|
|
202
205
|
|
|
203
206
|
}
|
|
@@ -1,46 +1,135 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file rangepicker.js
|
|
3
|
+
* @description A reusable, enhanced Ant Design RangePicker component.
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Manual selections require "Apply" button confirmation
|
|
7
|
+
* - Preset ranges apply immediately without confirmation
|
|
8
|
+
* - Optimized state management and event handling
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useRef, useState } from 'react';
|
|
12
|
+
import { DatePicker, Space } from 'antd';
|
|
2
13
|
import moment from 'moment-timezone';
|
|
3
14
|
import PropTypes from 'prop-types';
|
|
15
|
+
import { useTranslation } from 'react-i18next';
|
|
16
|
+
import Button from '../button/button';
|
|
4
17
|
import './rangepicker.scss';
|
|
5
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Enhanced RangePicker with Apply/Cancel controls for manual selections.
|
|
21
|
+
* @param {object} props
|
|
22
|
+
* @param {moment.Moment[]} props.value - Current applied date range
|
|
23
|
+
* @param {function(moment.Moment[]): void} props.onChange - Callback when range is applied
|
|
24
|
+
* @param {string} [props.format='DD/MM/YYYY'] - Date display format
|
|
25
|
+
* @param {object} [props.ranges] - Preset date ranges
|
|
26
|
+
* @param {boolean} [props.allowClear=false] - Whether to show clear button
|
|
27
|
+
* @param {boolean} [props.inputReadOnly=true] - Whether input is read-only
|
|
28
|
+
*/
|
|
29
|
+
|
|
6
30
|
const { RangePicker } = DatePicker;
|
|
7
31
|
|
|
8
|
-
export default function RangePickerComponent({
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
32
|
+
export default function RangePickerComponent({
|
|
33
|
+
value,
|
|
34
|
+
onChange,
|
|
35
|
+
format = 'DD/MM/YYYY',
|
|
36
|
+
ranges,
|
|
37
|
+
allowClear = false,
|
|
38
|
+
inputReadOnly = true,
|
|
39
|
+
...restProps
|
|
40
|
+
}) {
|
|
41
|
+
const { t } = useTranslation();
|
|
42
|
+
|
|
43
|
+
// Temporary range during selection (before Apply is clicked)
|
|
44
|
+
const [tempRange, setTempRange] = useState(null);
|
|
45
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
46
|
+
|
|
47
|
+
// Ref to track if a selection just happened, to prevent the picker from closing.
|
|
48
|
+
const selectionJustHappened = useRef(false);
|
|
12
49
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Handles any completed date selection (both manual and preset).
|
|
52
|
+
* It updates the temporary range and sets a flag to keep the picker open.
|
|
53
|
+
*/
|
|
54
|
+
const handleChange = useCallback((dates) => {
|
|
55
|
+
setTempRange(dates || []);
|
|
56
|
+
selectionJustHappened.current = true;
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Handles picker open/close events.
|
|
61
|
+
* Prevents closing after a selection, requiring user to click Apply/Cancel.
|
|
62
|
+
*/
|
|
63
|
+
const handleOpenChange = useCallback((open) => {
|
|
64
|
+
// If the picker is trying to close, check if it was due to a selection.
|
|
65
|
+
if (!open && selectionJustHappened.current) {
|
|
66
|
+
// It was a selection, so ignore the close request and reset the flag.
|
|
67
|
+
selectionJustHappened.current = false;
|
|
16
68
|
return;
|
|
17
69
|
}
|
|
18
70
|
|
|
19
|
-
//
|
|
20
|
-
|
|
71
|
+
// For all other cases (opening, or closing by clicking outside), proceed as normal.
|
|
72
|
+
if (open) {
|
|
73
|
+
// Reset temp range when opening for a fresh selection.
|
|
74
|
+
setTempRange(null);
|
|
75
|
+
}
|
|
76
|
+
setIsOpen(open);
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Applies the temporary range and closes picker
|
|
81
|
+
*/
|
|
82
|
+
const handleApply = useCallback(() => {
|
|
83
|
+
if (tempRange?.length === 2 && onChange) {
|
|
84
|
+
onChange(tempRange);
|
|
85
|
+
}
|
|
86
|
+
selectionJustHappened.current = false; // Clear the flag
|
|
87
|
+
setIsOpen(false);
|
|
88
|
+
}, [tempRange, onChange]);
|
|
21
89
|
|
|
22
|
-
|
|
23
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Cancels selection and closes picker
|
|
92
|
+
*/
|
|
93
|
+
const handleCancel = useCallback(() => {
|
|
94
|
+
selectionJustHappened.current = false; // Clear the flag
|
|
95
|
+
setTempRange(null);
|
|
96
|
+
setIsOpen(false);
|
|
97
|
+
}, []);
|
|
24
98
|
|
|
25
|
-
//
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
99
|
+
// Memoized preset ranges - use provided ranges or default set
|
|
100
|
+
const defaultRanges = useRef({
|
|
101
|
+
Today: [moment(), moment()],
|
|
102
|
+
Yesterday: [moment().subtract(1, 'days').startOf('day'), moment().subtract(1, 'days').endOf('day')],
|
|
103
|
+
'This Week': [moment().startOf('week'), moment().endOf('week')],
|
|
104
|
+
'Last Week': [moment().subtract(1, 'week').startOf('week'), moment().subtract(1, 'week').endOf('week')],
|
|
105
|
+
'This Month': [moment().startOf('month'), moment().endOf('month')],
|
|
106
|
+
'Last Month': [moment().subtract(1, 'month').startOf('month'), moment().subtract(1, 'month').endOf('month')],
|
|
107
|
+
}).current;
|
|
30
108
|
|
|
31
109
|
return (
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
110
|
+
<RangePicker
|
|
111
|
+
allowClear={allowClear}
|
|
112
|
+
inputReadOnly={inputReadOnly}
|
|
113
|
+
format={format}
|
|
114
|
+
value={tempRange || value}
|
|
115
|
+
open={isOpen}
|
|
116
|
+
onOpenChange={handleOpenChange}
|
|
117
|
+
onChange={handleChange}
|
|
118
|
+
ranges={ranges !== undefined ? ranges : defaultRanges}
|
|
119
|
+
{...restProps}
|
|
120
|
+
renderExtraFooter={() => (
|
|
121
|
+
<div style={{ width: '100%', display: 'flex', justifyContent: 'flex-end', order: 1 }}>
|
|
122
|
+
<Space>
|
|
123
|
+
<Button size="small" onClick={handleCancel}>
|
|
124
|
+
{t('Cancel')}
|
|
125
|
+
</Button>
|
|
126
|
+
<Button type="primary" size="small" onClick={handleApply}>
|
|
127
|
+
{t('Apply')}
|
|
128
|
+
</Button>
|
|
129
|
+
</Space>
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
/>
|
|
44
133
|
);
|
|
45
134
|
}
|
|
46
135
|
|