@visns-studio/visns-components 5.6.14 → 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.
@@ -1,98 +1,260 @@
1
- import React, { useEffect, useRef, useState } from 'react';
2
- import { useNavigate, useLocation } from 'react-router-dom';
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
- const QuickAction = ({ childRef, setting }) => {
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 = async () => {
27
- try {
28
- const res = await CustomFetch(setting.search.url, 'POST', {
29
- search,
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 handleSearch = (e) => {
126
+ const handleSearchChange = useCallback((e) => {
39
127
  const value = e.target.value;
40
128
  setSearch(value);
41
- if (e.key === 'Enter') fetchSearchResults();
42
- };
43
-
44
- const handleSelectResult = (type, result) => {
45
- const url = setting.search.types.find((t) => t.title === type)?.url;
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
- const handleButtonClick = (buttonSetting) => {
58
- // Check if the form object is identical to the current form
59
- if (JSON.stringify(buttonSetting.form) === JSON.stringify(form)) {
60
- modalOpen('create', 0); // Call modalOpen if they are the same
61
- } else {
62
- setForm(buttonSetting.form); // Otherwise, update the form state
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 modalClose = () => setModalShow(false);
67
- const modalOpen = (ft, fi) => {
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 (form?.fields?.length) {
85
- modalOpen('create', 0);
245
+ if (debouncedSearchTerm && debouncedSearchTerm.trim() !== '') {
246
+ fetchSearchResults(debouncedSearchTerm);
86
247
  }
87
- }, [form]);
248
+ }, [debouncedSearchTerm, fetchSearchResults]);
88
249
 
250
+ // Effect to open modal when form is set
89
251
  useEffect(() => {
90
- const hasSearchResults = setting.search.types.some(
91
- (type) => searchResults[type.id]?.length > 0
92
- );
93
- setShowSearchResults(hasSearchResults);
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
- /** Component Structure */
111
- const ResultSection = ({ title, data }) => {
112
- if (!data?.length) return null;
113
- return (
114
- <>
115
- <div className={styles.resultItem}>
116
- <h3>{title}</h3>
117
- </div>
118
- {data.map((result) => (
119
- <div
120
- key={result.id}
121
- className={styles.resultItem}
122
- onClick={() => handleSelectResult(title, result)}
123
- >
124
- <p>
125
- {title === 'Contacts'
126
- ? `${result.firstname} ${result.surname}`
127
- : result.name || result.project_name}
128
- </p>
129
- </div>
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>Quick Actions</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={handleSearch}
160
- onKeyDown={handleSearch}
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
- <Search
164
- strokeWidth={2}
165
- size={18}
166
- className={styles.searchIcon}
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 className={styles.searchResults}>
172
- {setting.search.types.map((t) => (
173
- <ResultSection
174
- key={t.id}
175
- title={t.title}
176
- data={searchResults[t.id]}
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>
@@ -9,8 +9,7 @@ import dayjs from 'dayjs';
9
9
  import parse from 'html-react-parser';
10
10
  import { createSwapy } from 'swapy';
11
11
  import { CircleX, Minus, Plus, Star, TrashCan } from 'akar-icons';
12
- import { confirmAlert } from 'react-confirm-alert';
13
- import 'react-confirm-alert/src/react-confirm-alert.css';
12
+ import { confirmDialog } from '../../utils/ConfirmDialog';
14
13
 
15
14
  import CustomFetch from '../../crm/Fetch';
16
15
  import MultiSelect from '../../crm/MultiSelect';
@@ -321,9 +320,10 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
321
320
  };
322
321
 
323
322
  const confirmDelete = (widgetId) => {
324
- confirmAlert({
323
+ confirmDialog({
325
324
  title: 'Confirm to delete',
326
325
  message: 'Are you sure you want to delete this widget?',
326
+ data: { id: widgetId, name: 'this widget' }, // Add context data
327
327
  buttons: [
328
328
  { label: 'Yes', onClick: () => handleDeleteWidget(widgetId) },
329
329
  { label: 'No' },