data-primals-engine 1.5.0 → 1.5.2

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