@visns-studio/visns-components 5.7.0 → 5.7.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.
- package/package.json +1 -4
- package/src/components/cms/DataGrid.jsx +2 -2
- package/src/components/crm/Autocomplete.jsx +161 -127
- package/src/components/crm/DataGrid.jsx +2 -2
- package/src/components/crm/Download.jsx +15 -4
- package/src/components/crm/Fetch.jsx +69 -16
- package/src/components/crm/Notification.jsx +237 -106
- package/src/components/crm/QuickAction.jsx +318 -91
- package/src/components/crm/generic/GenericDetail.jsx +43 -127
- package/src/components/crm/generic/NotificationList.jsx +76 -7
- package/src/components/crm/generic/styles/NotificationList.module.scss +90 -2
- package/src/components/crm/styles/DataGrid.module.scss +5 -0
- package/src/components/crm/styles/Notification.module.scss +313 -96
- package/src/components/crm/styles/QuickAction.module.scss +168 -46
- package/src/components/styles/global.css +5 -0
- package/src/utils/fetchUtil.js +240 -0
|
@@ -1,98 +1,260 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
|
|
1
|
+
import React, {
|
|
2
|
+
useEffect,
|
|
3
|
+
useRef,
|
|
4
|
+
useState,
|
|
5
|
+
useCallback,
|
|
6
|
+
useMemo,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { useNavigate } from 'react-router-dom';
|
|
3
9
|
import Popup from 'reactjs-popup';
|
|
4
|
-
import { Search } from 'akar-icons';
|
|
10
|
+
import { Search, Cross, Thunder } from 'akar-icons';
|
|
5
11
|
|
|
6
12
|
import CustomFetch from './Fetch';
|
|
7
13
|
import Form from './Form';
|
|
8
14
|
|
|
9
15
|
import styles from './styles/QuickAction.module.scss';
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
// Debounce hook to limit API calls
|
|
18
|
+
function useDebounce(value, delay) {
|
|
19
|
+
// Using React hooks directly since they're imported at the top level
|
|
20
|
+
const [debouncedValue, setDebouncedValue] = React.useState(value);
|
|
21
|
+
|
|
22
|
+
React.useEffect(() => {
|
|
23
|
+
const handler = setTimeout(() => {
|
|
24
|
+
setDebouncedValue(value);
|
|
25
|
+
}, delay);
|
|
26
|
+
|
|
27
|
+
return () => {
|
|
28
|
+
clearTimeout(handler);
|
|
29
|
+
};
|
|
30
|
+
}, [value, delay]);
|
|
31
|
+
|
|
32
|
+
return debouncedValue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Memoized ResultSection component to prevent unnecessary re-renders
|
|
36
|
+
const ResultSection = React.memo(({ title, data, onSelectResult }) => {
|
|
37
|
+
if (!data?.length) return null;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<div className={styles.resultItem}>
|
|
42
|
+
<h3>{title}</h3>
|
|
43
|
+
</div>
|
|
44
|
+
{data.map((result) => (
|
|
45
|
+
<div
|
|
46
|
+
key={result.id}
|
|
47
|
+
className={styles.resultItem}
|
|
48
|
+
onClick={() => onSelectResult(title, result)}
|
|
49
|
+
title={
|
|
50
|
+
title === 'Contacts'
|
|
51
|
+
? `${result.firstname} ${result.surname}`
|
|
52
|
+
: result.name || result.project_name
|
|
53
|
+
}
|
|
54
|
+
>
|
|
55
|
+
<p>
|
|
56
|
+
{title === 'Contacts'
|
|
57
|
+
? `${result.firstname} ${result.surname}`
|
|
58
|
+
: result.name || result.project_name}
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
))}
|
|
62
|
+
</>
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
ResultSection.displayName = 'ResultSection';
|
|
67
|
+
|
|
68
|
+
const QuickAction = ({ setting }) => {
|
|
12
69
|
const navigate = useNavigate();
|
|
13
|
-
const location = useLocation();
|
|
14
70
|
const searchBarRef = useRef(null);
|
|
71
|
+
const searchInputRef = useRef(null);
|
|
72
|
+
const [selectedResultIndex, setSelectedResultIndex] = useState(-1);
|
|
15
73
|
|
|
16
74
|
/** State Variables */
|
|
17
75
|
const [search, setSearch] = useState('');
|
|
18
76
|
const [searchResults, setSearchResults] = useState([]);
|
|
19
77
|
const [showSearchResults, setShowSearchResults] = useState(false);
|
|
78
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
20
79
|
const [modalShow, setModalShow] = useState(false);
|
|
21
80
|
const [formType, setFormType] = useState('create');
|
|
22
81
|
const [formId, setFormId] = useState(0);
|
|
23
82
|
const [form, setForm] = useState({});
|
|
24
83
|
|
|
84
|
+
// Debounce search input to prevent excessive API calls
|
|
85
|
+
const debouncedSearchTerm = useDebounce(search, 300);
|
|
86
|
+
|
|
25
87
|
/** Helper Functions */
|
|
26
|
-
const fetchSearchResults =
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
88
|
+
const fetchSearchResults = useCallback(
|
|
89
|
+
async (searchTerm) => {
|
|
90
|
+
if (!searchTerm || searchTerm.trim() === '') return;
|
|
91
|
+
|
|
92
|
+
setIsSearching(true);
|
|
93
|
+
try {
|
|
94
|
+
const res = await CustomFetch(setting.search.url, 'POST', {
|
|
95
|
+
search: searchTerm,
|
|
96
|
+
});
|
|
97
|
+
setSearchResults(res.data);
|
|
98
|
+
setShowSearchResults(true);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.error('Search error:', e);
|
|
101
|
+
} finally {
|
|
102
|
+
setIsSearching(false);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
[setting.search.url]
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Flatten search results for keyboard navigation
|
|
109
|
+
const flattenedResults = useMemo(() => {
|
|
110
|
+
const flattened = [];
|
|
111
|
+
if (searchResults && setting.search.types) {
|
|
112
|
+
setting.search.types.forEach((type) => {
|
|
113
|
+
if (searchResults[type.id]?.length) {
|
|
114
|
+
searchResults[type.id].forEach((result) => {
|
|
115
|
+
flattened.push({
|
|
116
|
+
type: type.title,
|
|
117
|
+
result,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
30
121
|
});
|
|
31
|
-
setSearchResults(res.data);
|
|
32
|
-
setSearch('');
|
|
33
|
-
} catch (e) {
|
|
34
|
-
console.error(e);
|
|
35
122
|
}
|
|
36
|
-
|
|
123
|
+
return flattened;
|
|
124
|
+
}, [searchResults, setting.search.types]);
|
|
37
125
|
|
|
38
|
-
const
|
|
126
|
+
const handleSearchChange = useCallback((e) => {
|
|
39
127
|
const value = e.target.value;
|
|
40
128
|
setSearch(value);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (url) {
|
|
47
|
-
const key = setting.search.types.find((t) => t.title === type)?.key;
|
|
48
|
-
if (key && result[key]) {
|
|
49
|
-
navigate(`${url}/${result[key]}`);
|
|
50
|
-
} else {
|
|
51
|
-
navigate(url);
|
|
52
|
-
}
|
|
129
|
+
setSelectedResultIndex(-1);
|
|
130
|
+
|
|
131
|
+
// Show empty results container when typing
|
|
132
|
+
if (value.trim() !== '') {
|
|
133
|
+
setShowSearchResults(true);
|
|
53
134
|
}
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
const handleSearchKeyDown = useCallback(
|
|
138
|
+
(e) => {
|
|
139
|
+
// Handle keyboard navigation
|
|
140
|
+
if (showSearchResults) {
|
|
141
|
+
switch (e.key) {
|
|
142
|
+
case 'ArrowDown':
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
setSelectedResultIndex((prev) =>
|
|
145
|
+
prev < flattenedResults.length - 1 ? prev + 1 : prev
|
|
146
|
+
);
|
|
147
|
+
break;
|
|
148
|
+
case 'ArrowUp':
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
setSelectedResultIndex((prev) =>
|
|
151
|
+
prev > 0 ? prev - 1 : 0
|
|
152
|
+
);
|
|
153
|
+
break;
|
|
154
|
+
case 'Enter':
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
if (
|
|
157
|
+
selectedResultIndex >= 0 &&
|
|
158
|
+
selectedResultIndex < flattenedResults.length
|
|
159
|
+
) {
|
|
160
|
+
const { type, result } =
|
|
161
|
+
flattenedResults[selectedResultIndex];
|
|
162
|
+
handleSelectResult(type, result);
|
|
163
|
+
} else if (search.trim() !== '') {
|
|
164
|
+
fetchSearchResults(search);
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
case 'Escape':
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
setShowSearchResults(false);
|
|
170
|
+
searchInputRef.current?.blur();
|
|
171
|
+
break;
|
|
172
|
+
default:
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
} else if (e.key === 'Enter' && search.trim() !== '') {
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
fetchSearchResults(search);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
[
|
|
181
|
+
showSearchResults,
|
|
182
|
+
selectedResultIndex,
|
|
183
|
+
flattenedResults,
|
|
184
|
+
search,
|
|
185
|
+
fetchSearchResults,
|
|
186
|
+
]
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const clearSearch = useCallback(() => {
|
|
190
|
+
setSearch('');
|
|
54
191
|
setSearchResults([]);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
192
|
+
setShowSearchResults(false);
|
|
193
|
+
searchInputRef.current?.focus();
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
const handleSelectResult = useCallback(
|
|
197
|
+
(type, result) => {
|
|
198
|
+
const typeObj = setting.search.types.find((t) => t.title === type);
|
|
199
|
+
if (typeObj?.url) {
|
|
200
|
+
if (typeObj.key && result[typeObj.key]) {
|
|
201
|
+
navigate(`${typeObj.url}/${result[typeObj.key]}`);
|
|
202
|
+
} else {
|
|
203
|
+
navigate(typeObj.url);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
setSearchResults([]);
|
|
207
|
+
setShowSearchResults(false);
|
|
208
|
+
setSearch('');
|
|
209
|
+
},
|
|
210
|
+
[navigate, setting.search.types]
|
|
211
|
+
);
|
|
65
212
|
|
|
66
|
-
const
|
|
67
|
-
|
|
213
|
+
const handleButtonClick = useCallback(
|
|
214
|
+
(buttonSetting) => {
|
|
215
|
+
// Check if the form object is identical to the current form
|
|
216
|
+
if (JSON.stringify(buttonSetting.form) === JSON.stringify(form)) {
|
|
217
|
+
modalOpen('create', 0); // Call modalOpen if they are the same
|
|
218
|
+
} else {
|
|
219
|
+
setForm(buttonSetting.form); // Otherwise, update the form state
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
[form]
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const modalClose = useCallback(() => setModalShow(false), []);
|
|
226
|
+
|
|
227
|
+
const modalOpen = useCallback((ft, fi) => {
|
|
68
228
|
setModalShow(true);
|
|
69
229
|
setFormType(ft);
|
|
70
230
|
setFormId(fi);
|
|
71
|
-
};
|
|
231
|
+
}, []);
|
|
72
232
|
|
|
73
|
-
const childDropdownCallback = (data) => {
|
|
233
|
+
const childDropdownCallback = useCallback((data) => {
|
|
74
234
|
setForm((prevForm) => {
|
|
75
235
|
const updatedFields = prevForm.fields.map((field) =>
|
|
76
236
|
field.id === data.id ? { ...field, options: data.data } : field
|
|
77
237
|
);
|
|
78
238
|
return { ...prevForm, fields: updatedFields };
|
|
79
239
|
});
|
|
80
|
-
};
|
|
240
|
+
}, []);
|
|
81
241
|
|
|
82
242
|
/** Effect Hooks */
|
|
243
|
+
// Effect to fetch search results when debounced search term changes
|
|
83
244
|
useEffect(() => {
|
|
84
|
-
if (
|
|
85
|
-
|
|
245
|
+
if (debouncedSearchTerm && debouncedSearchTerm.trim() !== '') {
|
|
246
|
+
fetchSearchResults(debouncedSearchTerm);
|
|
86
247
|
}
|
|
87
|
-
}, [
|
|
248
|
+
}, [debouncedSearchTerm, fetchSearchResults]);
|
|
88
249
|
|
|
250
|
+
// Effect to open modal when form is set
|
|
89
251
|
useEffect(() => {
|
|
90
|
-
|
|
91
|
-
(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}, [searchResults]);
|
|
252
|
+
if (form?.fields?.length) {
|
|
253
|
+
modalOpen('create', 0);
|
|
254
|
+
}
|
|
255
|
+
}, [form, modalOpen]);
|
|
95
256
|
|
|
257
|
+
// Effect to handle clicks outside search results
|
|
96
258
|
useEffect(() => {
|
|
97
259
|
const handleClickOutside = (event) => {
|
|
98
260
|
if (
|
|
@@ -102,40 +264,51 @@ const QuickAction = ({ childRef, setting }) => {
|
|
|
102
264
|
setShowSearchResults(false);
|
|
103
265
|
}
|
|
104
266
|
};
|
|
267
|
+
|
|
105
268
|
document.addEventListener('mousedown', handleClickOutside);
|
|
106
269
|
return () =>
|
|
107
270
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
108
271
|
}, []);
|
|
109
272
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
273
|
+
// Effect to scroll selected result into view
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
if (selectedResultIndex >= 0 && showSearchResults) {
|
|
276
|
+
// Target all result items within the search results container
|
|
277
|
+
const resultElements = document.querySelectorAll(
|
|
278
|
+
'.AutocompletePlace-results .resultItem, .AutocompletePlace-results .' +
|
|
279
|
+
styles.resultItem
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
if (resultElements[selectedResultIndex + 1]) {
|
|
283
|
+
// +1 to skip the header
|
|
284
|
+
resultElements[selectedResultIndex + 1].scrollIntoView({
|
|
285
|
+
behavior: 'smooth',
|
|
286
|
+
block: 'nearest',
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Add selected class to highlight the item
|
|
290
|
+
resultElements.forEach((el, i) => {
|
|
291
|
+
if (i === selectedResultIndex + 1) {
|
|
292
|
+
el.classList.add(styles.selected);
|
|
293
|
+
} else {
|
|
294
|
+
el.classList.remove(styles.selected);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}, [selectedResultIndex, showSearchResults, styles.selected]);
|
|
134
300
|
|
|
135
301
|
return (
|
|
136
302
|
<>
|
|
137
303
|
<div className={styles.quickActions}>
|
|
138
|
-
<span
|
|
304
|
+
<span
|
|
305
|
+
className={styles.actionIcon}
|
|
306
|
+
title="Quick Actions"
|
|
307
|
+
data-tooltip-id="system-tooltip"
|
|
308
|
+
data-tooltip-content="Quick Actions"
|
|
309
|
+
>
|
|
310
|
+
<Thunder strokeWidth={2} size={18} />
|
|
311
|
+
</span>
|
|
139
312
|
{setting.buttons.map((b) => (
|
|
140
313
|
<button
|
|
141
314
|
key={`button-${b.id}`}
|
|
@@ -153,29 +326,83 @@ const QuickAction = ({ childRef, setting }) => {
|
|
|
153
326
|
|
|
154
327
|
<div className={styles.searchBar} ref={searchBarRef}>
|
|
155
328
|
<input
|
|
329
|
+
ref={searchInputRef}
|
|
156
330
|
type="text"
|
|
157
|
-
placeholder="Search..."
|
|
331
|
+
placeholder="Search contacts, companies..."
|
|
158
332
|
value={search}
|
|
159
|
-
onChange={
|
|
160
|
-
onKeyDown={
|
|
333
|
+
onChange={handleSearchChange}
|
|
334
|
+
onKeyDown={handleSearchKeyDown}
|
|
335
|
+
onFocus={() =>
|
|
336
|
+
search.trim() !== '' && setShowSearchResults(true)
|
|
337
|
+
}
|
|
338
|
+
style={{
|
|
339
|
+
transition: 'all 0.2s ease',
|
|
340
|
+
}}
|
|
161
341
|
/>
|
|
162
342
|
<div className={styles.iconContainer}>
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
343
|
+
{search ? (
|
|
344
|
+
<Cross
|
|
345
|
+
strokeWidth={2}
|
|
346
|
+
size={16}
|
|
347
|
+
className={styles.searchIcon}
|
|
348
|
+
onClick={clearSearch}
|
|
349
|
+
style={{ cursor: 'pointer' }}
|
|
350
|
+
/>
|
|
351
|
+
) : (
|
|
352
|
+
<Search
|
|
353
|
+
strokeWidth={2}
|
|
354
|
+
size={18}
|
|
355
|
+
className={styles.searchIcon}
|
|
356
|
+
/>
|
|
357
|
+
)}
|
|
168
358
|
</div>
|
|
169
359
|
|
|
170
360
|
{showSearchResults && (
|
|
171
|
-
<div
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
361
|
+
<div
|
|
362
|
+
className="AutocompletePlace-results"
|
|
363
|
+
style={{
|
|
364
|
+
position: 'absolute',
|
|
365
|
+
zIndex: 9999,
|
|
366
|
+
width: '100%',
|
|
367
|
+
top: '100%',
|
|
368
|
+
}}
|
|
369
|
+
>
|
|
370
|
+
{isSearching ? (
|
|
371
|
+
<div className={styles.resultItem}>
|
|
372
|
+
<p>Searching...</p>
|
|
373
|
+
</div>
|
|
374
|
+
) : flattenedResults.length > 0 ? (
|
|
375
|
+
setting.search.types.map((t) => (
|
|
376
|
+
<ResultSection
|
|
377
|
+
key={t.id}
|
|
378
|
+
title={t.title}
|
|
379
|
+
data={searchResults[t.id]}
|
|
380
|
+
onSelectResult={handleSelectResult}
|
|
381
|
+
/>
|
|
382
|
+
))
|
|
383
|
+
) : (
|
|
384
|
+
search.trim() !== '' && (
|
|
385
|
+
<div className={styles.resultItem}>
|
|
386
|
+
<p>No results found for "{search}"</p>
|
|
387
|
+
</div>
|
|
388
|
+
)
|
|
389
|
+
)}
|
|
390
|
+
{flattenedResults.length > 0 && (
|
|
391
|
+
<div
|
|
392
|
+
className={styles.resultItem}
|
|
393
|
+
style={{
|
|
394
|
+
textAlign: 'center',
|
|
395
|
+
fontSize: '12px',
|
|
396
|
+
color: 'var(--paragraph-color)',
|
|
397
|
+
opacity: 0.7,
|
|
398
|
+
}}
|
|
399
|
+
>
|
|
400
|
+
<p>
|
|
401
|
+
Use ↑↓ arrows to navigate, Enter to
|
|
402
|
+
select
|
|
403
|
+
</p>
|
|
404
|
+
</div>
|
|
405
|
+
)}
|
|
179
406
|
</div>
|
|
180
407
|
)}
|
|
181
408
|
</div>
|