data-primals-engine 1.4.3 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +913 -867
  2. package/client/package-lock.json +49 -0
  3. package/client/package.json +1 -0
  4. package/client/src/AddWidgetTypeModal.jsx +47 -43
  5. package/client/src/App.jsx +3 -7
  6. package/client/src/App.scss +25 -3
  7. package/client/src/AssistantChat.jsx +363 -323
  8. package/client/src/AssistantChat.scss +30 -12
  9. package/client/src/Dashboard.jsx +480 -396
  10. package/client/src/Dashboard.scss +1 -1
  11. package/client/src/DashboardHtmlViewItem.jsx +147 -0
  12. package/client/src/DashboardView.jsx +104 -19
  13. package/client/src/DataEditor.jsx +12 -5
  14. package/client/src/DataLayout.jsx +805 -762
  15. package/client/src/DataLayout.scss +14 -0
  16. package/client/src/DataTable.jsx +63 -77
  17. package/client/src/Dialog.scss +1 -1
  18. package/client/src/Field.jsx +591 -322
  19. package/client/src/FlexDataRenderer.jsx +2 -0
  20. package/client/src/FlexTreeUtils.js +1 -1
  21. package/client/src/FlexViewCard.jsx +44 -0
  22. package/client/src/HistoryDialog.jsx +47 -14
  23. package/client/src/HtmlViewBuilderModal.jsx +91 -0
  24. package/client/src/HtmlViewBuilderModal.scss +18 -0
  25. package/client/src/HtmlViewCard.jsx +44 -0
  26. package/client/src/HtmlViewCard.scss +35 -0
  27. package/client/src/KPIDialog.jsx +11 -1
  28. package/client/src/KanbanCard.jsx +1 -2
  29. package/client/src/ModelCreator.jsx +6 -6
  30. package/client/src/ModelCreatorField.jsx +74 -31
  31. package/client/src/ModelList.jsx +93 -54
  32. package/client/src/Notification.jsx +136 -136
  33. package/client/src/Notification.scss +0 -18
  34. package/client/src/Pagination.jsx +5 -3
  35. package/client/src/RelationField.jsx +354 -258
  36. package/client/src/RelationSelectorWidget.jsx +173 -0
  37. package/client/src/constants.js +1 -1
  38. package/client/src/contexts/ModelContext.jsx +10 -1
  39. package/client/src/contexts/UIContext.jsx +72 -63
  40. package/client/src/filter.js +262 -212
  41. package/client/src/hooks/useTutorials.jsx +62 -65
  42. package/client/src/hooks/useValidation.js +75 -0
  43. package/client/src/translations.js +26 -24
  44. package/package.json +3 -1
  45. package/perf/README.md +147 -0
  46. package/perf/artillery-hooks.js +37 -0
  47. package/perf/perf-shot-hardwork.yml +84 -0
  48. package/perf/perf-shot-search.yml +45 -0
  49. package/perf/setup.yml +26 -0
  50. package/server.js +1 -1
  51. package/src/constants.js +3 -28
  52. package/src/core.js +15 -1
  53. package/src/data.js +1 -1
  54. package/src/defaultModels.js +63 -7
  55. package/src/email.js +5 -2
  56. package/src/engine.js +5 -3
  57. package/src/filter.js +5 -3
  58. package/src/i18n.js +710 -10
  59. package/src/modules/assistant/assistant.js +151 -19
  60. package/src/modules/bucket.js +14 -16
  61. package/src/modules/data/data.backup.js +11 -8
  62. package/src/modules/data/data.core.js +118 -92
  63. package/src/modules/data/data.history.js +531 -492
  64. package/src/modules/data/data.js +9 -56
  65. package/src/modules/data/data.operations.js +3282 -2999
  66. package/src/modules/data/data.relations.js +686 -686
  67. package/src/modules/data/data.routes.js +118 -24
  68. package/src/modules/data/data.scheduling.js +2 -1
  69. package/src/modules/data/data.validation.js +85 -3
  70. package/src/modules/file.js +247 -236
  71. package/src/modules/user.js +4 -1
  72. package/src/modules/workflow.js +9 -10
  73. package/src/openai.jobs.js +2 -0
  74. package/src/packs.js +5482 -5461
  75. package/src/providers.js +22 -7
  76. package/test/data.integration.test.js +136 -2
  77. package/test/import_export.integration.test.js +1 -1
