data-primals-engine 1.4.2 → 1.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.
Files changed (59) hide show
  1. package/README.md +878 -856
  2. package/client/package-lock.json +82 -0
  3. package/client/package.json +2 -0
  4. package/client/src/App.jsx +1 -1
  5. package/client/src/App.scss +25 -7
  6. package/client/src/AssistantChat.scss +3 -2
  7. package/client/src/ConditionBuilder.jsx +1 -1
  8. package/client/src/ConditionBuilder2.jsx +2 -1
  9. package/client/src/DashboardView.jsx +569 -569
  10. package/client/src/DataEditor.jsx +376 -368
  11. package/client/src/DataLayout.jsx +4 -10
  12. package/client/src/DataTable.jsx +858 -817
  13. package/client/src/Field.jsx +1825 -1784
  14. package/client/src/FlexDataRenderer.jsx +2 -0
  15. package/client/src/FlexTreeUtils.js +1 -1
  16. package/client/src/GeolocationField.jsx +94 -0
  17. package/client/src/KPIDialog.jsx +11 -1
  18. package/client/src/ModelCreator.jsx +1 -2
  19. package/client/src/ModelCreatorField.jsx +24 -27
  20. package/client/src/ModelList.jsx +1 -1
  21. package/client/src/RelationField.jsx +2 -2
  22. package/client/src/constants.js +3 -3
  23. package/client/src/filter.js +0 -155
  24. package/client/src/hooks/useTutorials.jsx +62 -65
  25. package/client/src/translations.js +14 -2
  26. package/package.json +2 -1
  27. package/perf/README.md +147 -0
  28. package/perf/artillery-hooks.js +37 -0
  29. package/perf/perf-shot-hardwork.yml +84 -0
  30. package/perf/perf-shot-search.yml +45 -0
  31. package/perf/setup.yml +26 -0
  32. package/server.js +1 -1
  33. package/src/constants.js +264 -31
  34. package/src/core.js +15 -1
  35. package/src/data.js +1 -1
  36. package/src/defaultModels.js +1544 -1540
  37. package/src/email.js +5 -2
  38. package/src/engine.js +10 -3
  39. package/src/filter.js +274 -260
  40. package/src/i18n.js +187 -177
  41. package/src/modules/assistant/assistant.js +3 -1
  42. package/src/modules/bucket.js +12 -15
  43. package/src/modules/data/data.backup.js +11 -8
  44. package/src/modules/data/data.core.js +2 -1
  45. package/src/modules/data/data.js +6 -3
  46. package/src/modules/data/data.operations.js +610 -168
  47. package/src/modules/data/data.routes.js +1821 -1785
  48. package/src/modules/data/data.scheduling.js +2 -1
  49. package/src/modules/data/data.validation.js +7 -1
  50. package/src/modules/file.js +4 -2
  51. package/src/modules/user.js +4 -1
  52. package/src/modules/workflow.js +9 -10
  53. package/src/openai.jobs.js +2 -0
  54. package/src/packs.js +22 -5
  55. package/src/providers.js +22 -7
  56. package/swagger-en.yml +133 -0
  57. package/test/data.integration.test.js +1060 -981
  58. package/test/import_export.integration.test.js +1 -1
  59. package/test/model.integration.test.js +377 -221