@@ -1,6 +1,7 @@
1
1
  import React, {
2
2
  forwardRef, useCallback,
3
3
  useEffect,
4
+ useMemo,
4
5
  useImperativeHandle,
5
6
  useRef,
6
7
  useState,
@@ -14,13 +15,15 @@ import { CodeiumEditor } from "@codeium/react-code-editor";
14
15
  import {debounce, escapeRegExp, isGUID, isLightColor} from "../../src/core.js";
15
16
  import {mainFieldsTypes, maxFileSize} from "../../src/constants.js";
16
17
  import {useModelContext} from "./contexts/ModelContext.jsx";
18
+ import { SketchPicker } from 'react-color'; // Importer le sélecteur
17
19
  import {useQueryClient} from "react-query";
18
- import {
20
+ import tinycolor from 'tinycolor2';
21
+ import {
19
22
  FaArrowDown,
20
- FaArrowUp, FaAt, FaEye, FaEyeSlash,
23
+ FaArrowUp, FaAt, FaCheckCircle, FaEye, FaEyeSlash, FaExclamationCircle,
21
24
  FaCalendar, FaCalendarWeek, FaCode, FaFile,
22
25
  FaHashtag, FaIcons,
23
- FaImage,
26
+ FaImage, FaSpinner,
24
27
  FaLink, FaListOl, FaListUl,
25
28
  FaLock, FaMinus,
26
29
  FaPallet, FaPhone, FaSitemap,
@@ -28,13 +31,12 @@ import {
28
31
  } from "react-icons/fa";
29
32
  import * as Fa6Icons from 'react-icons/fa6'; // Importer Fa6
30
33
  import {FaCalendarDays, FaCodeCompare, FaPencil, FaT, FaTableColumns} from "react-icons/fa6";
31
- import { CodeBlock, tomorrowNightBright } from 'react-code-blocks';
32
34
  import SyntaxHighlighter from 'react-syntax-highlighter';
33
- import { docco } from 'react-syntax-highlighter/dist/esm/styles/hljs';
34
35
 
35
36
  import { PhoneInput } from 'react-international-phone';
36
37
  import 'react-international-phone/style.css';
37
38
  import {useAuthContext} from "./contexts/AuthContext.jsx";
39
+ import {useRealtimeValidation} from "./hooks/useValidation.js";
38
40
  import Switch from "react-switch";
39
41
  export const Form = ({
40
42
  name,
@@ -78,167 +80,292 @@ export const Form = ({
78
80
  };
79
81
 
80
82
  const TextField = forwardRef(function TextField(
81
- {
82
- name,
83
- label,
84
- placeholder,
85
- help,
86
- editable,
87
- value,
88
- required,
89
- readOnly,
90
- onChange,
91
- multiline,
92
- minlength,
93
- maxlength,
94
- searchable,
95
- labelProps,
96
- showErrors=false,
97
- before,
98
- after,
99
- ...rest
100
- },
101
- ref,
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,
102
108
  ) {
103
- const [id, setId] = useState("textfield-" + uniqid());
104
- const [isPasswordVisible, setIsPasswordVisible] = useState(false);
105
- const [errors, setErrors] = useState([]);
106
- const inputRef = useRef();
107
-
108
- const { type, ...otherRest } = rest;
109
- const isPasswordField = type === 'password';
110
-
111
- const mult = typeof multiline !== 'undefined' ? multiline : maxlength > 255;
112
- const validate = () => {
113
- const errs = [];
114
- if (required && (!value || value.trim() === "")) {
115
- errs.push("Field required");
116
- }
117
- if (
118
- minlength > 0 &&
119
- typeof value == "string" &&
120
- value.trim().length < minlength
121
- ) {
122
- errs.push("Value length must be >= to " + minlength);
123
- }
124
- if (
125
- maxlength !== undefined && maxlength > 0 &&
126
- typeof value == "string" &&
127
- value.trim().length > maxlength
128
- ) {
129
- errs.push("Value length must be <= to " + maxlength);
130
- }
131
- if( showErrors )
132
- setErrors(errs);
133
- return !errs.length;
134
- };
135
- useImperativeHandle(ref, () => ({
136
- ref: inputRef.current,
137
- validate,
138
- getValue: () => value,
139
- }));
140
- const handleChange = (e) => {
141
- if (onChange) {
142
- onChange(e);
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);
143
254
  }
144
- };
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
+ };
145
269
 
146
- const togglePasswordVisibility = () => {
147
- setIsPasswordVisible(prevState => !prevState);
148
- };
149
- useEffect(() => {
150
- if (value !== null) validate();
151
- }, [value]);
152
- return (
153
- <>
154
- <div
155
- className={cn({
156
- field: true,
157
- flex: true,
158
- "field-text": !mult,
159
- "field-multiline": mult,
160
- })}
161
- >
162
- {label && (
163
- <label
164
- contentEditable={editable}
165
- className={cn({help: !!help, 'flex-1': true})}
166
- htmlFor={id}
167
- {...labelProps}
168
- >
169
- {label}
170
- {required ? (
171
- <span className="mandatory" contentEditable={false}>
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}>
172
297
  *
173
298
  </span>
174
- ) : (
175
- ""
176
- )}
177
- </label>
178
- )}
179
-
180
- {help &&<div className="flex help">{help}</div>}
299
+ ) : (
300
+ ""
301
+ )}
302
+ </label>
303
+ )}
181
304
 
182
- {mult && (
183
- <textarea
184
- ref={inputRef}
185
- aria-required={required}
186
- aria-readonly={readOnly}
187
- readOnly={readOnly}
188
- placeholder={placeholder}
189
- id={id}
190
- name={name}
191
- value={value || ""}
192
- rows={8}
193
- onChange={handleChange}
194
- minLength={minlength}
195
- maxLength={maxlength}
196
- {...rest}
197
- ></textarea>
198
- )}
305
+ {help && <div className="flex help">{help}</div>}
199
306
 
200
- {before}
201
- <div className={"flex flex-1 flex-no-gap flex-start"} style={{ position: 'relative' }}>
202
- {!mult && (
203
- <input
307
+ {mult && (
308
+ <textarea
204
309
  ref={inputRef}
205
310
  aria-required={required}
206
311
  aria-readonly={readOnly}
207
312
  readOnly={readOnly}
208
- type={isPasswordField ? (isPasswordVisible ? 'text' : 'password') : (searchable ? "search" : (type || "text"))}
209
313
  placeholder={placeholder}
210
- title={placeholder}
211
- alt={placeholder}
212
314
  id={id}
213
315
  name={name}
214
316
  value={value || ""}
317
+ rows={8}
215
318
  onChange={handleChange}
216
319
  minLength={minlength}
217
320
  maxLength={maxlength}
218
- required={required}
219
- style={isPasswordField ? { paddingRight: '40px' } : {}}
220
- {...otherRest}
221
- />
222
- )}
223
- {isPasswordField && !mult && (
224
- <button type="button" onClick={togglePasswordVisibility} className="password-toggle-icon" style={{position: 'absolute', right: '10px', top: '50%', transform: 'translateY(-50%)', display: 'flex', alignItems: 'center'}}>
225
- {isPasswordVisible ? <FaEyeSlash /> : <FaEye />}
226
- </button>
321
+ {...rest}
322
+ ></textarea>
227
323
  )}
228
- {after}
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>
229
357
  </div>
230
- </div>
231
- {errors.length > 0 && (
232
- <ul className="error">
233
- {errors.map((e, key) => (
234
- <li key={key} aria-live="assertive" role="alert">
235
- {e}
236
- </li>
237
- ))}
238
- </ul>
239
- )}
240
- </>
241
- );
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
+ );
242
369
  });
243
370
 
244
371
  TextField.displayName = "TextField";
@@ -252,56 +379,103 @@ const EmailField = forwardRef(
252
379
  placeholder,
253
380
  help,
254
381
  editable,
255
- defaultValue,
382
+ value, // Changed from defaultValue to be a controlled component
256
383
  required,
257
384
  readOnly,
258
385
  onChange,
259
386
  minlength,
260
387
  maxlength,
261
- fieldValidated,
388
+ validation,
389
+ showErrors = true,
390
+ showTooltipErrors = false,
262
391
  },
263
392
  ref,
264
393
  ) => {
265
394
  const id = "emailfield-" + uniqid();
266
395
  const [errors, setErrors] = useState([]);
267
- const [value, setValue] = useState(defaultValue || null);
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
+
268
409
  const validate = () => {
269
410
  const errs = [];
270
- if (required && (!value || value.trim() === "")) {
411
+ if (required && (!value || String(value).trim() === "")) {
271
412
  errs.push("Field required");
272
413
  }
273
- if (minlength !== undefined && minlength > 0 && value && value.trim().length < minlength) {
414
+ if (minlength !== undefined && minlength > 0 && value && String(value).trim().length < minlength) {
274
415
  errs.push("Value length must be >= to " + minlength);
275
416
  }
276
- if (maxlength !== undefined && maxlength > 0 && value && value.trim().length > maxlength) {
417
+ if (maxlength !== undefined && maxlength > 0 && value && String(value).trim().length > maxlength) {
277
418
  errs.push("Value length must be <= to " + maxlength);
278
419
  }
279
-
280
- if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
281
- errs.push("Invalid email");
420
+ if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
421
+ errs.push("Invalid email");
282
422
  }
283
- setErrors(errs);
284
- return !errs.length;
423
+ if (isRealtimeValidationEnabled && fieldValidationState.status === 'invalid') {
424
+ errs.push(fieldValidationState.error);
425
+ }
426
+ if (showErrors) setErrors(errs);
427
+ return !errs.length && fieldValidationState.status !== 'invalid';
285
428
  };
429
+
286
430
  useEffect(() => {
287
- if (value !== null) validate();
431
+ if (value !== null && !isRealtimeValidationEnabled) validate();
288
432
  }, [value]);
289
- useEffect(() => {
290
- if (fieldValidated) validate();
291
- }, [fieldValidated]);
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
+
292
441
  useImperativeHandle(ref, () => ({
442
+ ref: inputRef.current,
293
443
  validate,
294
444
  getValue: () => value,
295
445
  }));
446
+
296
447
  const handleChange = (e) => {
297
- setValue(e.target.value);
298
448
  if (onChange) {
299
449
  onChange(e);
300
450
  }
451
+ if (isRealtimeValidationEnabled) {
452
+ realtimeValidate(name, e.target.value);
453
+ }
301
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
+
302
467
  return (
303
468
  <>
304
- <div className={cn({ field: true, "field-email": true })}>
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
+ >
305
479
  <label
306
480
  contentEditable={editable}
307
481
  className={cn({ help: !!help })}
@@ -317,27 +491,33 @@ const EmailField = forwardRef(
317
491
  ""
318
492
  )}
319
493
  </label>
320
- <input
321
- aria-required={required}
322
- aria-readonly={readOnly}
323
- readOnly={readOnly}
324
- type="email"
325
- placeholder={placeholder}
326
- id={id}
327
- name={name}
328
- value={value || ""}
329
- onChange={handleChange}
330
- minLength={minlength}
331
- maxLength={maxlength}
332
- />
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>
333
513
  </div>
334
- {errors.length > 0 && (
514
+ {!showTooltipErrors && hasErrors && (
335
515
  <ul className="error">
336
- {errors.map((e, key) => (
337
- <li key={key} aria-live="assertive" role="alert">
338
- {e}
339
- </li>
340
- ))}
516
+ {combinedErrors.map((e, key) => (
517
+ <li key={key} aria-live="assertive" role="alert">
518
+ {e}
519
+ </li>
520
+ ))}
341
521
  </ul>
342
522
  )}
343
523
  </>
@@ -365,6 +545,9 @@ const NumberField = forwardRef(
365
545
  max,
366
546
  step,
367
547
  unit,
548
+ validation,
549
+ showErrors = true,
550
+ showTooltipErrors = false,
368
551
  ...rest
369
552
  },
370
553
  ref,
@@ -372,6 +555,14 @@ const NumberField = forwardRef(
372
555
  const id = "numberfield-" + uniqid();
373
556
  const [errors, setErrors] = useState([]);
374
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
+
375
566
  const validate = () => {
376
567
  const errs = [];
377
568
  if (required && value === undefined) {
@@ -389,25 +580,62 @@ const NumberField = forwardRef(
389
580
  if ((max || max === 0) && (value || value === 0) && max < value) {
390
581
  errs.push("Value > to " + max);
391
582
  }
392
- setErrors(errs);
393
- return !errs.length;
583
+ if (isRealtimeValidationEnabled && fieldValidationState.status === 'invalid') {
584
+ errs.push(fieldValidationState.error);
585
+ }
586
+ if(showErrors) setErrors(errs);
587
+ return !errs.length && fieldValidationState.status !== 'invalid';
394
588
  };
589
+
395
590
  useEffect(() => {
396
- if (value !== null) validate();
591
+ if (value !== null && !isRealtimeValidationEnabled) validate();
397
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
+
398
601
  useImperativeHandle(ref, () => ({
399
602
  ref: inputRef.current,
400
603
  validate,
401
604
  getValue: () => value,
402
605
  }));
606
+
403
607
  const handleChange = (e) => {
404
608
  if (onChange) {
405
609
  onChange(e);
406
610
  }
611
+ if (isRealtimeValidationEnabled) {
612
+ realtimeValidate(name, e.target.value);
613
+ }
407
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
+
408
627
  return (
409
628
  <>
410
- <div className={cn({ field: true, "field-number": true })}>
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
+ >
411
639
 
412
640
  <div className="flex flex-1">
413
641
  {label && (
@@ -428,36 +656,39 @@ const NumberField = forwardRef(
428
656
  </label>
429
657
  )}
430
658
  {help && <div className="flex help">{help}</div>}
431
- <div className={"flex flex-1 flex-no-wrap flex-mini-gap"}>
432
- <input
433
- ref={inputRef}
434
- aria-required={required}
435
- aria-readonly={readOnly}
436
- readOnly={readOnly}
437
- type="number"
438
- placeholder={placeholder}
439
- id={id}
440
- name={name}
441
- value={value || ""}
442
- onChange={handleChange}
443
- minLength={minlength}
444
- maxLength={maxlength}
445
- min={min}
446
- max={max}
447
- step={step}
448
- {...rest}
449
- />
450
- {unit && <span className="unit">{unit}</span>}
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>}
451
682
  </div>
452
683
  </div>
453
684
  </div>
454
- {errors.length > 0 && (
685
+ {!showTooltipErrors && hasErrors && (
455
686
  <ul className="error">
456
- {errors.map((e, key) => (
457
- <li key={key} aria-live="assertive" role="alert">
458
- {e}
459
- </li>
460
- ))}
687
+ {combinedErrors.map((e, key) => (
688
+ <li key={key} aria-live="assertive" role="alert">
689
+ {e}
690
+ </li>
691
+ ))}
461
692
  </ul>
462
693
  )}
463
694
  </>
@@ -1178,7 +1409,7 @@ export const FilterStringField = ({ field, onChangeFilterValue, filterValues, se
1178
1409
 
1179
1410
  if (currentValue === '') {
1180
1411
  // No need to call setFilterValues here as it's done immediately in handleChange
1181
- onChangeFilterValue(field, field.multiple ? [] : undefined, true);
1412
+ onChangeFilterValue(field, field.multiple ? [] : {}, true);
1182
1413
  return;
1183
1414
  }
1184
1415
 
@@ -1363,7 +1594,7 @@ export const FilterField = ({advanced,model, reversed, field, active, onChangeFi
1363
1594
  }
1364
1595
  return <FaPencil />
1365
1596
  }
1366
- return <th key={field.name} className={`form filter-field`} style={{backgroundColor: field.color, color: !field.color ||isLightColor(field.color) ? 'black': "white"}}>
1597
+ return <th key={`filter_${field.name}`} className={`form filter-field`} style={{backgroundColor: field.color, color: !field.color ||isLightColor(field.color) ? 'black': "white"}}>
1367
1598
  <div className="flex flex-centered flex-mini-gap flex-row">
1368
1599
  <div className="flex flex-1 flex-mini-gap flex-no-wrap">
1369
1600
  {renderIconFromType(field)}
@@ -1424,101 +1655,136 @@ export const PhoneField = ({name, value, onChange}) => {
1424
1655
  );
1425
1656
  }
1426
1657
 
1427
- export const ModelField = ({field, disableable=false, showModel=true, value, fieldValue, fields=false, onChange}) => {
1658
+ export const ModelField = ({field, formData, disableable=false, showModel=true, value, fields=false, onChange, ...rest}) => {
1428
1659
  const {models} = useModelContext();
1429
1660
  const {me} = useAuthContext();
1430
- const {t} = useTranslation()
1661
+ const {t} = useTranslation();
1431
1662
  const [checked, setChecked] = useState(true);
1432
1663
 
1433
- // Trouver le modèle correspondant à la valeur
1434
- const selectedModel = models.find(m => m.name === value && m._user === me?.username);
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
1435
1692
 
1436
1693
  // Préparer les options pour les champs du modèle
1437
1694
  const fieldOptions = selectedModel?.fields.map(f => ({
1438
- label: t(`field_${f.name}`, f.name),
1695
+ label: t(`field_${selectedModel.name}_${f.name}`, f.name),
1439
1696
  value: f.name
1440
1697
  })) || [];
1441
1698
 
1442
- // Gestion du changement de modèle
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)
1443
1713
  const handleModelChange = (e) => {
1444
- const newModel = e.value;
1445
- const firstField = fieldOptions[0]?.value || null;
1714
+ const newModelName = e.value;
1715
+ const newModel = models.find(m => m.name === newModelName);
1716
+ const firstField = newModel?.fields[0]?.name || null;
1446
1717
 
1447
1718
  if (fields) {
1448
- onChange({name: field?.name, value: { model: newModel, field: firstField }});
1719
+ onChange({name: field?.name, value: { model: newModelName, field: firstField }});
1449
1720
  } else {
1450
- onChange({name: field?.name, value: newModel});
1721
+ onChange({name: field?.name, value: newModelName});
1451
1722
  }
1452
1723
  };
1453
1724
 
1454
1725
  // Gestion du changement de champ
1455
1726
  const handleFieldChange = (e) => {
1456
- onChange({name: field?.name, value: { model: value, field: e.value }});
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
+ }
1457
1733
  };
1458
1734
 
1459
- const dis = disableable ? (
1460
- <CheckboxField
1461
- checked={checked}
1462
- onChange={e => {
1463
- setChecked(e);
1464
- if (!e) {
1465
- onChange({name: field?.name, value: null});
1466
- }
1467
- }}
1468
- />
1469
- ) : null;
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;
1470
1744
 
1471
1745
  if (!fields) {
1472
- return (
1473
- <div className="flex flex-1">
1474
- {dis}
1475
- {checked && (
1476
- <SelectField
1477
- className="flex-1"
1478
- value={value}
1479
- onChange={handleModelChange}
1480
- items={models
1481
- .filter(m => m._user === me?.username)
1482
- .map(m => ({
1483
- label: t(`model_${m.name}`, m.name),
1484
- value: m.name
1485
- }))
1486
- }
1487
- />
1488
- )}
1489
- </div>
1490
- );
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>);
1491
1761
  }
1492
1762
 
1493
- return (
1494
- <div className="flex flex-1">
1495
- {dis}
1496
- {checked && (
1497
- <div className="flex flex-stretch" key={field?.name ?? 'def'}>
1498
- {showModel && (
1499
- <SelectField
1500
- className="flex-1"
1501
- value={value}
1502
- onChange={handleModelChange}
1503
- items={models
1504
- .filter(m => m._user === me?.username)
1505
- .map(m => ({
1506
- label: t(`model_${m.name}`, m.name),
1507
- value: m.name
1508
- }))
1509
- }
1510
- />
1511
- )}
1512
- <SelectField
1513
- className="flex-1"
1514
- value={fieldValue || (fieldOptions[0]?.value || null)}
1515
- onChange={handleFieldChange}
1516
- items={fieldOptions}
1517
- />
1518
- </div>
1519
- )}
1520
- </div>
1521
- );
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>);
1522
1788
  };
1523
1789
 
1524
1790
  // Fonction pour obtenir le composant icône par son nom
@@ -1589,47 +1855,49 @@ export const IconField = ({name, label, value, disabled, onChange, className, ..
1589
1855
  )}
1590
1856
  </div>
1591
1857
  };
1592
- export const ColorField = ({name, label, value, disabled, onChange, className, ...rest}) => {
1593
- // 1. État interne pour une réactivité immédiate de l'interface.
1594
- const [internalValue, setInternalValue] = useState(value);
1595
-
1596
- // 2. On mémoïze le gestionnaire d'événements avec debounce pour éviter de le recréer à chaque rendu.
1597
- const debouncedOnChange = useCallback(
1598
- debounce((newValue) => {
1599
- // On notifie le parent du changement après un court délai.
1600
- onChange?.({ name, value: newValue });
1601
- }, 200), // Un délai de 200ms est confortable pour un sélecteur de couleur.
1602
- [onChange, name] // Dépendances de useCallback
1603
- );
1604
1858
 
1605
- // 3. Effet pour synchroniser l'état interne si la prop `value` du parent change.
1606
- useEffect(() => {
1607
- if (value !== internalValue) {
1608
- setInternalValue(value);
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);
1609
1865
  }
1610
- }, [value]);
1866
+ };
1611
1867
 
1612
- const handleChange = (e) => {
1613
- const newValue = e.target.value;
1614
- // Met à jour l'état interne instantanément pour que l'input soit réactif.
1615
- setInternalValue(newValue);
1616
- // Appelle la fonction "debounced" pour notifier le parent.
1617
- debouncedOnChange(newValue);
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',
1618
1888
  };
1619
1889
 
1620
1890
  return (
1621
- <div className={`flex flex-1 flex-no-wrap ${className || ''}`}>
1622
- {label && (<label className="flex-1">{label}</label>)}
1623
- <div className="flex flex-1 flex-no-wrap"><input
1624
- disabled={disabled}
1625
- type="color"
1626
- // L'input est maintenant contrôlé par notre état interne.
1627
- value={internalValue || '#FFFFFF'}
1628
- onChange={handleChange}
1629
- {...rest}
1630
- />
1631
- <span className="color-value">{internalValue || '#FFFFFF'}</span>
1632
- </div>
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}
1633
1901
  </div>
1634
1902
  );
1635
1903
  };
@@ -1717,7 +1985,7 @@ export const DurationField = forwardRef(({ value, onChange, name, label, help, r
1717
1985
  });
1718
1986
  DurationField.displayName = "DurationField";
1719
1987
 
1720
- export const CodeField = ({name, label, language, value, disabled, onChange}) => {
1988
+ export const CodeField = ({name, label, language, defaultValue, value, disabled, onChange}) => {
1721
1989
  const u = name || uniqid();
1722
1990
  const [currentEditor, setEditor] = useState(null);
1723
1991
 
@@ -1735,12 +2003,13 @@ export const CodeField = ({name, label, language, value, disabled, onChange}) =>
1735
2003
  onChange({name, value: code});
1736
2004
  } catch (e) {
1737
2005
  }
1738
- } else
2006
+ } else {
1739
2007
  onChange({name, value: e});
2008
+ }
1740
2009
  }}
1741
2010
  height="300px"
1742
2011
  /></div> : <div className="code"><SyntaxHighlighter
1743
- language={language || "javascript"} theme={docco}>{value}</SyntaxHighlighter></div>
2012
+ language={language || "javascript"}>{value}</SyntaxHighlighter></div>
1744
2013
  }</>
1745
2014
  }
1746
2015