@@ -1,1785 +1,1826 @@
1
- import React, {
2
- forwardRef, useCallback,
3
- useEffect,
4
- useImperativeHandle,
5
- useRef,
6
- useState,
7
- } from "react";
8
- import uniqid from "uniqid";
9
- import cn from "classnames";
10
- import { recursiveMap, useRefs } from "./Utils.jsx";
11
- import {useTranslation} from "react-i18next";
12
- import { CodeiumEditor } from "@codeium/react-code-editor";
13
-
14
- import {debounce, escapeRegExp, isGUID, isLightColor} from "../../src/core.js";
15
- import {mainFieldsTypes, maxFileSize} from "../../src/constants.js";
16
- import {useModelContext} from "./contexts/ModelContext.jsx";
17
- import {useQueryClient} from "react-query";
18
- import {
19
- FaArrowDown,
20
- FaArrowUp, FaAt, FaEye, FaEyeSlash,
21
- FaCalendar, FaCalendarWeek, FaCode, FaFile,
22
- FaHashtag, FaIcons,
23
- FaImage,
24
- FaLink, FaListOl, FaListUl,
25
- FaLock, FaMinus,
26
- FaPallet, FaPhone, FaSitemap,
27
- FaToggleOn
28
- } from "react-icons/fa";
29
- import * as Fa6Icons from 'react-icons/fa6'; // Importer Fa6
30
- import {FaCalendarDays, FaCodeCompare, FaPencil, FaT, FaTableColumns} from "react-icons/fa6";
31
- import { CodeBlock, tomorrowNightBright } from 'react-code-blocks';
32
- import SyntaxHighlighter from 'react-syntax-highlighter';
33
- import { docco } from 'react-syntax-highlighter/dist/esm/styles/hljs';
34
-
35
- import { PhoneInput } from 'react-international-phone';
36
- import 'react-international-phone/style.css';
37
- import {useAuthContext} from "./contexts/AuthContext.jsx";
38
- import Switch from "react-switch";
39
- export const Form = ({
40
- name,
41
- onValidate,
42
- onError,
43
- children,
44
- editable,
45
- className = "",
46
- }) => {
47
- const [childrenRef, registerRef] = useRefs();
48
- const onSubmit = (e) => {
49
- e.preventDefault();
50
- let res = true;
51
- Object.keys(childrenRef.current).forEach((item) => {
52
- res = childrenRef.current[item].validate() && res;
53
- });
54
- if (res) {
55
- if (onValidate) onValidate(e);
56
- } else {
57
- if (onError) onError();
58
- }
59
- };
60
-
61
- /**/
62
- return (
63
- <form
64
- name={name}
65
- noValidate={true}
66
- contentEditable={editable}
67
- className={cn({ ["form-" + name]: true }) + " " + (className || "")}
68
- onSubmit={onSubmit}
69
- >
70
- {recursiveMap(children, (child, index) => {
71
- /*if( child?.type?.displayName?.match(/(Field|RadioGroup)/)){
72
- return <child.type {...child.props} ref={registerRef(child?.type?.displayName.concat('-').concat(child.props.name || uniqid()))} />
73
- }*/
74
- return child;
75
- })}
76
- </form>
77
- );
78
- };
79
-
80
- const TextField = forwardRef(function TextField(
81
- {
82
- name,
83
- label,
84
- placeholder,
85
- help,
86
- editable,
87
- value,
88
- required,
89
- readOnly,
90
- onChange,
91
- multiline,
92
- minlength,
93
- maxlength,
94
- searchable,
95
- labelProps,
96
- showErrors=false,
97
- before,
98
- after,
99
- ...rest
100
- },
101
- ref,
102
- ) {
103
- const [id, setId] = useState("textfield-" + uniqid());
104
- const [isPasswordVisible, setIsPasswordVisible] = useState(false);
105
- const [errors, setErrors] = useState([]);
106
- const inputRef = useRef();
107
-
108
- const { type, ...otherRest } = rest;
109
- const isPasswordField = type === 'password';
110
-
111
- const mult = typeof multiline !== 'undefined' ? multiline : maxlength > 255;
112
- const validate = () => {
113
- const errs = [];
114
- if (required && (!value || value.trim() === "")) {
115
- errs.push("Field required");
116
- }
117
- if (
118
- minlength > 0 &&
119
- typeof value == "string" &&
120
- value.trim().length < minlength
121
- ) {
122
- errs.push("Value length must be >= to " + minlength);
123
- }
124
- if (
125
- maxlength !== undefined && maxlength > 0 &&
126
- typeof value == "string" &&
127
- value.trim().length > maxlength
128
- ) {
129
- errs.push("Value length must be <= to " + maxlength);
130
- }
131
- if( showErrors )
132
- setErrors(errs);
133
- return !errs.length;
134
- };
135
- useImperativeHandle(ref, () => ({
136
- ref: inputRef.current,
137
- validate,
138
- getValue: () => value,
139
- }));
140
- const handleChange = (e) => {
141
- if (onChange) {
142
- onChange(e);
143
- }
144
- };
145
-
146
- const togglePasswordVisibility = () => {
147
- setIsPasswordVisible(prevState => !prevState);
148
- };
149
- useEffect(() => {
150
- if (value !== null) validate();
151
- }, [value]);
152
- return (
153
- <>
154
- <div
155
- className={cn({
156
- field: true,
157
- flex: true,
158
- "field-text": !mult,
159
- "field-multiline": mult,
160
- })}
161
- >
162
- {label && (
163
- <label
164
- contentEditable={editable}
165
- className={cn({help: !!help, 'flex-1': true})}
166
- htmlFor={id}
167
- {...labelProps}
168
- >
169
- {label}
170
- {required ? (
171
- <span className="mandatory" contentEditable={false}>
172
- *
173
- </span>
174
- ) : (
175
- ""
176
- )}
177
- </label>
178
- )}
179
-
180
- {help &&<div className="flex help">{help}</div>}
181
-
182
- {mult && (
183
- <textarea
184
- ref={inputRef}
185
- aria-required={required}
186
- aria-readonly={readOnly}
187
- readOnly={readOnly}
188
- placeholder={placeholder}
189
- id={id}
190
- name={name}
191
- value={value || ""}
192
- rows={8}
193
- onChange={handleChange}
194
- minLength={minlength}
195
- maxLength={maxlength}
196
- {...rest}
197
- ></textarea>
198
- )}
199
-
200
- {before}
201
- <div className={"flex flex-1 flex-no-gap flex-start"} style={{ position: 'relative' }}>
202
- {!mult && (
203
- <input
204
- ref={inputRef}
205
- aria-required={required}
206
- aria-readonly={readOnly}
207
- readOnly={readOnly}
208
- type={isPasswordField ? (isPasswordVisible ? 'text' : 'password') : (searchable ? "search" : (type || "text"))}
209
- placeholder={placeholder}
210
- title={placeholder}
211
- alt={placeholder}
212
- id={id}
213
- name={name}
214
- value={value || ""}
215
- onChange={handleChange}
216
- minLength={minlength}
217
- maxLength={maxlength}
218
- required={required}
219
- style={isPasswordField ? { paddingRight: '40px' } : {}}
220
- {...otherRest}
221
- />
222
- )}
223
- {isPasswordField && !mult && (
224
- <button type="button" onClick={togglePasswordVisibility} className="password-toggle-icon" style={{position: 'absolute', right: '10px', top: '50%', transform: 'translateY(-50%)', display: 'flex', alignItems: 'center'}}>
225
- {isPasswordVisible ? <FaEyeSlash /> : <FaEye />}
226
- </button>
227
- )}
228
- {after}
229
- </div>
230
- </div>
231
- {errors.length > 0 && (
232
- <ul className="error">
233
- {errors.map((e, key) => (
234
- <li key={key} aria-live="assertive" role="alert">
235
- {e}
236
- </li>
237
- ))}
238
- </ul>
239
- )}
240
- </>
241
- );
242
- });
243
-
244
- TextField.displayName = "TextField";
245
- export { TextField };
246
-
247
- const EmailField = forwardRef(
248
- (
249
- {
250
- name,
251
- label,
252
- placeholder,
253
- help,
254
- editable,
255
- defaultValue,
256
- required,
257
- readOnly,
258
- onChange,
259
- minlength,
260
- maxlength,
261
- fieldValidated,
262
- },
263
- ref,
264
- ) => {
265
- const id = "emailfield-" + uniqid();
266
- const [errors, setErrors] = useState([]);
267
- const [value, setValue] = useState(defaultValue || null);
268
- const validate = () => {
269
- const errs = [];
270
- if (required && (!value || value.trim() === "")) {
271
- errs.push("Field required");
272
- }
273
- if (minlength !== undefined && minlength > 0 && value && value.trim().length < minlength) {
274
- errs.push("Value length must be >= to " + minlength);
275
- }
276
- if (maxlength !== undefined && maxlength > 0 && value && value.trim().length > maxlength) {
277
- errs.push("Value length must be <= to " + maxlength);
278
- }
279
-
280
- if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
281
- errs.push("Invalid email");
282
- }
283
- setErrors(errs);
284
- return !errs.length;
285
- };
286
- useEffect(() => {
287
- if (value !== null) validate();
288
- }, [value]);
289
- useEffect(() => {
290
- if (fieldValidated) validate();
291
- }, [fieldValidated]);
292
- useImperativeHandle(ref, () => ({
293
- validate,
294
- getValue: () => value,
295
- }));
296
- const handleChange = (e) => {
297
- setValue(e.target.value);
298
- if (onChange) {
299
- onChange(e);
300
- }
301
- };
302
- return (
303
- <>
304
- <div className={cn({ field: true, "field-email": true })}>
305
- <label
306
- contentEditable={editable}
307
- className={cn({ help: !!help })}
308
- title={help}
309
- htmlFor={id}
310
- >
311
- {label}
312
- {required ? (
313
- <span className="mandatory" contentEditable={false}>
314
- *
315
- </span>
316
- ) : (
317
- ""
318
- )}
319
- </label>
320
- <input
321
- aria-required={required}
322
- aria-readonly={readOnly}
323
- readOnly={readOnly}
324
- type="email"
325
- placeholder={placeholder}
326
- id={id}
327
- name={name}
328
- value={value || ""}
329
- onChange={handleChange}
330
- minLength={minlength}
331
- maxLength={maxlength}
332
- />
333
- </div>
334
- {errors.length > 0 && (
335
- <ul className="error">
336
- {errors.map((e, key) => (
337
- <li key={key} aria-live="assertive" role="alert">
338
- {e}
339
- </li>
340
- ))}
341
- </ul>
342
- )}
343
- </>
344
- );
345
- },
346
- );
347
- EmailField.displayName = "EmailField";
348
- export { EmailField };
349
-
350
- const NumberField = forwardRef(
351
- (
352
- {
353
- name,
354
- label,
355
- placeholder,
356
- help,
357
- editable,
358
- value,
359
- required,
360
- readOnly,
361
- onChange,
362
- minlength,
363
- maxlength,
364
- min,
365
- max,
366
- step,
367
- unit,
368
- ...rest
369
- },
370
- ref,
371
- ) => {
372
- const id = "numberfield-" + uniqid();
373
- const [errors, setErrors] = useState([]);
374
- const inputRef = useRef();
375
- const validate = () => {
376
- const errs = [];
377
- if (required && value === undefined) {
378
- errs.push("Field required");
379
- }
380
- if (minlength !== undefined && minlength > 0 && value && value.trim().length < minlength) {
381
- errs.push("Value length must be >= to " + minlength);
382
- }
383
- if (maxlength !== undefined && maxlength > 0 && value && value.trim().length > maxlength) {
384
- errs.push("Value length must be <= to " + maxlength);
385
- }
386
- if ((min || min === 0) && (value || value === 0) && min > value) {
387
- errs.push("Value < to " + min);
388
- }
389
- if ((max || max === 0) && (value || value === 0) && max < value) {
390
- errs.push("Value > to " + max);
391
- }
392
- setErrors(errs);
393
- return !errs.length;
394
- };
395
- useEffect(() => {
396
- if (value !== null) validate();
397
- }, [value]);
398
- useImperativeHandle(ref, () => ({
399
- ref: inputRef.current,
400
- validate,
401
- getValue: () => value,
402
- }));
403
- const handleChange = (e) => {
404
- if (onChange) {
405
- onChange(e);
406
- }
407
- };
408
- return (
409
- <>
410
- <div className={cn({ field: true, "field-number": true })}>
411
-
412
- <div className="flex flex-1">
413
- {label && (
414
- <label
415
- contentEditable={editable}
416
- className={cn({ help: !!help, flex: true, 'flex-1': true })}
417
- title={help}
418
- htmlFor={id}
419
- >
420
- {label}
421
- {required ? (
422
- <span className="mandatory" contentEditable={false}>
423
- *
424
- </span>
425
- ) : (
426
- ""
427
- )}
428
- </label>
429
- )}
430
- {help && <div className="flex help">{help}</div>}
431
- <div className={"flex flex-1 flex-no-wrap flex-mini-gap"}>
432
- <input
433
- ref={inputRef}
434
- aria-required={required}
435
- aria-readonly={readOnly}
436
- readOnly={readOnly}
437
- type="number"
438
- placeholder={placeholder}
439
- id={id}
440
- name={name}
441
- value={value || ""}
442
- onChange={handleChange}
443
- minLength={minlength}
444
- maxLength={maxlength}
445
- min={min}
446
- max={max}
447
- step={step}
448
- {...rest}
449
- />
450
- {unit && <span className="unit">{unit}</span>}
451
- </div>
452
- </div>
453
- </div>
454
- {errors.length > 0 && (
455
- <ul className="error">
456
- {errors.map((e, key) => (
457
- <li key={key} aria-live="assertive" role="alert">
458
- {e}
459
- </li>
460
- ))}
461
- </ul>
462
- )}
463
- </>
464
- );
465
- },
466
- );
467
- NumberField.displayName = "NumberField";
468
- export { NumberField };
469
-
470
- const CheckboxField = forwardRef(
471
- (
472
- {
473
- name,
474
- label,
475
- placeholder,
476
- help,
477
- editable,
478
- defaultValue,
479
- required,
480
- readOnly,
481
- onChange,
482
- minlength,
483
- maxlength,
484
- checked,
485
- ...rest
486
- },
487
- ref,
488
- ) => {
489
- const id = "checkfield-" + uniqid();
490
- const [errors, setErrors] = useState([]);
491
- const [value, setValue] = useState(checked || false);
492
- useEffect(() => {
493
- setValue(checked);
494
- }, [checked]);
495
- const validate = () => {
496
- const errs = [];
497
- if (required && !value) {
498
- errs.push("Field must be checked.");
499
- }
500
- setErrors(errs);
501
- return !errs.length;
502
- };
503
- useEffect(() => {
504
- if (value !== null) validate();
505
- }, [value]);
506
- useImperativeHandle(ref, () => ({
507
- validate,
508
- getValue: () => value,
509
- }));
510
- const handleChange = (e) => {
511
- setValue(!value);
512
- onChange?.(e);
513
- };
514
- return (
515
- <>
516
- <div className={cn({field: true, "field-checkbox": true,"field-bg": true})}>
517
- {label && (
518
- <label
519
- contentEditable={editable}
520
- title={help}
521
- htmlFor={id}
522
- >
523
- {label}
524
- {required ? (
525
- <span className="mandatory" contentEditable={false}>
526
- *
527
- </span>
528
- ) : (
529
- ""
530
- )}
531
- </label>
532
- )}
533
- {help && <div className="flex help">{help}</div>}
534
- <Switch
535
- id={id}
536
- onChange={handleChange}
537
- checked={value} />
538
- </div>
539
- {errors.length > 0 && (
540
- <ul className="error">
541
- {errors.map((e, key) => (
542
- <li key={key} aria-live="assertive" role="alert">
543
- {e}
544
- </li>
545
- ))}
546
- </ul>
547
- )}
548
- </>
549
- );
550
- },
551
- );
552
- CheckboxField.displayName = "CheckboxField";
553
- export { CheckboxField };
554
-
555
- const RadioField = forwardRef(
556
- (
557
- {
558
- name,
559
- label,
560
- placeholder,
561
- help,
562
- editable,
563
- checked,
564
- required,
565
- readOnly,
566
- onChange,
567
- minlength,
568
- maxlength,
569
- },
570
- ref,
571
- ) => {
572
- const id = "radiofield-" + uniqid();
573
- const [errors, setErrors] = useState([]);
574
- const [value, setValue] = useState(checked || null);
575
- const validate = () => {
576
- const errs = [];
577
- if (required && !value) {
578
- errs.push("Field must be checked.");
579
- }
580
- setErrors(errs);
581
- return !errs.length;
582
- };
583
- useEffect(() => {
584
- if (value !== null) validate();
585
- }, [value]);
586
- useImperativeHandle(ref, () => ({
587
- validate,
588
- getValue: () => value,
589
- }));
590
- const handleChange = (e) => {
591
- setValue(!value);
592
- if (onChange) {
593
- onChange(e);
594
- }
595
- };
596
- return (
597
- <>
598
- <div className={cn({ field: true, "field-radio": true })}>
599
- <input
600
- aria-required={required}
601
- aria-readonly={readOnly}
602
- readOnly={readOnly}
603
- type="radio"
604
- checked={value}
605
- value={value || label}
606
- placeholder={placeholder}
607
- id={id}
608
- name={name}
609
- onChange={handleChange}
610
- minLength={minlength}
611
- maxLength={maxlength}
612
- />
613
- <label
614
- contentEditable={editable}
615
- className={cn({ help: !!help })}
616
- title={help}
617
- htmlFor={id}
618
- >
619
- {label}
620
- {required ? (
621
- <span className="mandatory" contentEditable={false}>
622
- *
623
- </span>
624
- ) : (
625
- ""
626
- )}
627
- </label>
628
- </div>
629
- {errors.length > 0 && (
630
- <ul className="error">
631
- {errors.map((e, key) => (
632
- <li key={key} aria-live="assertive" role="alert">
633
- {e}
634
- </li>
635
- ))}
636
- </ul>
637
- )}
638
- </>
639
- );
640
- },
641
- );
642
- RadioField.displayName = "RadioField";
643
- export { RadioField };
644
-
645
- const SelectField = forwardRef(
646
- (
647
- {
648
- name,
649
- value,
650
- items,
651
- label,
652
- placeholder,
653
- disabled,
654
- help,
655
- editable,
656
- checked,
657
- required,
658
- readOnly,
659
- onChange,
660
- minlength,
661
- maxlength,
662
- multiple,
663
- ...rest
664
- },
665
- ref,
666
- ) => {
667
- const [values, setValues] = useState([]);
668
- const id = "selectfield-" + uniqid();
669
- const [errors, setErrors] = useState([]);
670
- const [_value, setValue] = useState(value);
671
- useEffect(() => {
672
- if( value === undefined && required && items[0]){
673
- setValue(items[0].value);
674
- }else {
675
- setValue(value);
676
- setValues(value)
677
- }
678
- if (!multiple && value) {
679
- //const index = items.findIndex((i) => i.value === value);
680
- //onChange({name, value:items[index]}, index);
681
- }
682
- }, [value]);
683
- const validate = () => {
684
- const errs = [];
685
- if (required && _value === undefined) {
686
- errs.push("Field is required.");
687
- }
688
- setErrors(errs);
689
- return !errs.length;
690
- };
691
- useEffect(() => {
692
- if (_value !== null) validate();
693
- }, [_value]);
694
- useImperativeHandle(ref, () => ({
695
- validate,
696
- getValue: () => _value,
697
- setValue,
698
- }));
699
- const handleChange = (e) => {
700
- setValue(e.target.value);
701
- if (onChange) {
702
- let options = e.target.options;
703
- let value = [];
704
- for (var i = 0, l = options.length; i < l; i++) {
705
- if (options[i].selected) {
706
- value.push(options[i].value);
707
- }
708
- }
709
- if( multiple ) {
710
- setValues(value);
711
- onChange(value);
712
- }else {
713
- const index = items.findIndex((i) => i.value+'' === e.target.value);
714
- onChange(items[index], index);
715
- }
716
- }
717
- };
718
- return (
719
- <>
720
- <div className={cn({ field: true, 'flex-1': true, flex: true, "field-select": true })}>
721
- {label && (
722
- <label
723
- contentEditable={editable}
724
- className={cn({ help: !!help, 'flex-1': true })}
725
- title={help}
726
- htmlFor={id}
727
- >
728
- {label}
729
- {required ? (
730
- <span className="mandatory" contentEditable={false}>
731
- *
732
- </span>
733
- ) : (
734
- ""
735
- )}
736
- </label>
737
- )}
738
- <select
739
- aria-required={required}
740
- aria-readonly={readOnly}
741
- value={(_value)}
742
- id={id}
743
- name={name}
744
- onChange={handleChange}
745
- multiple={multiple}
746
- disabled={disabled}
747
- className={"flex-1"}
748
- {...rest}
749
- >
750
- {items.map((i) => (
751
- <option value={i.value}>{i.label}</option>
752
- ))}
753
- </select>
754
- </div>
755
- {help && <div className="flex help">{help}</div>}
756
- {errors.length > 0 && (
757
- <ul className="error">
758
- {errors.map((e, key) => (
759
- <li key={key} aria-live="assertive" role="alert">
760
- {e}
761
- </li>
762
- ))}
763
- </ul>
764
- )}
765
- </>
766
- );
767
- },
768
- );
769
- SelectField.displayName = "SelectField";
770
- export { SelectField };
771
-
772
- const RadioGroup = forwardRef(
773
- ({ id, label, help, editable, name, required, children }, ref) => {
774
- const [childrenRef, registerRef] = useRefs();
775
- const [errors, setErrors] = useState([]);
776
- const validate = () => {
777
- const errs = [];
778
- let res = false;
779
- Object.keys(childrenRef.current).forEach((item) => {
780
- res = !!childrenRef.current[item].getValue() || res;
781
- });
782
- if (!res && required) {
783
- errs.push("Field is required");
784
- }
785
- setErrors(errs);
786
- return !errs.length;
787
- };
788
- useImperativeHandle(ref, () => ({
789
- validate,
790
- }));
791
- const handleChange = () => {
792
- setTimeout(() => validate(), 0);
793
- };
794
- return (
795
- <>
796
- <label
797
- contentEditable={editable}
798
- className={cn({ help: !!help })}
799
- title={help}
800
- htmlFor={children[0].props.id}
801
- >
802
- {label}
803
- {required ? (
804
- <span className="mandatory" contentEditable={false}>
805
- *
806
- </span>
807
- ) : (
808
- ""
809
- )}
810
- </label>
811
- {[
812
- recursiveMap(children, (child, index) => {
813
- if (child.type.displayName === "RadioField") {
814
- const props = {
815
- ...child.props,
816
- name: name ? name : child.props.name,
817
- onChange: () => handleChange(child.props.onChange),
818
- };
819
- return (
820
- <child.type
821
- {...props}
822
- ref={registerRef("Radio" + index)}
823
- name={child.props.name || "btn" + id}
824
- />
825
- );
826
- }
827
- return child;
828
- }),
829
- errors.length > 0 ? (
830
- <ul className="error">
831
- {errors.map((e, key) => (
832
- <li key={key} aria-live="assertive" role="alert">
833
- {e}
834
- </li>
835
- ))}
836
- </ul>
837
- ) : (
838
- <></>
839
- ),
840
- ]}
841
- </>
842
- );
843
- },
844
- );
845
-
846
- RadioGroup.displayName = "RadioGroup";
847
- export { RadioGroup };
848
-
849
- // New FileField component
850
- const FileField = ({ inputProps, value, onChange, name, mimeTypes, maxSize, multiple}) => {
851
- const [fileInfos, setFileInfos] = useState(value);
852
- const { t } = useTranslation();
853
-
854
- const handleFileChange = (e) => {
855
- const selectedFiles = Array.from(e.target.files);
856
- const newFileInfos = [];
857
-
858
- const promises = selectedFiles.map(selectedFile => {
859
- if (selectedFile && selectedFile.size > (maxSize || maxFileSize)) {
860
- alert(`Le fichier est trop volumineux. La taille maximale autorisée est de ${(maxSize || maxFileSize) / (1024 * 1024)} Mo.`);
861
- e.target.value = '';
862
- return Promise.resolve();
863
- }
864
- return new Promise((resolve) => {
865
- const reader = new FileReader();
866
- reader.onloadend = () => {
867
- newFileInfos.push({
868
- preview: reader.result,
869
- newFile: true,
870
- file: selectedFile,
871
- name: selectedFile.name
872
- });
873
- resolve();
874
- };
875
- reader.readAsDataURL(selectedFile);
876
- });
877
- });
878
-
879
- Promise.all(promises).then(() => {
880
- if(!multiple){
881
- setFileInfos(newFileInfos);
882
- }else{
883
- setFileInfos(fileInfos => [...fileInfos, ...newFileInfos]);
884
- }
885
- onChange([...fileInfos.map(m => ({...m, newFile: false})), ...newFileInfos]);
886
- });
887
- };
888
-
889
- const handleRemove = (e, index) => {
890
- e.preventDefault();
891
- const newFileInfos = fileInfos.filter((_, i) => i !== index);
892
- setFileInfos(newFileInfos);
893
- onChange(newFileInfos.map(m => ({...m, newFile: false})));
894
- };
895
-
896
- useEffect(() => {
897
- if( value == null || (Array.isArray(value) && value.length === 0))
898
- setFileInfos([])
899
- else{
900
- const v = Array.isArray(value) ? value : [value];
901
- setFileInfos(v)
902
- }
903
- }, [value]);
904
-
905
- return (
906
- <div className="field field-file">
907
- <input
908
- id={"field-file-" + name}
909
- type="file"
910
- data-field={name}
911
- accept={mimeTypes ? mimeTypes.join(',') : '*'}
912
- onChange={handleFileChange}
913
- multiple={multiple} // Add multiple attribute
914
- />
915
- {fileInfos?.length > 0 && (
916
- <div>
917
- {fileInfos.filter(f => isGUID(f.guid) || f.preview).map((fileInfo, index) => (
918
- <div key={index}>
919
- {fileInfo.preview ? (
920
- <a href={fileInfo.preview} target="_blank" rel="noopener noreferrer">
921
- <img src={fileInfo.preview} alt={"Preview"} width='200' height='200' />
922
- </a>
923
- ) :(isGUID(fileInfo.guid) ? (
924
- <a href={"/resources/"+fileInfo.guid} target="_blank" rel="noopener noreferrer">
925
- <img src={"/resources/"+fileInfo.guid} alt={"Preview"} width='200' height='200' />
926
- </a>
927
- ) :(
928
- <img src={fileInfo.preview} alt="Preview" style={{ maxWidth: '200px', maxHeight: '200px' }} />
929
- ))}
930
- <button onClick={(e) => handleRemove(e, index)}><FaMinus /></button>
931
- </div>
932
- ))}
933
- </div>
934
- )}
935
- </div>
936
- );
937
- };
938
-
939
- export { FileField };
940
-
941
- export const FilterNumberField = ({ model, field, onChangeFilterValue, filterValues, setFilterValues }) => {
942
- const { t } = useTranslation();
943
- const { models, setPage, dataByModel } = useModelContext(); // dataByModel is not used, consider removing
944
- const [min, setMin] = useState(null);
945
- const [max, setMax] = useState(null);
946
-
947
- // Debounced version of the function that actually calls onChangeFilterValue
948
- const debouncedApplyFilter = useCallback(
949
- debounce((currentMin, currentMax) => {
950
- const conditions = [];
951
- setPage(1);
952
- if (currentMin !== null && !isNaN(currentMin)) {
953
- conditions.push({ $gte: ['$' + field.name, parseFloat(currentMin)] });
954
- }
955
- if (currentMax !== null && !isNaN(currentMax)) {
956
- conditions.push({ $lte: ['$' + field.name, parseFloat(currentMax)] });
957
- }
958
-
959
- if (conditions.length > 0) {
960
- onChangeFilterValue(field, { $and: conditions });
961
- } else {
962
- onChangeFilterValue(field, {}); // Clear filter if both are invalid/null
963
- }
964
- }, 300), // Adjust delay as needed
965
- [field, onChangeFilterValue] // Dependencies for useCallback
966
- );
967
-
968
- useEffect(() => {
969
- // This effect is to reset local min/max if the global filterValues are cleared externally
970
- // It should not call debouncedApplyFilter directly if filterValues is the source of truth
971
- // for the parent component.
972
- if (filterValues && typeof filterValues[field.name] === 'object') {
973
- const andConditions = filterValues[field.name]?.$and;
974
- if (andConditions && Array.isArray(andConditions)) {
975
- const gteCondition = andConditions.find(cond => cond.$gte);
976
- const lteCondition = andConditions.find(cond => cond.$lte);
977
- setMin(gteCondition ? gteCondition.$gte[1] : null);
978
- setMax(lteCondition ? lteCondition.$lte[1] : null);
979
- } else {
980
- // If the structure is not $and or it's cleared
981
- setMin(null);
982
- setMax(null);
983
- }
984
- } else if (!filterValues || filterValues[field.name] === undefined || Object.keys(filterValues[field.name] || {}).length === 0) {
985
- // If filterValues for this field is cleared or doesn't exist
986
- setMin(null);
987
- setMax(null);
988
- }
989
- }, [filterValues, field.name]);
990
-
991
-
992
- const handleMinChange = (e) => {
993
- const inputValue = e.target.value;
994
- if (inputValue === "") {
995
- setMin(null);
996
- debouncedApplyFilter(null, max);
997
- } else {
998
- const pi = parseFloat(inputValue); // Use parseFloat for potentially decimal numbers
999
- if (!isNaN(pi)) {
1000
- setMin(pi);
1001
- debouncedApplyFilter(pi, max);
1002
- } else {
1003
- setMin(inputValue); // Keep invalid input in state to show user, but don't filter
1004
- // Or setMin(null) if you want to clear on invalid
1005
- // Potentially call debouncedApplyFilter(null, max) if invalid min means no min filter
1006
- }
1007
- }
1008
- gtag('event', 'search (number,min)');
1009
- };
1010
-
1011
- const handleMaxChange = (e) => {
1012
- const inputValue = e.target.value;
1013
- if (inputValue === "") {
1014
- setMax(null);
1015
- debouncedApplyFilter(min, null);
1016
- } else {
1017
- const pi = parseFloat(inputValue);
1018
- if (!isNaN(pi)) {
1019
- setMax(pi);
1020
- debouncedApplyFilter(min, pi);
1021
- } else {
1022
- setMax(inputValue);
1023
- // Potentially call debouncedApplyFilter(min, null) if invalid max means no max filter
1024
- }
1025
- }
1026
- gtag('event', 'search (number,max)');
1027
- };
1028
-
1029
- return (
1030
- <>
1031
- <NumberField
1032
- value={min === null ? '' : min} // Handle null for empty display
1033
- label="Min:"
1034
- onChange={handleMinChange}
1035
- type="number" // Ensure type is number for appropriate input behavior
1036
- />
1037
- <NumberField
1038
- value={max === null ? '' : max} // Handle null for empty display
1039
- label="Max:"
1040
- onChange={handleMaxChange}
1041
- type="number" // Ensure type is number
1042
- />
1043
- </>
1044
- );
1045
- };
1046
-
1047
- export const FilterEnumField = ({model, field, onChangeFilterValue, filterValues, setFilterValues}) => {
1048
- const {t} = useTranslation();
1049
- const debounced = debounce((field,filter) => onChangeFilterValue(field, { $find: filter }));
1050
- const { models, setPage, elementsPerPage,pagedFilters, pagedSort, page } = useModelContext();
1051
-
1052
- const [val, setVal] = useState(null);
1053
- const queryClient= useQueryClient()
1054
-
1055
- useEffect(() => {
1056
- if (Object.keys(filterValues).length === 0){
1057
- onChangeFilterValue(field, { });
1058
- setVal('');
1059
- }
1060
- }, [filterValues]);
1061
-
1062
- return <div className={"flex flex-no-gap flex-no-wrap"}><SelectField value={val} className={"flex-1"} items={['', ...(field.items || [])].map(m => ({label: t(m), value: m}))} onChange={(e) => {
1063
-
1064
- setPage(1);
1065
-
1066
- if( !e || e.value === '') {
1067
- setVal('');
1068
- onChangeFilterValue(field, undefined);
1069
- }
1070
- else {
1071
- onChangeFilterValue(field, {$eq: ['$' + field.name, e.value]});
1072
- setVal(e.value);
1073
- }
1074
-
1075
- gtag('event', 'search (enum)');
1076
- queryClient.invalidateQueries(['api/data', model.name, 'page', page, elementsPerPage, elementsPerPage, pagedFilters[model.name], pagedSort[model.name]]);
1077
- }} /><button onClick={() => {
1078
- onChangeFilterValue(field, { });
1079
- setVal('');
1080
- }}>x</button></div>
1081
- }
1082
- export const FilterBooleanField = ({model, field, filterValues, onChangeFilterValue }) => {
1083
- const {t} = useTranslation();
1084
- const { setPage, pagedFilters, pagedSort, page,elementsPerPage } = useModelContext();
1085
-
1086
- useEffect(() => {
1087
- if( Object.keys(filterValues).length === 0 ){
1088
- setVal('null');
1089
- onChangeFilterValue(field, { });
1090
- }
1091
- }, [filterValues]);
1092
- const [val, setVal] = useState(null);
1093
- const queryClient= useQueryClient()
1094
- return <div className={"flex flex-no-gap flex-no-wrap"}><SelectField value={val} className={"flex-1"} items={[
1095
- {label: t(''), value: 'null'},
1096
- {label: t('yes'), value: '1'},
1097
- { label: t('no'), value: '0'}]}
1098
- onChange={(e) => {
1099
- setPage(1);
1100
- if( !e || e.value === 'null') {
1101
- setVal(null);
1102
- onChangeFilterValue(field, { });
1103
- }
1104
- else {
1105
- onChangeFilterValue(field, {$or: [{$eq: ['$' + field.name, e.value === '1']}, {$eq: [{ $type: '$'+field.name }, "missing"]}]});
1106
- setVal(e.value);
1107
- }
1108
-
1109
- gtag('event', 'search (boolean)');
1110
- queryClient.invalidateQueries(['api/data', model.name, 'page', page, elementsPerPage, pagedFilters[model.name], pagedSort[model.name]]);
1111
- }} /></div>
1112
- }
1113
- export const FilterDateField = ({model, field, filterValues, onChangeFilterValue }) => {
1114
- const {t} = useTranslation();
1115
-
1116
- const [minDate ,setMinDate] = useState(null);
1117
- const [maxDate ,setMaxDate] = useState(null);
1118
- useEffect(() => {
1119
- if( Object.keys(filterValues).length === 0 ){
1120
- onChangeFilterValue(field, { });
1121
- setMinDate('');
1122
- setMaxDate('');
1123
- }
1124
- }, [filterValues]);
1125
- const onChange = (minDate, maxDate) =>{
1126
- const min = minDate ? { $gte: ['$'+field.name, minDate]} : null;
1127
-
1128
- const fm = new Date(maxDate);
1129
- fm.setDate(fm.getDate() + 1);
1130
-
1131
- const max = maxDate ? {$lte: ['$' + field.name, fm.toISOString()]} : null;
1132
- const and= [];
1133
- if( min) and.push(min);
1134
- if( max) and.push(max);
1135
- if( !min && !max)
1136
- onChangeFilterValue(field, { });
1137
- else
1138
- onChangeFilterValue(field, { $and: and});
1139
- gtag('event', 'search (date)');
1140
- }
1141
- return <div className={"flex flex-no-gap flex-no-wrap"}>
1142
- <label htmlFor={"minDate"+model.name+field.name}>
1143
- Min:
1144
- <input id={"minDate"+model.name+field.name} type={"datetime-local"} value={minDate} onChange={e => {
1145
- setMinDate(e.target.value);
1146
- onChange?.(e.target.value, maxDate);
1147
- }} />
1148
- </label>
1149
- <label htmlFor={"maxDate"+model.name+field.name}>
1150
- Max:
1151
- <input id={"maxDate"+model.name+field.name} type={"datetime-local"} value={maxDate} onChange={e => {
1152
- setMaxDate(e.target.value);
1153
- onChange?.(minDate, e.target.value);
1154
- }} />
1155
- </label>
1156
- </div>
1157
- }
1158
- export const FilterStringField = ({ field, onChangeFilterValue, filterValues, setFilterValues }) => {
1159
- const { models, setPage } = useModelContext();
1160
- const [isRegex, setIsRegex] = useState(false);
1161
- const { t } = useTranslation();
1162
-
1163
-
1164
- useEffect(() => {
1165
- if( Object.keys(filterValues).length === 0 ){
1166
- onChangeFilterValue(field, { });
1167
- }
1168
- }, [filterValues]);
1169
-
1170
- // Debounced function to apply the filter
1171
- const debouncedApplyFilter = useCallback(
1172
- debounce((currentValue, currentIsRegex) => {
1173
- setPage(1); // Reset page to 1 when filter changes
1174
-
1175
- if (currentValue === '') {
1176
- // No need to call setFilterValues here as it's done immediately in handleChange
1177
- onChangeFilterValue(field, field.multiple ? [] : undefined, true);
1178
- return;
1179
- }
1180
-
1181
- let filterQuery;
1182
- if (field.type === 'relation') {
1183
- const relationModel = models.find(f => f.name === field.relation);
1184
- if (relationModel) {
1185
- const relationFilters = relationModel.fields
1186
- .filter(f => mainFieldsTypes.includes(f.type))
1187
- .map(mf => ({
1188
- $regexMatch: { input: `$$this.${mf.name}`, regex: currentIsRegex ? currentValue : escapeRegExp(currentValue) }
1189
- }));
1190
- if (relationFilters.length > 0) {
1191
- filterQuery = { [field.name]: {$find: { $and: [{ $or: relationFilters }] }}};
1192
- } else {
1193
- filterQuery = {}; // Or handle as no match if no searchable fields
1194
- }
1195
- } else {
1196
- filterQuery = {}; // Relation model not found
1197
- }
1198
- } else { // Not a relation type
1199
- const regexToUse = currentIsRegex ? currentValue : escapeRegExp(currentValue);
1200
- if (field.type === 'array') {
1201
- filterQuery = {
1202
- $gt: [
1203
- {
1204
- $size: {
1205
- $filter: {
1206
- input: '$' + field.name,
1207
- as: 'item',
1208
- cond: {
1209
- $regexMatch: {
1210
- input: '$$item',
1211
- regex: regexToUse
1212
- }
1213
- }
1214
- }
1215
- }
1216
- },
1217
- 0
1218
- ]
1219
- };
1220
- } else { // Simple string field
1221
- filterQuery = {
1222
- $and: [{
1223
- $regexMatch: {
1224
- input: '$' + field.name,
1225
- regex: regexToUse
1226
- }
1227
- }]
1228
- };
1229
- }
1230
- }
1231
- onChangeFilterValue(field, filterQuery, true);
1232
- gtag('event', 'search (string)');
1233
- }, 1200), // Debounce delay
1234
- [] // Dependencies for useCallback
1235
- );
1236
-
1237
- const handleInputChange = (e) => {
1238
- const newValue = e.target.value;
1239
- // Update the displayed value immediately
1240
- setFilterValues(filter => ({ ...filter, [field.name]: newValue }));
1241
- // Call the debounced function to apply the filter
1242
- debouncedApplyFilter(newValue, isRegex);
1243
- };
1244
-
1245
- const handleToggleRegex = () => {
1246
- const newIsRegex = !isRegex;
1247
- setIsRegex(newIsRegex);
1248
- // Re-apply filter with the new regex state and current value
1249
- // The value from filterValues should be up-to-date
1250
- const currentValue = filterValues[field.name] || '';
1251
- debouncedApplyFilter(currentValue, newIsRegex);
1252
- };
1253
-
1254
- return (
1255
- <>
1256
- <TextField
1257
- type="text"
1258
- name={`filter_${field.name}`}
1259
- value={filterValues[field.name] || ''} // Ensure controlled component with a default empty string
1260
- placeholder={isRegex ? t("filterstringfield.placeholder.regex", "regular expression") : t("filterstringfield.placeholder", "...")}
1261
- onChange={handleInputChange}
1262
- maxLength={1000}
1263
- />
1264
- <button title={"regex"} className={isRegex ? 'active' : ''} onClick={handleToggleRegex}>.*</button>
1265
- </>
1266
- );
1267
- };
1268
- export const FilterField = ({advanced,model, reversed, field, active, onChangeFilterValue, filterValues, setFilterValues}) => {
1269
- const { elementsPerPage, pagedSort, setPagedSort, setPage, page, pagedFilters, lockedColumns, setLockedColumns } = useModelContext();
1270
- const {t} = useTranslation();
1271
- const [locked, setLocked] = useState(lockedColumns.includes(field.name));
1272
- const queryClient = useQueryClient()
1273
-
1274
- useEffect(() => {
1275
- if(!reversed) {
1276
- setFilterValues(filter => ({...filter, [field.name]: ''}));
1277
- onChangeFilterValue(field, '', true);
1278
- }
1279
- }, [field]);
1280
-
1281
- const handleToggleLock = () => {
1282
- if( locked ) {
1283
- if (lockedColumns.includes(field.name))
1284
- setLockedColumns(cols => [...cols].filter(f => f !== field.name));
1285
- }else{
1286
- if (!lockedColumns.includes(field.name))
1287
- setLockedColumns(cols => [...cols, field.name]);
1288
- }
1289
- setLocked(!locked);
1290
- }
1291
-
1292
- const [reset, setReset] = useState(false);
1293
- const handleChangeSort = (up) => {
1294
- setPagedSort(sort => {
1295
- const s = lockedColumns.length > 0 ? {...sort[model.name] || {}} : {};
1296
- if( up ){
1297
- if( reset ){
1298
- delete s[field.name];
1299
- setReset(false);
1300
- }else {
1301
- s[field.name] = 1;
1302
- }
1303
- }else{
1304
- s[field.name] = -1;
1305
- setReset(true);
1306
- }
1307
- return {...sort, [model.name]: s};
1308
- });
1309
- queryClient.invalidateQueries(['api/data', model.name, 'page', page, elementsPerPage, pagedFilters[model.name], pagedSort[model.name]]);
1310
- }
1311
-
1312
- const resetClass = pagedSort[model.name]?.[field.name] ? (((pagedSort[model.name]?.[field.name] === 1) || (pagedSort[model.name]?.[field.name] === -1)) ? 'active' : 'reset') : '';
1313
-
1314
- const renderIconFromType =(field)=>{
1315
- const type = field.type;
1316
- if( type === 'color'){
1317
- return <FaPallet/>;
1318
- }
1319
- if( type === 'code'){
1320
- return <FaCode />;
1321
- }
1322
- else if( type === 'date'){
1323
- return <FaCalendarWeek />;
1324
- }else if( type === 'datetime'){
1325
- return <FaCalendarDays />;
1326
- }
1327
- else if( type === 'richtext' || type === 'string' || type === 'string_t'){
1328
- return <></>;
1329
- }
1330
- else if( type === 'url'){
1331
- return <FaLink />;
1332
- }
1333
- else if( type === 'number'){
1334
- return <FaHashtag />;
1335
- }
1336
- else if( type === 'file'){
1337
- return <FaFile />;
1338
- }
1339
- else if( type === 'enum'){
1340
- return <FaListUl />;
1341
- }
1342
- else if( type === 'boolean'){
1343
- return <FaToggleOn />;
1344
- }
1345
- else if( type === 'image'){
1346
- return <FaImage/>;
1347
- }
1348
- else if( type === 'relation'){
1349
- return field.multiple ? <FaSitemap /> : <FaLink />;
1350
- }
1351
- else if( type === 'email'){
1352
- return <FaAt />;
1353
- }
1354
- else if( type === 'phone'){
1355
- return <FaPhone />;
1356
- }
1357
- else if( type === 'array'){
1358
- return <FaTableColumns />;
1359
- }
1360
- return <FaPencil />
1361
- }
1362
- return <th key={field.name} className={`form filter-field`} style={{backgroundColor: field.color, color: !field.color ||isLightColor(field.color) ? 'black': "white"}}>
1363
- <div className="flex flex-centered flex-mini-gap flex-row">
1364
- <div className="flex flex-1 flex-mini-gap flex-no-wrap">
1365
- {renderIconFromType(field)}
1366
- <span title={field.name} className={"flex-1 title"}>{t(`field_${model.name}_${field.name}`, field.name)}</span>
1367
- </div>
1368
- {advanced && (<>
1369
- { 'password'!==field.type && (<div className={"flex flex-no-gap"}>
1370
- {(<>
1371
- {(pagedSort[model.name]?.[field.name] !== 1) &&
1372
- <button onClick={() => handleChangeSort(true)}
1373
- className={resetClass}>
1374
- {pagedSort[model.name]?.[field.name] === undefined ? <FaArrowDown/> : <FaArrowUp/>}</button>}
1375
- {(pagedSort[model.name]?.[field.name] === 1) &&
1376
- <button onClick={() => handleChangeSort(false)}
1377
- className={resetClass}>
1378
- <FaArrowDown/></button>}
1379
- </>
1380
- )}
1381
- {!field.unique && (
1382
- <button onClick={() => handleToggleLock()} className={locked ? 'active' : ''}><FaLock/></button>)}
1383
- </div>)}
1384
- {active && !['date','datetime','enum', 'boolean', 'number', 'password'].includes(field.type) && <div className="filter flex flex-no-wrap flex-mini-gap">
1385
- <FilterStringField setFilterValues={setFilterValues} filterValues={filterValues} field={field} onChangeFilterValue={onChangeFilterValue} />
1386
- </div>}
1387
- {active && field.type === 'enum' && <div className="filter flex flex-no-wrap flex-mini-gap">
1388
- <FilterEnumField model={model} setFilterValues={setFilterValues} filterValues={filterValues} field={field} onChangeFilterValue={onChangeFilterValue} />
1389
- </div>}
1390
- {active && field.type === 'boolean' && <div className="filter flex flex-no-wrap flex-mini-gap">
1391
- <FilterBooleanField filterValues={filterValues} model={model} field={field} onChangeFilterValue={onChangeFilterValue} />
1392
- </div>}
1393
- {active && ['date', 'datetime'].includes(field.type) && <div className="filter flex flex-no-wrap flex-mini-gap">
1394
- <FilterDateField filterValues={filterValues} model={model} field={field} onChangeFilterValue={onChangeFilterValue} />
1395
- </div>}
1396
- {active && field.type === 'number' && <div className="filter flex flex-no-wrap flex-mini-gap">
1397
- <FilterNumberField model={model} setFilterValues={setFilterValues} filterValues={filterValues} field={field} onChangeFilterValue={onChangeFilterValue} />
1398
- </div>}
1399
- </>)}
1400
- </div>
1401
- </th>
1402
- }
1403
-
1404
- export const PhoneField = ({name, value, onChange}) => {
1405
- const [phone, setPhone] = useState(value);
1406
- useEffect(() => {
1407
- setPhone(value);
1408
- }, [value]);
1409
- return (
1410
- <div>
1411
- <PhoneInput
1412
- defaultCountry="ua"
1413
- value={phone || ''}
1414
- onChange={(phone) => {
1415
- setPhone(phone);
1416
- onChange?.(phone);
1417
- }}
1418
- />
1419
- </div>
1420
- );
1421
- }
1422
-
1423
- export const ModelField = ({field, disableable=false, showModel=true, value, fieldValue, fields=false, onChange}) => {
1424
- const {models} = useModelContext();
1425
- const {me} = useAuthContext();
1426
- const {t} = useTranslation()
1427
- const [checked, setChecked] = useState(true);
1428
-
1429
- // Trouver le modèle correspondant à la valeur
1430
- const selectedModel = models.find(m => m.name === value && m._user === me?.username);
1431
-
1432
- // Préparer les options pour les champs du modèle
1433
- const fieldOptions = selectedModel?.fields.map(f => ({
1434
- label: t(`field_${f.name}`, f.name),
1435
- value: f.name
1436
- })) || [];
1437
-
1438
- // Gestion du changement de modèle
1439
- const handleModelChange = (e) => {
1440
- const newModel = e.value;
1441
- const firstField = fieldOptions[0]?.value || null;
1442
-
1443
- if (fields) {
1444
- onChange({name: field?.name, value: { model: newModel, field: firstField }});
1445
- } else {
1446
- onChange({name: field?.name, value: newModel});
1447
- }
1448
- };
1449
-
1450
- // Gestion du changement de champ
1451
- const handleFieldChange = (e) => {
1452
- onChange({name: field?.name, value: { model: value, field: e.value }});
1453
- };
1454
-
1455
- const dis = disableable ? (
1456
- <CheckboxField
1457
- checked={checked}
1458
- onChange={e => {
1459
- setChecked(e);
1460
- if (!e) {
1461
- onChange({name: field?.name, value: null});
1462
- }
1463
- }}
1464
- />
1465
- ) : null;
1466
-
1467
- if (!fields) {
1468
- return (
1469
- <div className="flex flex-1">
1470
- {dis}
1471
- {checked && (
1472
- <SelectField
1473
- className="flex-1"
1474
- value={value}
1475
- onChange={handleModelChange}
1476
- items={models
1477
- .filter(m => m._user === me?.username)
1478
- .map(m => ({
1479
- label: t(`model_${m.name}`, m.name),
1480
- value: m.name
1481
- }))
1482
- }
1483
- />
1484
- )}
1485
- </div>
1486
- );
1487
- }
1488
-
1489
- return (
1490
- <div className="flex flex-1">
1491
- {dis}
1492
- {checked && (
1493
- <div className="flex flex-stretch" key={field?.name ?? 'def'}>
1494
- {showModel && (
1495
- <SelectField
1496
- className="flex-1"
1497
- value={value}
1498
- onChange={handleModelChange}
1499
- items={models
1500
- .filter(m => m._user === me?.username)
1501
- .map(m => ({
1502
- label: t(`model_${m.name}`, m.name),
1503
- value: m.name
1504
- }))
1505
- }
1506
- />
1507
- )}
1508
- <SelectField
1509
- className="flex-1"
1510
- value={fieldValue || (fieldOptions[0]?.value || null)}
1511
- onChange={handleFieldChange}
1512
- items={fieldOptions}
1513
- />
1514
- </div>
1515
- )}
1516
- </div>
1517
- );
1518
- };
1519
-
1520
- // Fonction pour obtenir le composant icône par son nom
1521
- const getIconComponent = (iconName) => {
1522
- if (!iconName) return null;
1523
- const IconComponent = FaIcons[iconName] || Fa6Icons[iconName];
1524
- return IconComponent ? <IconComponent /> : null; // Retourne l'élément React ou null
1525
- };
1526
- export const IconField = ({name, label, value, disabled, onChange, className, ...rest}) => {
1527
- const { t } = useTranslation();
1528
- const [iconSuggestions, setIconSuggestions] = useState([]);
1529
- // Tri alphabétique pour une recherche plus prévisible
1530
- const [allFaIcons] = useState(() => [...Object.keys(FaIcons), ...Object.keys(Fa6Icons)].sort());
1531
-
1532
- const handleIconChange = (e) => {
1533
- const value = e.target.value;
1534
- onChange(value);
1535
- if (value) {
1536
- const filtered = allFaIcons.filter(
1537
- icon => icon.toLowerCase().includes(value.toLowerCase())
1538
- );
1539
- setIconSuggestions(filtered.slice(0, 20));
1540
- } else {
1541
- setIconSuggestions([]);
1542
- }
1543
- };
1544
-
1545
- const handleIconFocus = () => {
1546
- if (value) {
1547
- const filtered = allFaIcons.filter(
1548
- icon => icon.toLowerCase().includes(value.toLowerCase())
1549
- );
1550
- setIconSuggestions(filtered.slice(0, 20));
1551
- } else {
1552
- setIconSuggestions(allFaIcons.slice(0, 10));
1553
- }
1554
- };
1555
-
1556
- const onSuggestionClick = (suggestion) => {
1557
- onChange(suggestion);
1558
- setIconSuggestions([]);
1559
- };
1560
-
1561
- return <div className="textfield-wrapper with-suggestions">
1562
- <div className={"flex flex-1 flex-no-wrap"}>
1563
- <TextField
1564
- help={t('modelcreator.field.icon')}
1565
- id="modelIcon"
1566
- disabled={disabled}
1567
- value={value}
1568
- label={label}
1569
- before={<div>{getIconComponent(value)}</div>}
1570
- onChange={handleIconChange}
1571
- onFocus={handleIconFocus}
1572
- onBlur={() => setTimeout(() => setIconSuggestions([]), 200)}
1573
- autoComplete="off"
1574
- />
1575
- </div>
1576
- {iconSuggestions.length > 0 && (
1577
- <ul className="suggestions-list">
1578
- {iconSuggestions.map(icon => (
1579
- <li key={icon} onMouseDown={() => onSuggestionClick(icon)}>
1580
- <span className="suggestion-icon">{getIconComponent(icon)}</span>
1581
- <span>{icon}</span>
1582
- </li>
1583
- ))}
1584
- </ul>
1585
- )}
1586
- </div>
1587
- };
1588
- export const ColorField = ({name, label, value, disabled, onChange, className, ...rest}) => {
1589
- // 1. État interne pour une réactivité immédiate de l'interface.
1590
- const [internalValue, setInternalValue] = useState(value);
1591
-
1592
- // 2. On mémoïze le gestionnaire d'événements avec debounce pour éviter de le recréer à chaque rendu.
1593
- const debouncedOnChange = useCallback(
1594
- debounce((newValue) => {
1595
- // On notifie le parent du changement après un court délai.
1596
- onChange?.({ name, value: newValue });
1597
- }, 200), // Un délai de 200ms est confortable pour un sélecteur de couleur.
1598
- [onChange, name] // Dépendances de useCallback
1599
- );
1600
-
1601
- // 3. Effet pour synchroniser l'état interne si la prop `value` du parent change.
1602
- useEffect(() => {
1603
- if (value !== internalValue) {
1604
- setInternalValue(value);
1605
- }
1606
- }, [value]);
1607
-
1608
- const handleChange = (e) => {
1609
- const newValue = e.target.value;
1610
- // Met à jour l'état interne instantanément pour que l'input soit réactif.
1611
- setInternalValue(newValue);
1612
- // Appelle la fonction "debounced" pour notifier le parent.
1613
- debouncedOnChange(newValue);
1614
- };
1615
-
1616
- return (
1617
- <div className={`flex flex-1 flex-no-wrap ${className || ''}`}>
1618
- {label && (<label className="flex-1">{label}</label>)}
1619
- <div className="flex flex-1 flex-no-wrap"><input
1620
- disabled={disabled}
1621
- type="color"
1622
- // L'input est maintenant contrôlé par notre état interne.
1623
- value={internalValue || '#FFFFFF'}
1624
- onChange={handleChange}
1625
- {...rest}
1626
- />
1627
- <span className="color-value">{internalValue || '#FFFFFF'}</span>
1628
- </div>
1629
- </div>
1630
- );
1631
- };
1632
-
1633
- const secondsToDuration = (totalSeconds) => {
1634
- if (totalSeconds === null || totalSeconds === undefined || isNaN(totalSeconds) || totalSeconds === '') {
1635
- return { days: '', hours: '', minutes: '', seconds: '' };
1636
- }
1637
- const total = parseInt(totalSeconds, 10);
1638
- const d = Math.floor(total / 86400);
1639
- let remainder = total % 86400;
1640
- const h = Math.floor(remainder / 3600);
1641
- remainder %= 3600;
1642
- const m = Math.floor(remainder / 60);
1643
- const s = remainder % 60;
1644
- return { days: d, hours: h, minutes: m, seconds: s };
1645
- };
1646
-
1647
- const durationToSeconds = ({ days, hours, minutes, seconds }) => {
1648
- return (parseInt(days, 10) || 0) * 86400 +
1649
- (parseInt(hours, 10) || 0) * 3600 +
1650
- (parseInt(minutes, 10) || 0) * 60 +
1651
- (parseInt(seconds, 10) || 0);
1652
- };
1653
-
1654
- export const DurationField = forwardRef(({ value, onChange, name, label, help, required, editable, readOnly }, ref) => {
1655
- const { t } = useTranslation();
1656
- const [duration, setDuration] = useState(secondsToDuration(value));
1657
- const [errors, setErrors] = useState([]);
1658
-
1659
- useEffect(() => {
1660
- setDuration(secondsToDuration(value));
1661
- }, [value]);
1662
-
1663
- const validate = () => {
1664
- const errs = [];
1665
- const totalSeconds = durationToSeconds(duration);
1666
- if (required && totalSeconds <= 0) {
1667
- errs.push(t('form.validation.required', "Field required"));
1668
- }
1669
- setErrors(errs);
1670
- return !errs.length;
1671
- };
1672
-
1673
- useImperativeHandle(ref, () => ({
1674
- validate,
1675
- getValue: () => durationToSeconds(duration),
1676
- }));
1677
-
1678
- const handlePartChange = (part) => (e) => {
1679
- const newDuration = { ...duration, [part]: e.target.value };
1680
- setDuration(newDuration);
1681
- if (onChange) {
1682
- const totalSeconds = durationToSeconds(newDuration);
1683
- onChange({ name, value: totalSeconds });
1684
- }
1685
- };
1686
-
1687
- return (
1688
- <>
1689
- <div className={cn({ field: true, "field-duration": true, 'flex-1': true, flex: true, "field-bg": true })}>
1690
- {label && (
1691
- <label contentEditable={editable} className={cn({ help: !!help, 'flex-1': true })}>
1692
- {label}
1693
- {required && <span className="mandatory" contentEditable={false}>*</span>}
1694
- </label>
1695
- )}
1696
- {help && <div className="flex help">{help}</div>}
1697
- <div className="duration-inputs flex flex-no-wrap flex-mini-gap">
1698
- <NumberField name={`${name}-days`} unit={t('duration.unit.days', 'days')} value={duration.days} onChange={handlePartChange('days')} readOnly={readOnly} min={0} />
1699
- <NumberField name={`${name}-hours`} unit={t('duration.unit.hours', 'hours')} value={duration.hours} onChange={handlePartChange('hours')} readOnly={readOnly} min={0} max={23} />
1700
- <NumberField name={`${name}-minutes`} unit={t('duration.unit.minutes', 'minutes')} value={duration.minutes} onChange={handlePartChange('minutes')} readOnly={readOnly} min={0} max={59} />
1701
- <NumberField name={`${name}-seconds`} unit={t('duration.unit.seconds', 'seconds')} value={duration.seconds} onChange={handlePartChange('seconds')} readOnly={readOnly} min={0} max={59} />
1702
- </div>
1703
- </div>
1704
- {errors.length > 0 && (
1705
- <ul className="error">
1706
- {errors.map((e, key) => (
1707
- <li key={key} aria-live="assertive" role="alert">{e}</li>
1708
- ))}
1709
- </ul>
1710
- )}
1711
- </>
1712
- );
1713
- });
1714
- DurationField.displayName = "DurationField";
1715
-
1716
- export const CodeField = ({name, label, language, value, disabled, onChange}) => {
1717
- const u = name || uniqid();
1718
- const [currentEditor, setEditor] = useState(null);
1719
-
1720
- return <>
1721
- {label && (<label className="flex flex-1">{label}</label>)}
1722
- {!disabled ? <div className={"codefield"}><span><b>{language}</b> : </span><CodeiumEditor
1723
- language={language || 'json'}
1724
- theme={"vs-dark"}
1725
- value={value}
1726
- onChange={e => {
1727
- if (language === 'json') {
1728
- let code;
1729
- try {
1730
- code = JSON.parse(e);
1731
- onChange({name, value: code});
1732
- } catch (e) {
1733
- }
1734
- } else
1735
- onChange({name, value: e});
1736
- }}
1737
- height="300px"
1738
- /></div> : <div className="code"><SyntaxHighlighter
1739
- language={language || "javascript"} theme={docco}>{value}</SyntaxHighlighter></div>
1740
- }</>
1741
- }
1742
-
1743
- export const EnumField = ({inputProps, value, handleChange, field}) => {
1744
- const { t} = useTranslation()
1745
- useEffect(() => {
1746
- if( field.items.includes(value))
1747
- handleChange(value);
1748
- else{
1749
- handleChange({name: field.name, value: field.items[0]})
1750
- }
1751
- }, []);
1752
- return (
1753
- <select {...inputProps} onChange={(e) => handleChange({name: field.name, value: e.target.value})} >{(field.items || []).map(item => {
1754
- if( typeof(item) === 'string'){
1755
- return <option value={item}>{t(item, item)}</option>;
1756
- }
1757
- return <></>
1758
- })}</select>
1759
- );
1760
- }
1761
-
1762
- export const RangeField = ({ name, value, onChange, min = 0, max = 100, step = 1, percent = false }) => {
1763
- const handleChange = (e) => {
1764
- // The onChange from the form probably expects the field name and value
1765
- onChange(parseFloat(e.target.value));
1766
- };
1767
-
1768
- const percentage = max > min ? Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)) : 0;
1769
- const displayValue = percent ? `${Math.round(percentage)}%` : value;
1770
-
1771
- return (
1772
- <div className="range-field">
1773
- <input
1774
- type="range"
1775
- name={name}
1776
- value={value || 0}
1777
- onChange={handleChange}
1778
- min={min}
1779
- max={max}
1780
- step={step}
1781
- />
1782
- <span className="range-value">{displayValue}</span>
1783
- </div>
1784
- );
1
+ import React, {
2
+ forwardRef, useCallback,
3
+ useEffect,
4
+ useImperativeHandle,
5
+ useRef,
6
+ useState,
7
+ } from "react";
8
+ import uniqid from "uniqid";
9
+ import cn from "classnames";
10
+ import { recursiveMap, useRefs } from "./Utils.jsx";
11
+ import {useTranslation} from "react-i18next";
12
+ import { CodeiumEditor } from "@codeium/react-code-editor";
13
+
14
+ import {debounce, escapeRegExp, isGUID, isLightColor} from "../../src/core.js";
15
+ import {mainFieldsTypes, maxFileSize} from "../../src/constants.js";
16
+ import {useModelContext} from "./contexts/ModelContext.jsx";
17
+ import { SketchPicker } from 'react-color'; // Importer le sélecteur
18
+ import {useQueryClient} from "react-query";
19
+ import tinycolor from 'tinycolor2';
20
+ import {
21
+ FaArrowDown,
22
+ FaArrowUp, FaAt, FaEye, FaEyeSlash,
23
+ FaCalendar, FaCalendarWeek, FaCode, FaFile,
24
+ FaHashtag, FaIcons,
25
+ FaImage,
26
+ FaLink, FaListOl, FaListUl,
27
+ FaLock, FaMinus,
28
+ FaPallet, FaPhone, FaSitemap,
29
+ FaToggleOn
30
+ } from "react-icons/fa";
31
+ import * as Fa6Icons from 'react-icons/fa6'; // Importer Fa6
32
+ import {FaCalendarDays, FaCodeCompare, FaPencil, FaT, FaTableColumns} from "react-icons/fa6";
33
+ import { CodeBlock, tomorrowNightBright } from 'react-code-blocks';
34
+ import SyntaxHighlighter from 'react-syntax-highlighter';
35
+ import { docco } from 'react-syntax-highlighter/dist/esm/styles/hljs';
36
+
37
+ import { PhoneInput } from 'react-international-phone';
38
+ import 'react-international-phone/style.css';
39
+ import {useAuthContext} from "./contexts/AuthContext.jsx";
40
+ import Switch from "react-switch";
41
+ export const Form = ({
42
+ name,
43
+ onValidate,
44
+ onError,
45
+ children,
46
+ editable,
47
+ className = "",
48
+ }) => {
49
+ const [childrenRef, registerRef] = useRefs();
50
+ const onSubmit = (e) => {
51
+ e.preventDefault();
52
+ let res = true;
53
+ Object.keys(childrenRef.current).forEach((item) => {
54
+ res = childrenRef.current[item].validate() && res;
55
+ });
56
+ if (res) {
57
+ if (onValidate) onValidate(e);
58
+ } else {
59
+ if (onError) onError();
60
+ }
61
+ };
62
+
63
+ /**/
64
+ return (
65
+ <form
66
+ name={name}
67
+ noValidate={true}
68
+ contentEditable={editable}
69
+ className={cn({ ["form-" + name]: true }) + " " + (className || "")}
70
+ onSubmit={onSubmit}
71
+ >
72
+ {recursiveMap(children, (child, index) => {
73
+ /*if( child?.type?.displayName?.match(/(Field|RadioGroup)/)){
74
+ return <child.type {...child.props} ref={registerRef(child?.type?.displayName.concat('-').concat(child.props.name || uniqid()))} />
75
+ }*/
76
+ return child;
77
+ })}
78
+ </form>
79
+ );
80
+ };
81
+
82
+ const TextField = forwardRef(function TextField(
83
+ {
84
+ name,
85
+ label,
86
+ placeholder,
87
+ help,
88
+ editable,
89
+ value,
90
+ required,
91
+ readOnly,
92
+ onChange,
93
+ multiline,
94
+ minlength,
95
+ maxlength,
96
+ searchable,
97
+ labelProps,
98
+ showErrors=false,
99
+ before,
100
+ after,
101
+ ...rest
102
+ },
103
+ ref,
104
+ ) {
105
+ const [id, setId] = useState("textfield-" + uniqid());
106
+ const [isPasswordVisible, setIsPasswordVisible] = useState(false);
107
+ const [errors, setErrors] = useState([]);
108
+ const inputRef = useRef();
109
+
110
+ const { type, ...otherRest } = rest;
111
+ const isPasswordField = type === 'password';
112
+
113
+ const mult = typeof multiline !== 'undefined' ? multiline : maxlength > 255;
114
+ const validate = () => {
115
+ const errs = [];
116
+ if (required && (!value || value.trim() === "")) {
117
+ errs.push("Field required");
118
+ }
119
+ if (
120
+ minlength > 0 &&
121
+ typeof value == "string" &&
122
+ value.trim().length < minlength
123
+ ) {
124
+ errs.push("Value length must be >= to " + minlength);
125
+ }
126
+ if (
127
+ maxlength !== undefined && maxlength > 0 &&
128
+ typeof value == "string" &&
129
+ value.trim().length > maxlength
130
+ ) {
131
+ errs.push("Value length must be <= to " + maxlength);
132
+ }
133
+ if( showErrors )
134
+ setErrors(errs);
135
+ return !errs.length;
136
+ };
137
+ useImperativeHandle(ref, () => ({
138
+ ref: inputRef.current,
139
+ validate,
140
+ getValue: () => value,
141
+ }));
142
+ const handleChange = (e) => {
143
+ if (onChange) {
144
+ onChange(e);
145
+ }
146
+ };
147
+
148
+ const togglePasswordVisibility = () => {
149
+ setIsPasswordVisible(prevState => !prevState);
150
+ };
151
+ useEffect(() => {
152
+ if (value !== null) validate();
153
+ }, [value]);
154
+ return (
155
+ <>
156
+ <div
157
+ className={cn({
158
+ field: true,
159
+ flex: true,
160
+ "field-text": !mult,
161
+ "field-multiline": mult,
162
+ })}
163
+ >
164
+ {label && (
165
+ <label
166
+ contentEditable={editable}
167
+ className={cn({help: !!help, 'flex-1': true})}
168
+ htmlFor={id}
169
+ {...labelProps}
170
+ >
171
+ {label}
172
+ {required ? (
173
+ <span className="mandatory" contentEditable={false}>
174
+ *
175
+ </span>
176
+ ) : (
177
+ ""
178
+ )}
179
+ </label>
180
+ )}
181
+
182
+ {help &&<div className="flex help">{help}</div>}
183
+
184
+ {mult && (
185
+ <textarea
186
+ ref={inputRef}
187
+ aria-required={required}
188
+ aria-readonly={readOnly}
189
+ readOnly={readOnly}
190
+ placeholder={placeholder}
191
+ id={id}
192
+ name={name}
193
+ value={value || ""}
194
+ rows={8}
195
+ onChange={handleChange}
196
+ minLength={minlength}
197
+ maxLength={maxlength}
198
+ {...rest}
199
+ ></textarea>
200
+ )}
201
+
202
+ {before}
203
+ <div className={"flex flex-1 flex-no-gap flex-start"} style={{ position: 'relative' }}>
204
+ {!mult && (
205
+ <input
206
+ ref={inputRef}
207
+ aria-required={required}
208
+ aria-readonly={readOnly}
209
+ readOnly={readOnly}
210
+ type={isPasswordField ? (isPasswordVisible ? 'text' : 'password') : (searchable ? "search" : (type || "text"))}
211
+ placeholder={placeholder}
212
+ title={placeholder}
213
+ alt={placeholder}
214
+ id={id}
215
+ name={name}
216
+ value={value || ""}
217
+ onChange={handleChange}
218
+ minLength={minlength}
219
+ maxLength={maxlength}
220
+ required={required}
221
+ style={isPasswordField ? { paddingRight: '40px' } : {}}
222
+ {...otherRest}
223
+ />
224
+ )}
225
+ {isPasswordField && !mult && (
226
+ <button type="button" onClick={togglePasswordVisibility} className="password-toggle-icon" style={{position: 'absolute', right: '10px', top: '50%', transform: 'translateY(-50%)', display: 'flex', alignItems: 'center'}}>
227
+ {isPasswordVisible ? <FaEyeSlash /> : <FaEye />}
228
+ </button>
229
+ )}
230
+ {after}
231
+ </div>
232
+ </div>
233
+ {errors.length > 0 && (
234
+ <ul className="error">
235
+ {errors.map((e, key) => (
236
+ <li key={key} aria-live="assertive" role="alert">
237
+ {e}
238
+ </li>
239
+ ))}
240
+ </ul>
241
+ )}
242
+ </>
243
+ );
244
+ });
245
+
246
+ TextField.displayName = "TextField";
247
+ export { TextField };
248
+
249
+ const EmailField = forwardRef(
250
+ (
251
+ {
252
+ name,
253
+ label,
254
+ placeholder,
255
+ help,
256
+ editable,
257
+ defaultValue,
258
+ required,
259
+ readOnly,
260
+ onChange,
261
+ minlength,
262
+ maxlength,
263
+ fieldValidated,
264
+ },
265
+ ref,
266
+ ) => {
267
+ const id = "emailfield-" + uniqid();
268
+ const [errors, setErrors] = useState([]);
269
+ const [value, setValue] = useState(defaultValue || null);
270
+ const validate = () => {
271
+ const errs = [];
272
+ if (required && (!value || value.trim() === "")) {
273
+ errs.push("Field required");
274
+ }
275
+ if (minlength !== undefined && minlength > 0 && value && value.trim().length < minlength) {
276
+ errs.push("Value length must be >= to " + minlength);
277
+ }
278
+ if (maxlength !== undefined && maxlength > 0 && value && value.trim().length > maxlength) {
279
+ errs.push("Value length must be <= to " + maxlength);
280
+ }
281
+
282
+ if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
283
+ errs.push("Invalid email");
284
+ }
285
+ setErrors(errs);
286
+ return !errs.length;
287
+ };
288
+ useEffect(() => {
289
+ if (value !== null) validate();
290
+ }, [value]);
291
+ useEffect(() => {
292
+ if (fieldValidated) validate();
293
+ }, [fieldValidated]);
294
+ useImperativeHandle(ref, () => ({
295
+ validate,
296
+ getValue: () => value,
297
+ }));
298
+ const handleChange = (e) => {
299
+ setValue(e.target.value);
300
+ if (onChange) {
301
+ onChange(e);
302
+ }
303
+ };
304
+ return (
305
+ <>
306
+ <div className={cn({ field: true, "field-email": true })}>
307
+ <label
308
+ contentEditable={editable}
309
+ className={cn({ help: !!help })}
310
+ title={help}
311
+ htmlFor={id}
312
+ >
313
+ {label}
314
+ {required ? (
315
+ <span className="mandatory" contentEditable={false}>
316
+ *
317
+ </span>
318
+ ) : (
319
+ ""
320
+ )}
321
+ </label>
322
+ <input
323
+ aria-required={required}
324
+ aria-readonly={readOnly}
325
+ readOnly={readOnly}
326
+ type="email"
327
+ placeholder={placeholder}
328
+ id={id}
329
+ name={name}
330
+ value={value || ""}
331
+ onChange={handleChange}
332
+ minLength={minlength}
333
+ maxLength={maxlength}
334
+ />
335
+ </div>
336
+ {errors.length > 0 && (
337
+ <ul className="error">
338
+ {errors.map((e, key) => (
339
+ <li key={key} aria-live="assertive" role="alert">
340
+ {e}
341
+ </li>
342
+ ))}
343
+ </ul>
344
+ )}
345
+ </>
346
+ );
347
+ },
348
+ );
349
+ EmailField.displayName = "EmailField";
350
+ export { EmailField };
351
+
352
+ const NumberField = forwardRef(
353
+ (
354
+ {
355
+ name,
356
+ label,
357
+ placeholder,
358
+ help,
359
+ editable,
360
+ value,
361
+ required,
362
+ readOnly,
363
+ onChange,
364
+ minlength,
365
+ maxlength,
366
+ min,
367
+ max,
368
+ step,
369
+ unit,
370
+ ...rest
371
+ },
372
+ ref,
373
+ ) => {
374
+ const id = "numberfield-" + uniqid();
375
+ const [errors, setErrors] = useState([]);
376
+ const inputRef = useRef();
377
+ const validate = () => {
378
+ const errs = [];
379
+ if (required && value === undefined) {
380
+ errs.push("Field required");
381
+ }
382
+ if (minlength !== undefined && minlength > 0 && value && value.trim().length < minlength) {
383
+ errs.push("Value length must be >= to " + minlength);
384
+ }
385
+ if (maxlength !== undefined && maxlength > 0 && value && value.trim().length > maxlength) {
386
+ errs.push("Value length must be <= to " + maxlength);
387
+ }
388
+ if ((min || min === 0) && (value || value === 0) && min > value) {
389
+ errs.push("Value < to " + min);
390
+ }
391
+ if ((max || max === 0) && (value || value === 0) && max < value) {
392
+ errs.push("Value > to " + max);
393
+ }
394
+ setErrors(errs);
395
+ return !errs.length;
396
+ };
397
+ useEffect(() => {
398
+ if (value !== null) validate();
399
+ }, [value]);
400
+ useImperativeHandle(ref, () => ({
401
+ ref: inputRef.current,
402
+ validate,
403
+ getValue: () => value,
404
+ }));
405
+ const handleChange = (e) => {
406
+ if (onChange) {
407
+ onChange(e);
408
+ }
409
+ };
410
+ return (
411
+ <>
412
+ <div className={cn({ field: true, "field-number": true })}>
413
+
414
+ <div className="flex flex-1">
415
+ {label && (
416
+ <label
417
+ contentEditable={editable}
418
+ className={cn({ help: !!help, flex: true, 'flex-1': true })}
419
+ title={help}
420
+ htmlFor={id}
421
+ >
422
+ {label}
423
+ {required ? (
424
+ <span className="mandatory" contentEditable={false}>
425
+ *
426
+ </span>
427
+ ) : (
428
+ ""
429
+ )}
430
+ </label>
431
+ )}
432
+ {help && <div className="flex help">{help}</div>}
433
+ <div className={"flex flex-1 flex-no-wrap flex-mini-gap flex-end"}>
434
+ <input
435
+ ref={inputRef}
436
+ aria-required={required}
437
+ aria-readonly={readOnly}
438
+ readOnly={readOnly}
439
+ type="number"
440
+ placeholder={placeholder}
441
+ id={id}
442
+ name={name}
443
+ value={value || ""}
444
+ onChange={handleChange}
445
+ minLength={minlength}
446
+ maxLength={maxlength}
447
+ min={min}
448
+ max={max}
449
+ step={step}
450
+ {...rest}
451
+ />
452
+ {unit && <span className="unit">{unit}</span>}
453
+ </div>
454
+ </div>
455
+ </div>
456
+ {errors.length > 0 && (
457
+ <ul className="error">
458
+ {errors.map((e, key) => (
459
+ <li key={key} aria-live="assertive" role="alert">
460
+ {e}
461
+ </li>
462
+ ))}
463
+ </ul>
464
+ )}
465
+ </>
466
+ );
467
+ },
468
+ );
469
+ NumberField.displayName = "NumberField";
470
+ export { NumberField };
471
+
472
+ const CheckboxField = forwardRef(
473
+ (
474
+ {
475
+ name,
476
+ label,
477
+ placeholder,
478
+ help,
479
+ editable,
480
+ defaultValue,
481
+ required,
482
+ readOnly,
483
+ onChange,
484
+ minlength,
485
+ maxlength,
486
+ checked,
487
+ checkbox=false,
488
+ ...rest
489
+ },
490
+ ref,
491
+ ) => {
492
+ const id = "checkfield-" + uniqid();
493
+ const [errors, setErrors] = useState([]);
494
+ const [value, setValue] = useState(checked || false);
495
+ useEffect(() => {
496
+ setValue(checked);
497
+ }, [checked]);
498
+ const validate = () => {
499
+ const errs = [];
500
+ if (required && !value) {
501
+ errs.push("Field must be checked.");
502
+ }
503
+ setErrors(errs);
504
+ return !errs.length;
505
+ };
506
+ useEffect(() => {
507
+ if (value !== null) validate();
508
+ }, [value]);
509
+ useImperativeHandle(ref, () => ({
510
+ validate,
511
+ getValue: () => value,
512
+ }));
513
+ const handleChange = (e) => {
514
+ setValue(!value);
515
+ onChange?.(e);
516
+ };
517
+ return (
518
+ <>
519
+ <div className={cn({field: true, "field-checkbox": true,"field-bg": true})}>
520
+ {label && (
521
+ <label
522
+ contentEditable={editable}
523
+ title={help}
524
+ htmlFor={id}
525
+ >
526
+ {label}
527
+ {required ? (
528
+ <span className="mandatory" contentEditable={false}>
529
+ *
530
+ </span>
531
+ ) : (
532
+ ""
533
+ )}
534
+ </label>
535
+ )}
536
+ {help && <div className="flex help">{help}</div>}
537
+ {!checkbox && (<Switch
538
+ id={id}
539
+ onChange={handleChange}
540
+ checked={value} />)}
541
+ {checkbox && (
542
+ <input type={"checkbox"} id={id} onChange={handleChange} checked={value} />
543
+ )}
544
+ </div>
545
+ {errors.length > 0 && (
546
+ <ul className="error">
547
+ {errors.map((e, key) => (
548
+ <li key={key} aria-live="assertive" role="alert">
549
+ {JSON.stringify(e, null, 2)}
550
+ </li>
551
+ ))}
552
+ </ul>
553
+ )}
554
+ </>
555
+ );
556
+ },
557
+ );
558
+ CheckboxField.displayName = "CheckboxField";
559
+ export { CheckboxField };
560
+
561
+ const RadioField = forwardRef(
562
+ (
563
+ {
564
+ name,
565
+ label,
566
+ placeholder,
567
+ help,
568
+ editable,
569
+ checked,
570
+ required,
571
+ readOnly,
572
+ onChange,
573
+ minlength,
574
+ maxlength,
575
+ },
576
+ ref,
577
+ ) => {
578
+ const id = "radiofield-" + uniqid();
579
+ const [errors, setErrors] = useState([]);
580
+ const [value, setValue] = useState(checked || null);
581
+ const validate = () => {
582
+ const errs = [];
583
+ if (required && !value) {
584
+ errs.push("Field must be checked.");
585
+ }
586
+ setErrors(errs);
587
+ return !errs.length;
588
+ };
589
+ useEffect(() => {
590
+ if (value !== null) validate();
591
+ }, [value]);
592
+ useImperativeHandle(ref, () => ({
593
+ validate,
594
+ getValue: () => value,
595
+ }));
596
+ const handleChange = (e) => {
597
+ setValue(!value);
598
+ if (onChange) {
599
+ onChange(e);
600
+ }
601
+ };
602
+ return (
603
+ <>
604
+ <div className={cn({ field: true, "field-radio": true })}>
605
+ <input
606
+ aria-required={required}
607
+ aria-readonly={readOnly}
608
+ readOnly={readOnly}
609
+ type="radio"
610
+ checked={value}
611
+ value={value || label}
612
+ placeholder={placeholder}
613
+ id={id}
614
+ name={name}
615
+ onChange={handleChange}
616
+ minLength={minlength}
617
+ maxLength={maxlength}
618
+ />
619
+ <label
620
+ contentEditable={editable}
621
+ className={cn({ help: !!help })}
622
+ title={help}
623
+ htmlFor={id}
624
+ >
625
+ {label}
626
+ {required ? (
627
+ <span className="mandatory" contentEditable={false}>
628
+ *
629
+ </span>
630
+ ) : (
631
+ ""
632
+ )}
633
+ </label>
634
+ </div>
635
+ {errors.length > 0 && (
636
+ <ul className="error">
637
+ {errors.map((e, key) => (
638
+ <li key={key} aria-live="assertive" role="alert">
639
+ {e}
640
+ </li>
641
+ ))}
642
+ </ul>
643
+ )}
644
+ </>
645
+ );
646
+ },
647
+ );
648
+ RadioField.displayName = "RadioField";
649
+ export { RadioField };
650
+
651
+ const SelectField = forwardRef(
652
+ (
653
+ {
654
+ name,
655
+ value,
656
+ items,
657
+ label,
658
+ placeholder,
659
+ disabled,
660
+ help,
661
+ editable,
662
+ checked,
663
+ required,
664
+ readOnly,
665
+ onChange,
666
+ minlength,
667
+ maxlength,
668
+ multiple,
669
+ ...rest
670
+ },
671
+ ref,
672
+ ) => {
673
+ const [values, setValues] = useState([]);
674
+ const id = "selectfield-" + uniqid();
675
+ const [errors, setErrors] = useState([]);
676
+ const [_value, setValue] = useState(value);
677
+ useEffect(() => {
678
+ if( value === undefined && required && items[0]){
679
+ setValue(items[0].value);
680
+ }else {
681
+ setValue(value);
682
+ setValues(value)
683
+ }
684
+ if (!multiple && value) {
685
+ //const index = items.findIndex((i) => i.value === value);
686
+ //onChange({name, value:items[index]}, index);
687
+ }
688
+ }, [value]);
689
+ const validate = () => {
690
+ const errs = [];
691
+ if (required && _value === undefined) {
692
+ errs.push("Field is required.");
693
+ }
694
+ setErrors(errs);
695
+ return !errs.length;
696
+ };
697
+ useEffect(() => {
698
+ if (_value !== null) validate();
699
+ }, [_value]);
700
+ useImperativeHandle(ref, () => ({
701
+ validate,
702
+ getValue: () => _value,
703
+ setValue,
704
+ }));
705
+ const handleChange = (e) => {
706
+ setValue(e.target.value);
707
+ if (onChange) {
708
+ let options = e.target.options;
709
+ let value = [];
710
+ for (var i = 0, l = options.length; i < l; i++) {
711
+ if (options[i].selected) {
712
+ value.push(options[i].value);
713
+ }
714
+ }
715
+ if( multiple ) {
716
+ setValues(value);
717
+ onChange(value);
718
+ }else {
719
+ const index = items.findIndex((i) => i.value+'' === e.target.value);
720
+ onChange(items[index], index);
721
+ }
722
+ }
723
+ };
724
+ return (
725
+ <>
726
+ <div className={cn({ field: true, 'flex-1': true, flex: true, "field-select": true })}>
727
+ {label && (
728
+ <label
729
+ contentEditable={editable}
730
+ className={cn({ help: !!help, 'flex-1': true })}
731
+ title={help}
732
+ htmlFor={id}
733
+ >
734
+ {label}
735
+ {required ? (
736
+ <span className="mandatory" contentEditable={false}>
737
+ *
738
+ </span>
739
+ ) : (
740
+ ""
741
+ )}
742
+ </label>
743
+ )}
744
+ <select
745
+ aria-required={required}
746
+ aria-readonly={readOnly}
747
+ value={(_value)}
748
+ id={id}
749
+ name={name}
750
+ onChange={handleChange}
751
+ multiple={multiple}
752
+ disabled={disabled}
753
+ className={"flex-1"}
754
+ {...rest}
755
+ >
756
+ {items.map((i) => (
757
+ <option value={i.value}>{i.label}</option>
758
+ ))}
759
+ </select>
760
+ </div>
761
+ {help && <div className="flex help">{help}</div>}
762
+ {errors.length > 0 && (
763
+ <ul className="error">
764
+ {errors.map((e, key) => (
765
+ <li key={key} aria-live="assertive" role="alert">
766
+ {e}
767
+ </li>
768
+ ))}
769
+ </ul>
770
+ )}
771
+ </>
772
+ );
773
+ },
774
+ );
775
+ SelectField.displayName = "SelectField";
776
+ export { SelectField };
777
+
778
+ const RadioGroup = forwardRef(
779
+ ({ id, label, help, editable, name, required, children }, ref) => {
780
+ const [childrenRef, registerRef] = useRefs();
781
+ const [errors, setErrors] = useState([]);
782
+ const validate = () => {
783
+ const errs = [];
784
+ let res = false;
785
+ Object.keys(childrenRef.current).forEach((item) => {
786
+ res = !!childrenRef.current[item].getValue() || res;
787
+ });
788
+ if (!res && required) {
789
+ errs.push("Field is required");
790
+ }
791
+ setErrors(errs);
792
+ return !errs.length;
793
+ };
794
+ useImperativeHandle(ref, () => ({
795
+ validate,
796
+ }));
797
+ const handleChange = () => {
798
+ setTimeout(() => validate(), 0);
799
+ };
800
+ return (
801
+ <>
802
+ <label
803
+ contentEditable={editable}
804
+ className={cn({ help: !!help })}
805
+ title={help}
806
+ htmlFor={children[0].props.id}
807
+ >
808
+ {label}
809
+ {required ? (
810
+ <span className="mandatory" contentEditable={false}>
811
+ *
812
+ </span>
813
+ ) : (
814
+ ""
815
+ )}
816
+ </label>
817
+ {[
818
+ recursiveMap(children, (child, index) => {
819
+ if (child.type.displayName === "RadioField") {
820
+ const props = {
821
+ ...child.props,
822
+ name: name ? name : child.props.name,
823
+ onChange: () => handleChange(child.props.onChange),
824
+ };
825
+ return (
826
+ <child.type
827
+ {...props}
828
+ ref={registerRef("Radio" + index)}
829
+ name={child.props.name || "btn" + id}
830
+ />
831
+ );
832
+ }
833
+ return child;
834
+ }),
835
+ errors.length > 0 ? (
836
+ <ul className="error">
837
+ {errors.map((e, key) => (
838
+ <li key={key} aria-live="assertive" role="alert">
839
+ {e}
840
+ </li>
841
+ ))}
842
+ </ul>
843
+ ) : (
844
+ <></>
845
+ ),
846
+ ]}
847
+ </>
848
+ );
849
+ },
850
+ );
851
+
852
+ RadioGroup.displayName = "RadioGroup";
853
+ export { RadioGroup };
854
+
855
+ // New FileField component
856
+ const FileField = ({ inputProps, value, onChange, name, mimeTypes, maxSize, multiple}) => {
857
+ const [fileInfos, setFileInfos] = useState(value);
858
+ const { t } = useTranslation();
859
+
860
+ const handleFileChange = (e) => {
861
+ const selectedFiles = Array.from(e.target.files);
862
+ const newFileInfos = [];
863
+
864
+ const promises = selectedFiles.map(selectedFile => {
865
+ if (selectedFile && selectedFile.size > (maxSize || maxFileSize)) {
866
+ alert(`Le fichier est trop volumineux. La taille maximale autorisée est de ${(maxSize || maxFileSize) / (1024 * 1024)} Mo.`);
867
+ e.target.value = '';
868
+ return Promise.resolve();
869
+ }
870
+ return new Promise((resolve) => {
871
+ const reader = new FileReader();
872
+ reader.onloadend = () => {
873
+ newFileInfos.push({
874
+ preview: reader.result,
875
+ newFile: true,
876
+ file: selectedFile,
877
+ name: selectedFile.name
878
+ });
879
+ resolve();
880
+ };
881
+ reader.readAsDataURL(selectedFile);
882
+ });
883
+ });
884
+
885
+ Promise.all(promises).then(() => {
886
+ if(!multiple){
887
+ setFileInfos(newFileInfos);
888
+ }else{
889
+ setFileInfos(fileInfos => [...fileInfos, ...newFileInfos]);
890
+ }
891
+ onChange([...fileInfos.map(m => ({...m, newFile: false})), ...newFileInfos]);
892
+ });
893
+ };
894
+
895
+ const handleRemove = (e, index) => {
896
+ e.preventDefault();
897
+ const newFileInfos = fileInfos.filter((_, i) => i !== index);
898
+ setFileInfos(newFileInfos);
899
+ onChange(newFileInfos.map(m => ({...m, newFile: false})));
900
+ };
901
+
902
+ useEffect(() => {
903
+ if( value == null || (Array.isArray(value) && value.length === 0))
904
+ setFileInfos([])
905
+ else{
906
+ const v = Array.isArray(value) ? value : [value];
907
+ setFileInfos(v)
908
+ }
909
+ }, [value]);
910
+
911
+ return (
912
+ <div className="field field-file">
913
+ <input
914
+ id={"field-file-" + name}
915
+ type="file"
916
+ data-field={name}
917
+ accept={mimeTypes ? mimeTypes.join(',') : '*'}
918
+ onChange={handleFileChange}
919
+ multiple={multiple} // Add multiple attribute
920
+ />
921
+ {fileInfos?.length > 0 && (
922
+ <div>
923
+ {fileInfos.filter(f => isGUID(f.guid) || f.preview).map((fileInfo, index) => (
924
+ <div key={index}>
925
+ {fileInfo.preview ? (
926
+ <a href={fileInfo.preview} target="_blank" rel="noopener noreferrer">
927
+ <img src={fileInfo.preview} alt={"Preview"} width='200' height='200' />
928
+ </a>
929
+ ) :(isGUID(fileInfo.guid) ? (
930
+ <a href={"/resources/"+fileInfo.guid} target="_blank" rel="noopener noreferrer">
931
+ <img src={"/resources/"+fileInfo.guid} alt={"Preview"} width='200' height='200' />
932
+ </a>
933
+ ) :(
934
+ <img src={fileInfo.preview} alt="Preview" style={{ maxWidth: '200px', maxHeight: '200px' }} />
935
+ ))}
936
+ <button onClick={(e) => handleRemove(e, index)}><FaMinus /></button>
937
+ </div>
938
+ ))}
939
+ </div>
940
+ )}
941
+ </div>
942
+ );
943
+ };
944
+
945
+ export { FileField };
946
+
947
+ export const FilterNumberField = ({ model, field, onChangeFilterValue, filterValues, setFilterValues }) => {
948
+ const { t } = useTranslation();
949
+ const { models, setPage, dataByModel } = useModelContext(); // dataByModel is not used, consider removing
950
+ const [min, setMin] = useState(null);
951
+ const [max, setMax] = useState(null);
952
+
953
+ // Debounced version of the function that actually calls onChangeFilterValue
954
+ const debouncedApplyFilter = useCallback(
955
+ debounce((currentMin, currentMax) => {
956
+ const conditions = [];
957
+ setPage(1);
958
+ if (currentMin !== null && !isNaN(currentMin)) {
959
+ conditions.push({ $gte: ['$' + field.name, parseFloat(currentMin)] });
960
+ }
961
+ if (currentMax !== null && !isNaN(currentMax)) {
962
+ conditions.push({ $lte: ['$' + field.name, parseFloat(currentMax)] });
963
+ }
964
+
965
+ if (conditions.length > 0) {
966
+ onChangeFilterValue(field, { $and: conditions });
967
+ } else {
968
+ onChangeFilterValue(field, {}); // Clear filter if both are invalid/null
969
+ }
970
+ }, 300), // Adjust delay as needed
971
+ [field, onChangeFilterValue] // Dependencies for useCallback
972
+ );
973
+
974
+ useEffect(() => {
975
+ // This effect is to reset local min/max if the global filterValues are cleared externally
976
+ // It should not call debouncedApplyFilter directly if filterValues is the source of truth
977
+ // for the parent component.
978
+ if (filterValues && typeof filterValues[field.name] === 'object') {
979
+ const andConditions = filterValues[field.name]?.$and;
980
+ if (andConditions && Array.isArray(andConditions)) {
981
+ const gteCondition = andConditions.find(cond => cond.$gte);
982
+ const lteCondition = andConditions.find(cond => cond.$lte);
983
+ setMin(gteCondition ? gteCondition.$gte[1] : null);
984
+ setMax(lteCondition ? lteCondition.$lte[1] : null);
985
+ } else {
986
+ // If the structure is not $and or it's cleared
987
+ setMin(null);
988
+ setMax(null);
989
+ }
990
+ } else if (!filterValues || filterValues[field.name] === undefined || Object.keys(filterValues[field.name] || {}).length === 0) {
991
+ // If filterValues for this field is cleared or doesn't exist
992
+ setMin(null);
993
+ setMax(null);
994
+ }
995
+ }, [filterValues, field.name]);
996
+
997
+
998
+ const handleMinChange = (e) => {
999
+ const inputValue = e.target.value;
1000
+ if (inputValue === "") {
1001
+ setMin(null);
1002
+ debouncedApplyFilter(null, max);
1003
+ } else {
1004
+ const pi = parseFloat(inputValue); // Use parseFloat for potentially decimal numbers
1005
+ if (!isNaN(pi)) {
1006
+ setMin(pi);
1007
+ debouncedApplyFilter(pi, max);
1008
+ } else {
1009
+ setMin(inputValue); // Keep invalid input in state to show user, but don't filter
1010
+ // Or setMin(null) if you want to clear on invalid
1011
+ // Potentially call debouncedApplyFilter(null, max) if invalid min means no min filter
1012
+ }
1013
+ }
1014
+ gtag('event', 'search (number,min)');
1015
+ };
1016
+
1017
+ const handleMaxChange = (e) => {
1018
+ const inputValue = e.target.value;
1019
+ if (inputValue === "") {
1020
+ setMax(null);
1021
+ debouncedApplyFilter(min, null);
1022
+ } else {
1023
+ const pi = parseFloat(inputValue);
1024
+ if (!isNaN(pi)) {
1025
+ setMax(pi);
1026
+ debouncedApplyFilter(min, pi);
1027
+ } else {
1028
+ setMax(inputValue);
1029
+ // Potentially call debouncedApplyFilter(min, null) if invalid max means no max filter
1030
+ }
1031
+ }
1032
+ gtag('event', 'search (number,max)');
1033
+ };
1034
+
1035
+ return (
1036
+ <>
1037
+ <NumberField
1038
+ value={min === null ? '' : min} // Handle null for empty display
1039
+ label="Min:"
1040
+ onChange={handleMinChange}
1041
+ type="number" // Ensure type is number for appropriate input behavior
1042
+ />
1043
+ <NumberField
1044
+ value={max === null ? '' : max} // Handle null for empty display
1045
+ label="Max:"
1046
+ onChange={handleMaxChange}
1047
+ type="number" // Ensure type is number
1048
+ />
1049
+ </>
1050
+ );
1051
+ };
1052
+
1053
+ export const FilterEnumField = ({model, field, onChangeFilterValue, filterValues, setFilterValues}) => {
1054
+ const {t} = useTranslation();
1055
+ const debounced = debounce((field,filter) => onChangeFilterValue(field, { $find: filter }));
1056
+ const { models, setPage, elementsPerPage,pagedFilters, pagedSort, page } = useModelContext();
1057
+
1058
+ const [val, setVal] = useState(null);
1059
+ const queryClient= useQueryClient()
1060
+
1061
+ useEffect(() => {
1062
+ if (Object.keys(filterValues).length === 0){
1063
+ onChangeFilterValue(field, { });
1064
+ setVal('');
1065
+ }
1066
+ }, [filterValues]);
1067
+
1068
+ return <div className={"flex flex-no-gap flex-no-wrap"}><SelectField value={val} className={"flex-1"} items={['', ...(field.items || [])].map(m => ({label: t(m), value: m}))} onChange={(e) => {
1069
+
1070
+ setPage(1);
1071
+
1072
+ if( !e || e.value === '') {
1073
+ setVal('');
1074
+ onChangeFilterValue(field, undefined);
1075
+ }
1076
+ else {
1077
+ onChangeFilterValue(field, {$eq: ['$' + field.name, e.value]});
1078
+ setVal(e.value);
1079
+ }
1080
+
1081
+ gtag('event', 'search (enum)');
1082
+ queryClient.invalidateQueries(['api/data', model.name, 'page', page, elementsPerPage, elementsPerPage, pagedFilters[model.name], pagedSort[model.name]]);
1083
+ }} /><button onClick={() => {
1084
+ onChangeFilterValue(field, { });
1085
+ setVal('');
1086
+ }}>x</button></div>
1087
+ }
1088
+ export const FilterBooleanField = ({model, field, filterValues, onChangeFilterValue }) => {
1089
+ const {t} = useTranslation();
1090
+ const { setPage, pagedFilters, pagedSort, page,elementsPerPage } = useModelContext();
1091
+
1092
+ useEffect(() => {
1093
+ if( Object.keys(filterValues).length === 0 ){
1094
+ setVal('null');
1095
+ onChangeFilterValue(field, { });
1096
+ }
1097
+ }, [filterValues]);
1098
+ const [val, setVal] = useState(null);
1099
+ const queryClient= useQueryClient()
1100
+ return <div className={"flex flex-no-gap flex-no-wrap"}><SelectField value={val} className={"flex-1"} items={[
1101
+ {label: t(''), value: 'null'},
1102
+ {label: t('yes'), value: '1'},
1103
+ { label: t('no'), value: '0'}]}
1104
+ onChange={(e) => {
1105
+ setPage(1);
1106
+ if( !e || e.value === 'null') {
1107
+ setVal(null);
1108
+ onChangeFilterValue(field, { });
1109
+ }
1110
+ else {
1111
+ onChangeFilterValue(field, {$or: [{$eq: ['$' + field.name, e.value === '1']}, {$eq: [{ $type: '$'+field.name }, "missing"]}]});
1112
+ setVal(e.value);
1113
+ }
1114
+
1115
+ gtag('event', 'search (boolean)');
1116
+ queryClient.invalidateQueries(['api/data', model.name, 'page', page, elementsPerPage, pagedFilters[model.name], pagedSort[model.name]]);
1117
+ }} /></div>
1118
+ }
1119
+ export const FilterDateField = ({model, field, filterValues, onChangeFilterValue }) => {
1120
+ const {t} = useTranslation();
1121
+
1122
+ const [minDate ,setMinDate] = useState(null);
1123
+ const [maxDate ,setMaxDate] = useState(null);
1124
+ useEffect(() => {
1125
+ if( Object.keys(filterValues).length === 0 ){
1126
+ onChangeFilterValue(field, { });
1127
+ setMinDate('');
1128
+ setMaxDate('');
1129
+ }
1130
+ }, [filterValues]);
1131
+ const onChange = (minDate, maxDate) =>{
1132
+ const min = minDate ? { $gte: ['$'+field.name, minDate]} : null;
1133
+
1134
+ const fm = new Date(maxDate);
1135
+ fm.setDate(fm.getDate() + 1);
1136
+
1137
+ const max = maxDate ? {$lte: ['$' + field.name, fm.toISOString()]} : null;
1138
+ const and= [];
1139
+ if( min) and.push(min);
1140
+ if( max) and.push(max);
1141
+ if( !min && !max)
1142
+ onChangeFilterValue(field, { });
1143
+ else
1144
+ onChangeFilterValue(field, { $and: and});
1145
+ gtag('event', 'search (date)');
1146
+ }
1147
+ return <div className={"flex flex-no-gap flex-no-wrap"}>
1148
+ <label htmlFor={"minDate"+model.name+field.name}>
1149
+ Min:
1150
+ <input id={"minDate"+model.name+field.name} type={"datetime-local"} value={minDate} onChange={e => {
1151
+ setMinDate(e.target.value);
1152
+ onChange?.(e.target.value, maxDate);
1153
+ }} />
1154
+ </label>
1155
+ <label htmlFor={"maxDate"+model.name+field.name}>
1156
+ Max:
1157
+ <input id={"maxDate"+model.name+field.name} type={"datetime-local"} value={maxDate} onChange={e => {
1158
+ setMaxDate(e.target.value);
1159
+ onChange?.(minDate, e.target.value);
1160
+ }} />
1161
+ </label>
1162
+ </div>
1163
+ }
1164
+ export const FilterStringField = ({ field, onChangeFilterValue, filterValues, setFilterValues }) => {
1165
+ const { models, setPage } = useModelContext();
1166
+ const [isRegex, setIsRegex] = useState(false);
1167
+ const { t } = useTranslation();
1168
+
1169
+
1170
+ useEffect(() => {
1171
+ if( Object.keys(filterValues).length === 0 ){
1172
+ onChangeFilterValue(field, { });
1173
+ }
1174
+ }, [filterValues]);
1175
+
1176
+ // Debounced function to apply the filter
1177
+ const debouncedApplyFilter = useCallback(
1178
+ debounce((currentValue, currentIsRegex) => {
1179
+ setPage(1); // Reset page to 1 when filter changes
1180
+
1181
+ if (currentValue === '') {
1182
+ // No need to call setFilterValues here as it's done immediately in handleChange
1183
+ onChangeFilterValue(field, field.multiple ? [] : {}, true);
1184
+ return;
1185
+ }
1186
+
1187
+ let filterQuery;
1188
+ if (field.type === 'relation') {
1189
+ const relationModel = models.find(f => f.name === field.relation);
1190
+ if (relationModel) {
1191
+ const relationFilters = relationModel.fields
1192
+ .filter(f => mainFieldsTypes.includes(f.type))
1193
+ .map(mf => ({
1194
+ $regexMatch: { input: `$$this.${mf.name}`, regex: currentIsRegex ? currentValue : escapeRegExp(currentValue) }
1195
+ }));
1196
+ if (relationFilters.length > 0) {
1197
+ filterQuery = { [field.name]: {$find: { $and: [{ $or: relationFilters }] }}};
1198
+ } else {
1199
+ filterQuery = {}; // Or handle as no match if no searchable fields
1200
+ }
1201
+ } else {
1202
+ filterQuery = {}; // Relation model not found
1203
+ }
1204
+ } else { // Not a relation type
1205
+ const regexToUse = currentIsRegex ? currentValue : escapeRegExp(currentValue);
1206
+ if (field.type === 'array') {
1207
+ filterQuery = {
1208
+ $gt: [
1209
+ {
1210
+ $size: {
1211
+ $filter: {
1212
+ input: '$' + field.name,
1213
+ as: 'item',
1214
+ cond: {
1215
+ $regexMatch: {
1216
+ input: '$$item',
1217
+ regex: regexToUse
1218
+ }
1219
+ }
1220
+ }
1221
+ }
1222
+ },
1223
+ 0
1224
+ ]
1225
+ };
1226
+ } else { // Simple string field
1227
+ filterQuery = {
1228
+ $and: [{
1229
+ $regexMatch: {
1230
+ input: '$' + field.name,
1231
+ regex: regexToUse
1232
+ }
1233
+ }]
1234
+ };
1235
+ }
1236
+ }
1237
+ onChangeFilterValue(field, filterQuery, true);
1238
+ gtag('event', 'search (string)');
1239
+ }, 1200), // Debounce delay
1240
+ [] // Dependencies for useCallback
1241
+ );
1242
+
1243
+ const handleInputChange = (e) => {
1244
+ const newValue = e.target.value;
1245
+ // Update the displayed value immediately
1246
+ setFilterValues(filter => ({ ...filter, [field.name]: newValue }));
1247
+ // Call the debounced function to apply the filter
1248
+ debouncedApplyFilter(newValue, isRegex);
1249
+ };
1250
+
1251
+ const handleToggleRegex = () => {
1252
+ const newIsRegex = !isRegex;
1253
+ setIsRegex(newIsRegex);
1254
+ // Re-apply filter with the new regex state and current value
1255
+ // The value from filterValues should be up-to-date
1256
+ const currentValue = filterValues[field.name] || '';
1257
+ debouncedApplyFilter(currentValue, newIsRegex);
1258
+ };
1259
+
1260
+ return (
1261
+ <>
1262
+ <TextField
1263
+ type="text"
1264
+ name={`filter_${field.name}`}
1265
+ value={filterValues[field.name] || ''} // Ensure controlled component with a default empty string
1266
+ placeholder={isRegex ? t("filterstringfield.placeholder.regex", "regular expression") : t("filterstringfield.placeholder", "...")}
1267
+ onChange={handleInputChange}
1268
+ maxLength={1000}
1269
+ />
1270
+ <button title={"regex"} className={isRegex ? 'active' : ''} onClick={handleToggleRegex}>.*</button>
1271
+ </>
1272
+ );
1273
+ };
1274
+ export const FilterField = ({advanced,model, reversed, field, active, onChangeFilterValue, filterValues, setFilterValues}) => {
1275
+ const { elementsPerPage, pagedSort, setPagedSort, setPage, page, pagedFilters, lockedColumns, setLockedColumns } = useModelContext();
1276
+ const {t} = useTranslation();
1277
+ const [locked, setLocked] = useState(lockedColumns.includes(field.name));
1278
+ const queryClient = useQueryClient()
1279
+
1280
+ useEffect(() => {
1281
+ if(!reversed) {
1282
+ setFilterValues(filter => ({...filter, [field.name]: ''}));
1283
+ onChangeFilterValue(field, '', true);
1284
+ }
1285
+ }, [field]);
1286
+
1287
+ const handleToggleLock = () => {
1288
+ if( locked ) {
1289
+ if (lockedColumns.includes(field.name))
1290
+ setLockedColumns(cols => [...cols].filter(f => f !== field.name));
1291
+ }else{
1292
+ if (!lockedColumns.includes(field.name))
1293
+ setLockedColumns(cols => [...cols, field.name]);
1294
+ }
1295
+ setLocked(!locked);
1296
+ }
1297
+
1298
+ const [reset, setReset] = useState(false);
1299
+ const handleChangeSort = (up) => {
1300
+ setPagedSort(sort => {
1301
+ const s = lockedColumns.length > 0 ? {...sort[model.name] || {}} : {};
1302
+ if( up ){
1303
+ if( reset ){
1304
+ delete s[field.name];
1305
+ setReset(false);
1306
+ }else {
1307
+ s[field.name] = 1;
1308
+ }
1309
+ }else{
1310
+ s[field.name] = -1;
1311
+ setReset(true);
1312
+ }
1313
+ return {...sort, [model.name]: s};
1314
+ });
1315
+ queryClient.invalidateQueries(['api/data', model.name, 'page', page, elementsPerPage, pagedFilters[model.name], pagedSort[model.name]]);
1316
+ }
1317
+
1318
+ const resetClass = pagedSort[model.name]?.[field.name] ? (((pagedSort[model.name]?.[field.name] === 1) || (pagedSort[model.name]?.[field.name] === -1)) ? 'active' : 'reset') : '';
1319
+
1320
+ const renderIconFromType =(field)=>{
1321
+ const type = field.type;
1322
+ if( type === 'color'){
1323
+ return <FaPallet/>;
1324
+ }
1325
+ if( type === 'code'){
1326
+ return <FaCode />;
1327
+ }
1328
+ else if( type === 'date'){
1329
+ return <FaCalendarWeek />;
1330
+ }else if( type === 'datetime'){
1331
+ return <FaCalendarDays />;
1332
+ }
1333
+ else if( type === 'richtext' || type === 'string' || type === 'string_t'){
1334
+ return <></>;
1335
+ }
1336
+ else if( type === 'url'){
1337
+ return <FaLink />;
1338
+ }
1339
+ else if( type === 'number'){
1340
+ return <FaHashtag />;
1341
+ }
1342
+ else if( type === 'file'){
1343
+ return <FaFile />;
1344
+ }
1345
+ else if( type === 'enum'){
1346
+ return <FaListUl />;
1347
+ }
1348
+ else if( type === 'boolean'){
1349
+ return <FaToggleOn />;
1350
+ }
1351
+ else if( type === 'image'){
1352
+ return <FaImage/>;
1353
+ }
1354
+ else if( type === 'relation'){
1355
+ return field.multiple ? <FaSitemap /> : <FaLink />;
1356
+ }
1357
+ else if( type === 'email'){
1358
+ return <FaAt />;
1359
+ }
1360
+ else if( type === 'phone'){
1361
+ return <FaPhone />;
1362
+ }
1363
+ else if( type === 'array'){
1364
+ return <FaTableColumns />;
1365
+ }
1366
+ return <FaPencil />
1367
+ }
1368
+ return <th key={field.name} className={`form filter-field`} style={{backgroundColor: field.color, color: !field.color ||isLightColor(field.color) ? 'black': "white"}}>
1369
+ <div className="flex flex-centered flex-mini-gap flex-row">
1370
+ <div className="flex flex-1 flex-mini-gap flex-no-wrap">
1371
+ {renderIconFromType(field)}
1372
+ <span title={field.name} className={"flex-1 title"}>{t(`field_${model.name}_${field.name}`, field.name)}</span>
1373
+ </div>
1374
+ {advanced && (<>
1375
+ { 'password'!==field.type && (<div className={"flex flex-no-gap"}>
1376
+ {(<>
1377
+ {(pagedSort[model.name]?.[field.name] !== 1) &&
1378
+ <button onClick={() => handleChangeSort(true)}
1379
+ className={resetClass}>
1380
+ {pagedSort[model.name]?.[field.name] === undefined ? <FaArrowDown/> : <FaArrowUp/>}</button>}
1381
+ {(pagedSort[model.name]?.[field.name] === 1) &&
1382
+ <button onClick={() => handleChangeSort(false)}
1383
+ className={resetClass}>
1384
+ <FaArrowDown/></button>}
1385
+ </>
1386
+ )}
1387
+ {!field.unique && (
1388
+ <button onClick={() => handleToggleLock()} className={locked ? 'active' : ''}><FaLock/></button>)}
1389
+ </div>)}
1390
+ {active && !['date','datetime','enum', 'boolean', 'number', 'password'].includes(field.type) && <div className="filter flex flex-no-wrap flex-mini-gap">
1391
+ <FilterStringField setFilterValues={setFilterValues} filterValues={filterValues} field={field} onChangeFilterValue={onChangeFilterValue} />
1392
+ </div>}
1393
+ {active && field.type === 'enum' && <div className="filter flex flex-no-wrap flex-mini-gap">
1394
+ <FilterEnumField model={model} setFilterValues={setFilterValues} filterValues={filterValues} field={field} onChangeFilterValue={onChangeFilterValue} />
1395
+ </div>}
1396
+ {active && field.type === 'boolean' && <div className="filter flex flex-no-wrap flex-mini-gap">
1397
+ <FilterBooleanField filterValues={filterValues} model={model} field={field} onChangeFilterValue={onChangeFilterValue} />
1398
+ </div>}
1399
+ {active && ['date', 'datetime'].includes(field.type) && <div className="filter flex flex-no-wrap flex-mini-gap">
1400
+ <FilterDateField filterValues={filterValues} model={model} field={field} onChangeFilterValue={onChangeFilterValue} />
1401
+ </div>}
1402
+ {active && field.type === 'number' && <div className="filter flex flex-no-wrap flex-mini-gap">
1403
+ <FilterNumberField model={model} setFilterValues={setFilterValues} filterValues={filterValues} field={field} onChangeFilterValue={onChangeFilterValue} />
1404
+ </div>}
1405
+ </>)}
1406
+ </div>
1407
+ </th>
1408
+ }
1409
+
1410
+ export const PhoneField = ({name, value, onChange}) => {
1411
+ const [phone, setPhone] = useState(value);
1412
+ useEffect(() => {
1413
+ setPhone(value);
1414
+ }, [value]);
1415
+ return (
1416
+ <div>
1417
+ <PhoneInput
1418
+ defaultCountry="ua"
1419
+ value={phone || ''}
1420
+ onChange={(phone) => {
1421
+ setPhone(phone);
1422
+ onChange?.(phone);
1423
+ }}
1424
+ />
1425
+ </div>
1426
+ );
1427
+ }
1428
+
1429
+ export const ModelField = ({field, formData, disableable=false, showModel=true, value, fields=false, onChange}) => {
1430
+ const {models} = useModelContext();
1431
+ const {me} = useAuthContext();
1432
+ const {t} = useTranslation();
1433
+ const [checked, setChecked] = useState(true);
1434
+
1435
+ // --- LOGIQUE AMÉLIORÉE POUR DÉTERMINER LE MODÈLE CIBLE ET LES VALEURS ---
1436
+ const hasTargetModel = !!field?.targetModel;
1437
+ let modelValue, fieldValue, targetModelName;
1438
+
1439
+ if (hasTargetModel) {
1440
+ // Le modèle est déterminé par un autre champ. La valeur de ce champ est juste le nom du champ (string).
1441
+ if (typeof field.targetModel === 'string' && field.targetModel.startsWith('$')) {
1442
+ const dynamicFieldName = field.targetModel.substring(1);
1443
+ targetModelName = formData?.[dynamicFieldName] || null;
1444
+ } else {
1445
+ targetModelName = field.targetModel;
1446
+ }
1447
+ modelValue = targetModelName;
1448
+ fieldValue = value;
1449
+ } else {
1450
+ // La valeur de ce champ contient le modèle et/ou le champ.
1451
+ if (fields) { // fields=true: on sélectionne un modèle ET un champ. La valeur est un objet.
1452
+ targetModelName = value?.model;
1453
+ modelValue = value?.model;
1454
+ fieldValue = value?.field;
1455
+ } else { // fields=false: on sélectionne seulement un modèle. La valeur est une chaîne.
1456
+ targetModelName = value;
1457
+ modelValue = value;
1458
+ fieldValue = undefined;
1459
+ }
1460
+ }
1461
+
1462
+ const selectedModel = models.find(m => m.name === targetModelName); // Note: Removed user check for broader compatibility with system models
1463
+
1464
+ // Préparer les options pour les champs du modèle
1465
+ const fieldOptions = selectedModel?.fields.map(f => ({
1466
+ label: t(`field_${selectedModel.name}_${f.name}`, f.name),
1467
+ value: f.name
1468
+ })) || [];
1469
+
1470
+ // Effet pour réinitialiser le champ si le modèle cible change et que le champ actuel n'est plus valide
1471
+ useEffect(() => {
1472
+ if (hasTargetModel) {
1473
+ const isValid = fieldOptions.some(opt => opt.value === fieldValue);
1474
+ if (!isValid) {
1475
+ const newValue = fieldOptions.length > 0 ? fieldOptions[0].value : null;
1476
+ if (fieldValue !== newValue) {
1477
+ onChange({ name: field.name, value: newValue });
1478
+ }
1479
+ }
1480
+ }
1481
+ }, [targetModelName]); // Se déclenche quand le nom du modèle cible change
1482
+
1483
+ // Gestion du changement de modèle (uniquement si pas de targetModel)
1484
+ const handleModelChange = (e) => {
1485
+ const newModelName = e.value;
1486
+ const newModel = models.find(m => m.name === newModelName);
1487
+ const firstField = newModel?.fields[0]?.name || null;
1488
+
1489
+ if (fields) {
1490
+ onChange({name: field?.name, value: { model: newModelName, field: firstField }});
1491
+ } else {
1492
+ onChange({name: field?.name, value: newModelName});
1493
+ }
1494
+ };
1495
+
1496
+ // Gestion du changement de champ
1497
+ const handleFieldChange = (e) => {
1498
+ const newFieldName = e.value;
1499
+ if (hasTargetModel) {
1500
+ onChange({ name: field.name, value: newFieldName });
1501
+ } else {
1502
+ onChange({name: field?.name, value: { model: modelValue, field: newFieldName }});
1503
+ }
1504
+ };
1505
+
1506
+ const dis = disableable ? (<CheckboxField
1507
+ checked={checked}
1508
+ onChange={e => {
1509
+ setChecked(e);
1510
+ if (!e) {
1511
+ onChange({name: field?.name, value: null});
1512
+ }
1513
+ }}
1514
+ />) : null;
1515
+
1516
+ if (!fields) {
1517
+ return (<div className="flex flex-1">
1518
+ {dis}
1519
+ {checked && (<SelectField
1520
+ className="flex-1"
1521
+ value={modelValue}
1522
+ onChange={handleModelChange}
1523
+ items={models
1524
+ .filter(m => m._user === me?.username) // Keep user filter for selection
1525
+ .map(m => ({
1526
+ label: t(`model_${m.name}`, m.name),
1527
+ value: m.name
1528
+ }))}
1529
+ />)}
1530
+ </div>);
1531
+ }
1532
+
1533
+ return (<div className="flex flex-1">
1534
+ {dis}
1535
+ {checked && (<div className="flex flex-stretch" key={field?.name ?? 'def'}>
1536
+ {showModel && (<SelectField
1537
+ className="flex-1"
1538
+ value={modelValue}
1539
+ onChange={handleModelChange}
1540
+ items={models
1541
+ .filter(m => m._user === me?.username) // Keep user filter for selection
1542
+ .map(m => ({
1543
+ label: t(`model_${m.name}`, m.name),
1544
+ value: m.name
1545
+ }))}
1546
+ disabled={hasTargetModel} // Le sélecteur de modèle est désactivé si le modèle est piloté par un autre champ
1547
+ />)}
1548
+ <SelectField
1549
+ className="flex-1"
1550
+ value={fieldValue}
1551
+ onChange={handleFieldChange}
1552
+ items={fieldOptions}
1553
+ disabled={!targetModelName}
1554
+ />
1555
+ </div>)}
1556
+ </div>);
1557
+ };
1558
+
1559
+ // Fonction pour obtenir le composant icône par son nom
1560
+ const getIconComponent = (iconName) => {
1561
+ if (!iconName) return null;
1562
+ const IconComponent = FaIcons[iconName] || Fa6Icons[iconName];
1563
+ return IconComponent ? <IconComponent /> : null; // Retourne l'élément React ou null
1564
+ };
1565
+ export const IconField = ({name, label, value, disabled, onChange, className, ...rest}) => {
1566
+ const { t } = useTranslation();
1567
+ const [iconSuggestions, setIconSuggestions] = useState([]);
1568
+ // Tri alphabétique pour une recherche plus prévisible
1569
+ const [allFaIcons] = useState(() => [...Object.keys(FaIcons), ...Object.keys(Fa6Icons)].sort());
1570
+
1571
+ const handleIconChange = (e) => {
1572
+ const value = e.target.value;
1573
+ onChange(value);
1574
+ if (value) {
1575
+ const filtered = allFaIcons.filter(
1576
+ icon => icon.toLowerCase().includes(value.toLowerCase())
1577
+ );
1578
+ setIconSuggestions(filtered.slice(0, 20));
1579
+ } else {
1580
+ setIconSuggestions([]);
1581
+ }
1582
+ };
1583
+
1584
+ const handleIconFocus = () => {
1585
+ if (value) {
1586
+ const filtered = allFaIcons.filter(
1587
+ icon => icon.toLowerCase().includes(value.toLowerCase())
1588
+ );
1589
+ setIconSuggestions(filtered.slice(0, 20));
1590
+ } else {
1591
+ setIconSuggestions(allFaIcons.slice(0, 10));
1592
+ }
1593
+ };
1594
+
1595
+ const onSuggestionClick = (suggestion) => {
1596
+ onChange(suggestion);
1597
+ setIconSuggestions([]);
1598
+ };
1599
+
1600
+ return <div className="textfield-wrapper with-suggestions">
1601
+ <div className={"flex flex-1 flex-no-wrap"}>
1602
+ <TextField
1603
+ help={t('modelcreator.field.icon')}
1604
+ id="modelIcon"
1605
+ disabled={disabled}
1606
+ value={value}
1607
+ label={label}
1608
+ before={<div>{getIconComponent(value)}</div>}
1609
+ onChange={handleIconChange}
1610
+ onFocus={handleIconFocus}
1611
+ onBlur={() => setTimeout(() => setIconSuggestions([]), 200)}
1612
+ autoComplete="off"
1613
+ />
1614
+ </div>
1615
+ {iconSuggestions.length > 0 && (
1616
+ <ul className="suggestions-list">
1617
+ {iconSuggestions.map(icon => (
1618
+ <li key={icon} onMouseDown={() => onSuggestionClick(icon)}>
1619
+ <span className="suggestion-icon">{getIconComponent(icon)}</span>
1620
+ <span>{icon}</span>
1621
+ </li>
1622
+ ))}
1623
+ </ul>
1624
+ )}
1625
+ </div>
1626
+ };
1627
+
1628
+ export const ColorField = ({ name, label, value, disabled, onChange, className, ...rest }) => {
1629
+ const [displayColorPicker, setDisplayColorPicker] = useState(false);
1630
+
1631
+ const handleClick = () => {
1632
+ if (!disabled) {
1633
+ setDisplayColorPicker(!displayColorPicker);
1634
+ }
1635
+ };
1636
+
1637
+ const handleClose = () => {
1638
+ setDisplayColorPicker(false);
1639
+ };
1640
+
1641
+ const handleChange = (color) => {
1642
+ // react-color nous donne un objet avec tous les formats.
1643
+ // On utilise tinycolor pour le convertir au format canonique attendu par le backend (#RRGGBBAA).
1644
+ const newColor = tinycolor(color.rgb); // color.rgb contient {r, g, b, a}
1645
+ onChange?.({ name, value: newColor.toHex8String().toUpperCase() });
1646
+ };
1647
+
1648
+ const color = tinycolor(value || '#FFFFFFFF');
1649
+ const swatchStyle = {
1650
+ background: color.toRgbString(),
1651
+ width: '100%',
1652
+ minWidth: '52px',
1653
+ height: '36px',
1654
+ borderRadius: '2px',
1655
+ border: '1px solid #ccc',
1656
+ cursor: disabled ? 'not-allowed' : 'pointer',
1657
+ };
1658
+
1659
+ return (
1660
+ <div className={`flex flex-1 flex-no-wrap ${className || ''}`}>
1661
+ {label && (<label className="flex-1 mb-1">{label}</label>)}
1662
+ <div style={swatchStyle} onClick={handleClick}>&nbsp;</div>
1663
+ <div className={"flex-1"}>{value}</div>
1664
+ {displayColorPicker ? (
1665
+ <div style={{ position: 'absolute', zIndex: '2' }}>
1666
+ <div style={{ position: 'fixed', top: '0px', right: '0px', bottom: '0px', left: '0px' }} onClick={handleClose} />
1667
+ <SketchPicker color={value || '#FFFFFFFF'} onChange={handleChange} />
1668
+ </div>
1669
+ ) : null}
1670
+ </div>
1671
+ );
1672
+ };
1673
+
1674
+ const secondsToDuration = (totalSeconds) => {
1675
+ if (totalSeconds === null || totalSeconds === undefined || isNaN(totalSeconds) || totalSeconds === '') {
1676
+ return { days: '', hours: '', minutes: '', seconds: '' };
1677
+ }
1678
+ const total = parseInt(totalSeconds, 10);
1679
+ const d = Math.floor(total / 86400);
1680
+ let remainder = total % 86400;
1681
+ const h = Math.floor(remainder / 3600);
1682
+ remainder %= 3600;
1683
+ const m = Math.floor(remainder / 60);
1684
+ const s = remainder % 60;
1685
+ return { days: d, hours: h, minutes: m, seconds: s };
1686
+ };
1687
+
1688
+ const durationToSeconds = ({ days, hours, minutes, seconds }) => {
1689
+ return (parseInt(days, 10) || 0) * 86400 +
1690
+ (parseInt(hours, 10) || 0) * 3600 +
1691
+ (parseInt(minutes, 10) || 0) * 60 +
1692
+ (parseInt(seconds, 10) || 0);
1693
+ };
1694
+
1695
+ export const DurationField = forwardRef(({ value, onChange, name, label, help, required, editable, readOnly }, ref) => {
1696
+ const { t } = useTranslation();
1697
+ const [duration, setDuration] = useState(secondsToDuration(value));
1698
+ const [errors, setErrors] = useState([]);
1699
+
1700
+ useEffect(() => {
1701
+ setDuration(secondsToDuration(value));
1702
+ }, [value]);
1703
+
1704
+ const validate = () => {
1705
+ const errs = [];
1706
+ const totalSeconds = durationToSeconds(duration);
1707
+ if (required && totalSeconds <= 0) {
1708
+ errs.push(t('form.validation.required', "Field required"));
1709
+ }
1710
+ setErrors(errs);
1711
+ return !errs.length;
1712
+ };
1713
+
1714
+ useImperativeHandle(ref, () => ({
1715
+ validate,
1716
+ getValue: () => durationToSeconds(duration),
1717
+ }));
1718
+
1719
+ const handlePartChange = (part) => (e) => {
1720
+ const newDuration = { ...duration, [part]: e.target.value };
1721
+ setDuration(newDuration);
1722
+ if (onChange) {
1723
+ const totalSeconds = durationToSeconds(newDuration);
1724
+ onChange({ name, value: totalSeconds });
1725
+ }
1726
+ };
1727
+
1728
+ return (
1729
+ <>
1730
+ <div className={cn({ field: true, "field-duration": true, 'flex-1': true, flex: true, "field-bg": true })}>
1731
+ {label && (
1732
+ <label contentEditable={editable} className={cn({ help: !!help, 'flex-1': true })}>
1733
+ {label}
1734
+ {required && <span className="mandatory" contentEditable={false}>*</span>}
1735
+ </label>
1736
+ )}
1737
+ {help && <div className="flex help">{help}</div>}
1738
+ <div className="duration-inputs flex flex-no-wrap flex-mini-gap">
1739
+ <NumberField name={`${name}-days`} unit={t('duration.unit.days', 'days')} value={duration.days} onChange={handlePartChange('days')} readOnly={readOnly} min={0} />
1740
+ <NumberField name={`${name}-hours`} unit={t('duration.unit.hours', 'hours')} value={duration.hours} onChange={handlePartChange('hours')} readOnly={readOnly} min={0} max={23} />
1741
+ <NumberField name={`${name}-minutes`} unit={t('duration.unit.minutes', 'minutes')} value={duration.minutes} onChange={handlePartChange('minutes')} readOnly={readOnly} min={0} max={59} />
1742
+ <NumberField name={`${name}-seconds`} unit={t('duration.unit.seconds', 'seconds')} value={duration.seconds} onChange={handlePartChange('seconds')} readOnly={readOnly} min={0} max={59} />
1743
+ </div>
1744
+ </div>
1745
+ {errors.length > 0 && (
1746
+ <ul className="error">
1747
+ {errors.map((e, key) => (
1748
+ <li key={key} aria-live="assertive" role="alert">{e}</li>
1749
+ ))}
1750
+ </ul>
1751
+ )}
1752
+ </>
1753
+ );
1754
+ });
1755
+ DurationField.displayName = "DurationField";
1756
+
1757
+ export const CodeField = ({name, label, language, value, disabled, onChange}) => {
1758
+ const u = name || uniqid();
1759
+ const [currentEditor, setEditor] = useState(null);
1760
+
1761
+ return <>
1762
+ {label && (<label className="flex flex-1">{label}</label>)}
1763
+ {!disabled ? <div className={"codefield"}><span><b>{language}</b> : </span><CodeiumEditor
1764
+ language={language || 'json'}
1765
+ theme={"vs-dark"}
1766
+ value={value}
1767
+ onChange={e => {
1768
+ if (language === 'json') {
1769
+ let code;
1770
+ try {
1771
+ code = JSON.parse(e);
1772
+ onChange({name, value: code});
1773
+ } catch (e) {
1774
+ }
1775
+ } else
1776
+ onChange({name, value: e});
1777
+ }}
1778
+ height="300px"
1779
+ /></div> : <div className="code"><SyntaxHighlighter
1780
+ language={language || "javascript"}>{value}</SyntaxHighlighter></div>
1781
+ }</>
1782
+ }
1783
+
1784
+ export const EnumField = ({inputProps, value, handleChange, field}) => {
1785
+ const { t} = useTranslation()
1786
+ useEffect(() => {
1787
+ if( field.items.includes(value))
1788
+ handleChange(value);
1789
+ else{
1790
+ handleChange({name: field.name, value: field.items[0]})
1791
+ }
1792
+ }, []);
1793
+ return (
1794
+ <select {...inputProps} onChange={(e) => handleChange({name: field.name, value: e.target.value})} >{(field.items || []).map(item => {
1795
+ if( typeof(item) === 'string'){
1796
+ return <option value={item}>{t(item, item)}</option>;
1797
+ }
1798
+ return <></>
1799
+ })}</select>
1800
+ );
1801
+ }
1802
+
1803
+ export const RangeField = ({ name, value, onChange, min = 0, max = 100, step = 1, percent = false }) => {
1804
+ const handleChange = (e) => {
1805
+ // The onChange from the form probably expects the field name and value
1806
+ onChange(parseFloat(e.target.value));
1807
+ };
1808
+
1809
+ const percentage = max > min ? Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)) : 0;
1810
+ const displayValue = percent ? `${Math.round(percentage)}%` : value;
1811
+
1812
+ return (
1813
+ <div className="range-field">
1814
+ <input
1815
+ type="range"
1816
+ name={name}
1817
+ value={value || 0}
1818
+ onChange={handleChange}
1819
+ min={min}
1820
+ max={max}
1821
+ step={step}
1822
+ />
1823
+ <span className="range-value">{displayValue}</span>
1824
+ </div>
1825
+ );
1785
1826
  };