data-primals-engine 1.5.2 → 1.6.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 (70) hide show
  1. package/README.md +938 -915
  2. package/client/README.md +136 -136
  3. package/client/index.js +0 -1
  4. package/client/public/doc/API.postman_collection.json +213 -213
  5. package/client/public/react.svg +9 -9
  6. package/client/src/AddWidgetTypeModal.jsx +47 -47
  7. package/client/src/App.css +42 -42
  8. package/client/src/App.jsx +92 -150
  9. package/client/src/App.scss +6 -0
  10. package/client/src/AssistantChat.jsx +362 -363
  11. package/client/src/Button.jsx +12 -12
  12. package/client/src/Dashboard.jsx +480 -480
  13. package/client/src/DashboardHtmlViewItem.jsx +146 -146
  14. package/client/src/DashboardView.jsx +654 -654
  15. package/client/src/DataEditor.jsx +383 -383
  16. package/client/src/DataLayout.jsx +779 -808
  17. package/client/src/DataTable.jsx +782 -822
  18. package/client/src/DataTable.scss +186 -186
  19. package/client/src/GeolocationField.jsx +93 -93
  20. package/client/src/HistoryDialog.jsx +307 -307
  21. package/client/src/HistoryDialog.scss +319 -319
  22. package/client/src/HtmlViewBuilderModal.jsx +90 -90
  23. package/client/src/HtmlViewBuilderModal.scss +17 -17
  24. package/client/src/HtmlViewCard.jsx +43 -43
  25. package/client/src/ModelCreator.jsx +683 -686
  26. package/client/src/ModelCreator.scss +1 -1
  27. package/client/src/ModelCreatorField.jsx +950 -950
  28. package/client/src/ModelImporter.jsx +3 -0
  29. package/client/src/ModelList.jsx +280 -280
  30. package/client/src/Notification.jsx +136 -136
  31. package/client/src/PackGallery.jsx +391 -391
  32. package/client/src/PackGallery.scss +231 -231
  33. package/client/src/Pagination.jsx +143 -143
  34. package/client/src/RelationField.jsx +354 -354
  35. package/client/src/RelationSelectorWidget.jsx +172 -172
  36. package/client/src/RelationValue.jsx +2 -1
  37. package/client/src/contexts/CommandContext.jsx +260 -0
  38. package/client/src/contexts/ModelContext.jsx +4 -1
  39. package/client/src/contexts/UIContext.jsx +72 -72
  40. package/client/src/filter.js +263 -263
  41. package/client/src/index.css +90 -90
  42. package/package.json +6 -5
  43. package/perf/artillery-hooks.js +37 -37
  44. package/perf/setup.yml +25 -25
  45. package/src/constants.js +4 -0
  46. package/src/defaultModels.js +7 -0
  47. package/src/engine.js +335 -335
  48. package/src/events.js +232 -137
  49. package/src/filter.js +274 -274
  50. package/src/index.js +1 -0
  51. package/src/modules/assistant/assistant.js +225 -83
  52. package/src/modules/auth-google/index.js +50 -50
  53. package/src/modules/auth-microsoft/index.js +81 -81
  54. package/src/modules/data/data.core.js +118 -118
  55. package/src/modules/data/data.history.js +555 -555
  56. package/src/modules/data/data.operations.js +112 -9
  57. package/src/modules/data/data.relations.js +686 -686
  58. package/src/modules/data/data.routes.js +1879 -1879
  59. package/src/modules/data/data.validation.js +2 -2
  60. package/src/modules/file.js +247 -247
  61. package/src/modules/user.js +14 -9
  62. package/src/packs.js +2 -2
  63. package/src/providers.js +2 -2
  64. package/src/services/stripe.js +196 -196
  65. package/src/sso.js +193 -193
  66. package/test/data.history.integration.test.js +264 -264
  67. package/test/data.integration.test.js +1281 -1206
  68. package/test/events.test.js +108 -10
  69. package/test/model.integration.test.js +377 -377
  70. package/test/user.test.js +106 -1
@@ -1,355 +1,355 @@
1
- import React, { useState, useEffect, useRef } from 'react';
2
- import { useQuery, useQueryClient } from 'react-query';
3
- import { ModelProvider, useModelContext } from './contexts/ModelContext.jsx';
4
- import { TextField } from './Field.jsx';
5
- import { useAuthContext } from './contexts/AuthContext.jsx';
6
- import { getDataAsString } from '../../src/data.js';
7
- import { FaEdit, FaTrash } from 'react-icons/fa';
8
- import Button from './Button.jsx';
9
- import { mainFieldsTypes } from "../../src/constants.js";
10
- import Draggable from "./Draggable.jsx";
11
- import { useTranslation } from "react-i18next";
12
- import { Dialog, DialogProvider } from "./Dialog.jsx";
13
- import RelationSelectorWidget from "./RelationSelectorWidget.jsx";
14
-
15
- const RelationField = ({ field, help, onFocus, onBlur, onChange, value = null }) => {
16
- const { models, dataByModel, setOnSuccessCallbacks, relationIds, setRelationIds } = useModelContext();
17
- const { name, relation: modelName } = field;
18
- const queryClient = useQueryClient();
19
- const [selectedValues, setSelectedValues] = useState([]);
20
- const [showResults, setResultsVisible] = useState(false);
21
- const [searchValue, setSearchValue] = useState('');
22
- const [isSelectorOpen, setSelectorOpen] = useState(false);
23
- const [history, setHistory] = useState([]);
24
- const { me } = useAuthContext();
25
- const tr = useTranslation();
26
- const { t, i18n } = tr;
27
- const model = models?.find(f => f.name === modelName && f._user === me?.username);
28
-
29
- const { isLoading: isLoadingInitialData } = useQuery(
30
- // Clé de requête unique pour les données initiales de cette relation
31
- ['initialRelationData', modelName, Array.isArray(value) ? value.join(',') : value],
32
- async () => {
33
- // Détermine les IDs qu'il faut réellement aller chercher
34
- const idsToFetch = (Array.isArray(value) ? value : (value ? [value] : []))
35
- .filter(id =>
36
- id && // S'assurer que l'ID n'est pas nul/undefined
37
- !history.some(h => h._id === id) &&
38
- !dataByModel[modelName]?.some(d => d._id === id)
39
- );
40
-
41
- if (!model || idsToFetch.length === 0) {
42
- return []; // Rien à charger
43
- }
44
-
45
- const params = new URLSearchParams();
46
- params.append('model', model.name);
47
- params.append('ids', idsToFetch.join(','));
48
- params.append('depth', '1'); // On a besoin des données complètes
49
-
50
- const response = await fetch(`/api/data/search?${params.toString()}`, {
51
- method: 'POST',
52
- headers: { 'Content-Type': 'application/json' },
53
- body: JSON.stringify({ filter: {} }) // Le filtre peut être vide quand on cherche par IDs
54
- });
55
-
56
- if (!response.ok) throw new Error('Network response was not ok for initial relation data.');
57
- const result = await response.json();
58
- return result.data || [];
59
- },
60
- {
61
- enabled: !!model && !!value && (Array.isArray(value) ? value.length > 0 : true),
62
- onSuccess: (fetchedData) => {
63
- if (fetchedData?.length > 0) {
64
- // Ajoute les données fraîchement récupérées à l'historique local pour l'affichage
65
- setHistory(current => [...current, ...fetchedData.filter(fd => !current.some(c => c._id === fd._id))]);
66
- }
67
- }
68
- }
69
- );
70
- // Fetch related data based on search value
71
- const { data: results = [], isError, refetch } = useQuery(
72
- // La clé de la requête inclut maintenant le filtre de relation pour une mise en cache correcte
73
- ['api/search', model?.name, field?.name, searchValue, JSON.stringify(field.relationFilter)],
74
- async ({ signal }) => {
75
- if (!model) return [];
76
-
77
- // --- DÉBUT DE LA NOUVELLE LOGIQUE DE FILTRAGE ---
78
-
79
- // 1. On récupère le filtre permanent défini dans le modèle.
80
- // S'il n'y en a pas, on utilise un objet vide qui n'aura aucun effet.
81
- const permanentFilter = field.relationFilter || {};
82
-
83
- // 2. On construit le filtre basé sur la recherche de l'utilisateur.
84
- const searchFilter = {};
85
- if (searchValue) {
86
- const orConditions = [];
87
- // Recherche sur les champs principaux (asMain)
88
- model.fields.forEach(f => {
89
- if (f.asMain && mainFieldsTypes.includes(f.type)) {
90
- orConditions.push({"$regexMatch": {input: "$" + f.name, regex: searchValue}});
91
- }
92
- });
93
- // Si aucun champ principal, recherche sur les champs texte
94
- if (orConditions.length === 0) {
95
- model.fields.forEach(f => {
96
- if (["string", "string_t", "richtext", "url"].includes(f.type)) {
97
- orConditions.push({"$regexMatch": {input: "$" + f.name, regex: searchValue}});
98
- }
99
- });
100
- }
101
- // Si toujours rien, on cherche sur l'ID (utile pour les développeurs)
102
- if (orConditions.length === 0) {
103
- orConditions.push({ "_id": searchValue });
104
- }
105
- searchFilter['$or'] = orConditions;
106
- }
107
-
108
- // 3. On combine les deux filtres avec un opérateur $and.
109
- // Un document devra correspondre au filtre permanent ET au filtre de recherche.
110
- const finalFilter = {
111
- "$and": [
112
- permanentFilter,
113
- searchFilter
114
- ]
115
- };
116
-
117
- // --- FIN DE LA NOUVELLE LOGIQUE DE FILTRAGE ---
118
-
119
- const params = new URLSearchParams();
120
- params.append('model', field.relation);
121
- params.append('limit', '100'); // Limite raisonnable pour les suggestions
122
- params.append('depth', '2');
123
-
124
- return fetch(`/api/data/search?${params.toString()}`, {
125
- // On envoie le filtre final et complet
126
- body: JSON.stringify({ filter: finalFilter }),
127
- method: 'POST',
128
- signal: signal,
129
- headers: { 'Content-Type': 'application/json' },
130
- })
131
- .then(e => e.json())
132
- .then(e => e.data);
133
- },
134
- { enabled: !!model && showResults } // La requête ne s'exécute que si le panneau de résultats est visible
135
- );
136
-
137
- // ... (le reste du composant reste identique) ...
138
-
139
- useEffect(() => {
140
- setSearchValue('');
141
- setHistory([]);
142
- }, [modelName]);
143
-
144
- useEffect(()=>{
145
- if(searchValue==='' && !value && !field.multiple ){
146
- onChange({ name, value: null });
147
- }
148
- },[searchValue])
149
-
150
- useEffect(() => {
151
- // Add new results to history, avoiding duplicates.
152
- if (results?.length > 0)
153
- setHistory(currentHistory => {
154
- const newItems = results.filter(
155
- resultItem => !currentHistory.some(historyItem => historyItem._id === resultItem._id)
156
- );
157
- if (newItems.length > 0) return [...currentHistory, ...newItems];
158
- return currentHistory;
159
- });
160
- }, [results]);
161
-
162
- useEffect(() => {
163
- updateValue();
164
- }, [history]);
165
-
166
- useEffect(() => {
167
- if( value)
168
- updateValue();
169
- }, [value]);
170
- useEffect(() => {
171
- if( value === null ){
172
- onChange({name, value: null});
173
- setSearchValue('');
174
- }
175
- }, [value]);
176
-
177
- const updateValue = () => {
178
- if (!field.multiple) {
179
- const v = history.find(d => d._id === value) || dataByModel[modelName]?.find(d => d._id === value);
180
- if (v) {
181
- setSearchValue(getDataAsString(model, v, tr, models) || '');
182
- onChange({ name, value });
183
- } else {
184
- // Ne rien faire si la valeur n'est pas dans l'historique pour éviter d'effacer
185
- }
186
- } else {
187
- if (Array.isArray(value)) {
188
- setSelectedValues(value);
189
- onChange({ name, value });
190
- } else {
191
- setSelectedValues([]);
192
- onChange({ name, value: [] });
193
- }
194
- }
195
- };
196
-
197
- const handleClick = (e, data) => {
198
- if (!field.multiple) {
199
- setSearchValue(getDataAsString(model, history.find(d => d._id === data._id), tr, models) || '');
200
- onChange({ name, value: data._id });
201
- } else {
202
- if (!selectedValues.includes(data._id)) {
203
- setSelectedValues(values => {
204
- const value = [...values, data._id];
205
- onChange({ name, value });
206
- return value;
207
- });
208
- } else {
209
- onChange({ name, value: selectedValues });
210
- }
211
- }
212
- setResultsVisible(false);
213
- ref.current?.focus();
214
- e.preventDefault();
215
- };
216
-
217
- const handleRemove = (element) => {
218
- if (!field.multiple) return;
219
- setSelectedValues(values => {
220
- const value = values.filter(f => f !== element);
221
- onChange({ name, value });
222
- return value;
223
- });
224
- };
225
-
226
- const handleValidateSelection = (selectedItems) => {
227
- // Add the newly selected full objects to our local history
228
- // so we can display them immediately.
229
- setHistory(currentHistory => {
230
- const newItems = selectedItems.filter(
231
- selectedItem => !currentHistory.some(historyItem => historyItem._id === selectedItem._id)
232
- );
233
- if (newItems.length > 0) return [...currentHistory, ...newItems];
234
- return currentHistory;
235
- });
236
-
237
- if (field.multiple) {
238
- const selectedIds = selectedItems.map(item => item._id);
239
- onChange({ name, value: selectedIds });
240
- setSelectedValues(selectedIds);
241
- } else {
242
- const selectedItem = selectedItems[0] || null;
243
- onChange({ name, value: selectedItem?._id || null });
244
- if (selectedItem) {
245
- setSearchValue(getDataAsString(model, selectedItem, tr, models) || '');
246
- } else {
247
- setSearchValue('');
248
- }
249
- }
250
- setSelectorOpen(false);
251
- };
252
-
253
- const ref = useRef();
254
- const inputRef = useRef();
255
-
256
- useEffect(() => {
257
- if( inputRef?.current ){
258
- inputRef.current.ref.setAttribute('autocomplete', 'off');
259
- }
260
- }, [inputRef]);
261
-
262
- return (
263
- <div onFocus={onFocus} onBlur={onBlur} className="field field-relation flex flex-row flex-start flex-1">
264
- {help && <div className={"flex help"}>{help}</div>}
265
-
266
- <div className="inner">
267
- <TextField
268
- required={!field.multiple && field.required}
269
- ref={inputRef}
270
- multiline={field.multiline}
271
- autocomplete="none"
272
- name={field.name}
273
- id={field.name}
274
- onFocus={e => {
275
- setResultsVisible(true);
276
- }}
277
- value={searchValue}
278
- onChange={e => {
279
- if (!e.target.value) onChange({ name, value: null });
280
- setResultsVisible(true);
281
- setSearchValue(e.target.value);
282
- }}
283
- onBlur={(e) => {
284
- setTimeout(() => setResultsVisible(false), 150); // Léger délai pour permettre le clic sur un résultat
285
- }}
286
- />
287
- <Button
288
- type="button" className="btn-form btn-last" onClick={() => setSelectorOpen(true)}><FaEdit /></Button>
289
-
290
- {showResults && (
291
- <div className="results" onKeyDown={e => {
292
- if( e.key === 'Escape' )
293
- setResultsVisible(false);
294
- }} >
295
- {!isError &&
296
- (results || []).map(r => {
297
- const v = getDataAsString(models.find(m => m.name === modelName && m._user === me?.username), r, tr, models);
298
- return (
299
- <div tabIndex={0} onKeyDown={e => {
300
- if( e.key === 'Enter')
301
- handleClick(e, r);
302
- }} onMouseDown={e => handleClick(e, r)} className="item" key={r._id}>
303
- {v}
304
- </div>
305
- );
306
- })}
307
- </div>
308
- )}
309
- <DialogProvider>
310
- {isSelectorOpen && (
311
- <Dialog
312
- title={t('relationField.select.title', 'Sélectionner {{modelName}}', { modelName: model?.name || modelName })}
313
- onClose={() => setSelectorOpen(false)}
314
- isModal={true}
315
- className="relation-selector-dialog"
316
- >
317
- <ModelProvider>
318
- <RelationSelectorWidget
319
- modelName={field.relation}
320
- initialSelection={field.multiple ? selectedValues : (value ? [value] : [])}
321
- isMultiple={field.multiple}
322
- onValidate={handleValidateSelection}
323
- onCancel={() => setSelectorOpen(false)}
324
- />
325
- </ModelProvider>
326
- </Dialog>
327
- )}
328
- </DialogProvider>
329
- </div>
330
- {field.multiple && selectedValues.length > 0 && (
331
- <div ref={ref} tabIndex={0} className="selected-values flex flex-border flex-row flex-no-gap flex-start flex-1">
332
- <Draggable items={selectedValues} renderItem={(id,i) =>{
333
- const val = history.find(f => f._id === id) || dataByModel[modelName]?.find(f => f._id === id);
334
- if (isLoadingInitialData && !val) {
335
- return <div className="flex selected-value-loading" key={id}>...</div>;
336
- }
337
- if (!val) {
338
- return <div className="flex" key={id}>data non chargée<FaTrash onClick={() => handleRemove(id)} /></div>;
339
- }
340
- const v = getDataAsString(models.find(m => m.name === modelName && m._user === me?.username), val, tr, models);
341
- return <div onClick={() => handleRemove(id)} className="selected-value flex" key={id}>
342
- <span className="flex-1">{v}</span>
343
- <FaTrash className="cursor-pointer" />
344
- </div>;
345
- }} onChange={(arr) => {
346
- setSelectedValues(arr);
347
- onChange({ name, value: arr });
348
- }} />
349
- </div>
350
- )}
351
- </div>
352
- );
353
- };
354
-
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { useQuery, useQueryClient } from 'react-query';
3
+ import { ModelProvider, useModelContext } from './contexts/ModelContext.jsx';
4
+ import { TextField } from './Field.jsx';
5
+ import { useAuthContext } from './contexts/AuthContext.jsx';
6
+ import { getDataAsString } from '../../src/data.js';
7
+ import { FaEdit, FaTrash } from 'react-icons/fa';
8
+ import Button from './Button.jsx';
9
+ import { mainFieldsTypes } from "../../src/constants.js";
10
+ import Draggable from "./Draggable.jsx";
11
+ import { useTranslation } from "react-i18next";
12
+ import { Dialog, DialogProvider } from "./Dialog.jsx";
13
+ import RelationSelectorWidget from "./RelationSelectorWidget.jsx";
14
+
15
+ const RelationField = ({ field, help, onFocus, onBlur, onChange, value = null }) => {
16
+ const { models, dataByModel, setOnSuccessCallbacks, relationIds, setRelationIds } = useModelContext();
17
+ const { name, relation: modelName } = field;
18
+ const queryClient = useQueryClient();
19
+ const [selectedValues, setSelectedValues] = useState([]);
20
+ const [showResults, setResultsVisible] = useState(false);
21
+ const [searchValue, setSearchValue] = useState('');
22
+ const [isSelectorOpen, setSelectorOpen] = useState(false);
23
+ const [history, setHistory] = useState([]);
24
+ const { me } = useAuthContext();
25
+ const tr = useTranslation();
26
+ const { t, i18n } = tr;
27
+ const model = models?.find(f => f.name === modelName && f._user === me?.username);
28
+
29
+ const { isLoading: isLoadingInitialData } = useQuery(
30
+ // Clé de requête unique pour les données initiales de cette relation
31
+ ['initialRelationData', modelName, Array.isArray(value) ? value.join(',') : value],
32
+ async () => {
33
+ // Détermine les IDs qu'il faut réellement aller chercher
34
+ const idsToFetch = (Array.isArray(value) ? value : (value ? [value] : []))
35
+ .filter(id =>
36
+ id && // S'assurer que l'ID n'est pas nul/undefined
37
+ !history.some(h => h._id === id) &&
38
+ !dataByModel[modelName]?.some(d => d._id === id)
39
+ );
40
+
41
+ if (!model || idsToFetch.length === 0) {
42
+ return []; // Rien à charger
43
+ }
44
+
45
+ const params = new URLSearchParams();
46
+ params.append('model', model.name);
47
+ params.append('ids', idsToFetch.join(','));
48
+ params.append('depth', '1'); // On a besoin des données complètes
49
+
50
+ const response = await fetch(`/api/data/search?${params.toString()}`, {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify({ filter: {} }) // Le filtre peut être vide quand on cherche par IDs
54
+ });
55
+
56
+ if (!response.ok) throw new Error('Network response was not ok for initial relation data.');
57
+ const result = await response.json();
58
+ return result.data || [];
59
+ },
60
+ {
61
+ enabled: !!model && !!value && (Array.isArray(value) ? value.length > 0 : true),
62
+ onSuccess: (fetchedData) => {
63
+ if (fetchedData?.length > 0) {
64
+ // Ajoute les données fraîchement récupérées à l'historique local pour l'affichage
65
+ setHistory(current => [...current, ...fetchedData.filter(fd => !current.some(c => c._id === fd._id))]);
66
+ }
67
+ }
68
+ }
69
+ );
70
+ // Fetch related data based on search value
71
+ const { data: results = [], isError, refetch } = useQuery(
72
+ // La clé de la requête inclut maintenant le filtre de relation pour une mise en cache correcte
73
+ ['api/search', model?.name, field?.name, searchValue, JSON.stringify(field.relationFilter)],
74
+ async ({ signal }) => {
75
+ if (!model) return [];
76
+
77
+ // --- DÉBUT DE LA NOUVELLE LOGIQUE DE FILTRAGE ---
78
+
79
+ // 1. On récupère le filtre permanent défini dans le modèle.
80
+ // S'il n'y en a pas, on utilise un objet vide qui n'aura aucun effet.
81
+ const permanentFilter = field.relationFilter || {};
82
+
83
+ // 2. On construit le filtre basé sur la recherche de l'utilisateur.
84
+ const searchFilter = {};
85
+ if (searchValue) {
86
+ const orConditions = [];
87
+ // Recherche sur les champs principaux (asMain)
88
+ model.fields.forEach(f => {
89
+ if (f.asMain && mainFieldsTypes.includes(f.type)) {
90
+ orConditions.push({"$regexMatch": {input: "$" + f.name, regex: searchValue}});
91
+ }
92
+ });
93
+ // Si aucun champ principal, recherche sur les champs texte
94
+ if (orConditions.length === 0) {
95
+ model.fields.forEach(f => {
96
+ if (["string", "string_t", "richtext", "url"].includes(f.type)) {
97
+ orConditions.push({"$regexMatch": {input: "$" + f.name, regex: searchValue}});
98
+ }
99
+ });
100
+ }
101
+ // Si toujours rien, on cherche sur l'ID (utile pour les développeurs)
102
+ if (orConditions.length === 0) {
103
+ orConditions.push({ "_id": searchValue });
104
+ }
105
+ searchFilter['$or'] = orConditions;
106
+ }
107
+
108
+ // 3. On combine les deux filtres avec un opérateur $and.
109
+ // Un document devra correspondre au filtre permanent ET au filtre de recherche.
110
+ const finalFilter = {
111
+ "$and": [
112
+ permanentFilter,
113
+ searchFilter
114
+ ]
115
+ };
116
+
117
+ // --- FIN DE LA NOUVELLE LOGIQUE DE FILTRAGE ---
118
+
119
+ const params = new URLSearchParams();
120
+ params.append('model', field.relation);
121
+ params.append('limit', '100'); // Limite raisonnable pour les suggestions
122
+ params.append('depth', '2');
123
+
124
+ return fetch(`/api/data/search?${params.toString()}`, {
125
+ // On envoie le filtre final et complet
126
+ body: JSON.stringify({ filter: finalFilter }),
127
+ method: 'POST',
128
+ signal: signal,
129
+ headers: { 'Content-Type': 'application/json' },
130
+ })
131
+ .then(e => e.json())
132
+ .then(e => e.data);
133
+ },
134
+ { enabled: !!model && showResults } // La requête ne s'exécute que si le panneau de résultats est visible
135
+ );
136
+
137
+ // ... (le reste du composant reste identique) ...
138
+
139
+ useEffect(() => {
140
+ setSearchValue('');
141
+ setHistory([]);
142
+ }, [modelName]);
143
+
144
+ useEffect(()=>{
145
+ if(searchValue==='' && !value && !field.multiple ){
146
+ onChange({ name, value: null });
147
+ }
148
+ },[searchValue])
149
+
150
+ useEffect(() => {
151
+ // Add new results to history, avoiding duplicates.
152
+ if (results?.length > 0)
153
+ setHistory(currentHistory => {
154
+ const newItems = results.filter(
155
+ resultItem => !currentHistory.some(historyItem => historyItem._id === resultItem._id)
156
+ );
157
+ if (newItems.length > 0) return [...currentHistory, ...newItems];
158
+ return currentHistory;
159
+ });
160
+ }, [results]);
161
+
162
+ useEffect(() => {
163
+ updateValue();
164
+ }, [history]);
165
+
166
+ useEffect(() => {
167
+ if( value)
168
+ updateValue();
169
+ }, [value]);
170
+ useEffect(() => {
171
+ if( value === null ){
172
+ onChange({name, value: null});
173
+ setSearchValue('');
174
+ }
175
+ }, [value]);
176
+
177
+ const updateValue = () => {
178
+ if (!field.multiple) {
179
+ const v = history.find(d => d._id === value) || dataByModel[modelName]?.find(d => d._id === value);
180
+ if (v) {
181
+ setSearchValue(getDataAsString(model, v, tr, models) || '');
182
+ onChange({ name, value });
183
+ } else {
184
+ // Ne rien faire si la valeur n'est pas dans l'historique pour éviter d'effacer
185
+ }
186
+ } else {
187
+ if (Array.isArray(value)) {
188
+ setSelectedValues(value);
189
+ onChange({ name, value });
190
+ } else {
191
+ setSelectedValues([]);
192
+ onChange({ name, value: [] });
193
+ }
194
+ }
195
+ };
196
+
197
+ const handleClick = (e, data) => {
198
+ if (!field.multiple) {
199
+ setSearchValue(getDataAsString(model, history.find(d => d._id === data._id), tr, models) || '');
200
+ onChange({ name, value: data._id });
201
+ } else {
202
+ if (!selectedValues.includes(data._id)) {
203
+ setSelectedValues(values => {
204
+ const value = [...values, data._id];
205
+ onChange({ name, value });
206
+ return value;
207
+ });
208
+ } else {
209
+ onChange({ name, value: selectedValues });
210
+ }
211
+ }
212
+ setResultsVisible(false);
213
+ ref.current?.focus();
214
+ e.preventDefault();
215
+ };
216
+
217
+ const handleRemove = (element) => {
218
+ if (!field.multiple) return;
219
+ setSelectedValues(values => {
220
+ const value = values.filter(f => f !== element);
221
+ onChange({ name, value });
222
+ return value;
223
+ });
224
+ };
225
+
226
+ const handleValidateSelection = (selectedItems) => {
227
+ // Add the newly selected full objects to our local history
228
+ // so we can display them immediately.
229
+ setHistory(currentHistory => {
230
+ const newItems = selectedItems.filter(
231
+ selectedItem => !currentHistory.some(historyItem => historyItem._id === selectedItem._id)
232
+ );
233
+ if (newItems.length > 0) return [...currentHistory, ...newItems];
234
+ return currentHistory;
235
+ });
236
+
237
+ if (field.multiple) {
238
+ const selectedIds = selectedItems.map(item => item._id);
239
+ onChange({ name, value: selectedIds });
240
+ setSelectedValues(selectedIds);
241
+ } else {
242
+ const selectedItem = selectedItems[0] || null;
243
+ onChange({ name, value: selectedItem?._id || null });
244
+ if (selectedItem) {
245
+ setSearchValue(getDataAsString(model, selectedItem, tr, models) || '');
246
+ } else {
247
+ setSearchValue('');
248
+ }
249
+ }
250
+ setSelectorOpen(false);
251
+ };
252
+
253
+ const ref = useRef();
254
+ const inputRef = useRef();
255
+
256
+ useEffect(() => {
257
+ if( inputRef?.current ){
258
+ inputRef.current.ref.setAttribute('autocomplete', 'off');
259
+ }
260
+ }, [inputRef]);
261
+
262
+ return (
263
+ <div onFocus={onFocus} onBlur={onBlur} className="field field-relation flex flex-row flex-start flex-1">
264
+ {help && <div className={"flex help"}>{help}</div>}
265
+
266
+ <div className="inner">
267
+ <TextField
268
+ required={!field.multiple && field.required}
269
+ ref={inputRef}
270
+ multiline={field.multiline}
271
+ autocomplete="none"
272
+ name={field.name}
273
+ id={field.name}
274
+ onFocus={e => {
275
+ setResultsVisible(true);
276
+ }}
277
+ value={searchValue}
278
+ onChange={e => {
279
+ if (!e.target.value) onChange({ name, value: null });
280
+ setResultsVisible(true);
281
+ setSearchValue(e.target.value);
282
+ }}
283
+ onBlur={(e) => {
284
+ setTimeout(() => setResultsVisible(false), 150); // Léger délai pour permettre le clic sur un résultat
285
+ }}
286
+ />
287
+ <Button
288
+ type="button" className="btn-form btn-last" onClick={() => setSelectorOpen(true)}><FaEdit /></Button>
289
+
290
+ {showResults && (
291
+ <div className="results" onKeyDown={e => {
292
+ if( e.key === 'Escape' )
293
+ setResultsVisible(false);
294
+ }} >
295
+ {!isError &&
296
+ (results || []).map(r => {
297
+ const v = getDataAsString(models.find(m => m.name === modelName && m._user === me?.username), r, tr, models);
298
+ return (
299
+ <div tabIndex={0} onKeyDown={e => {
300
+ if( e.key === 'Enter')
301
+ handleClick(e, r);
302
+ }} onMouseDown={e => handleClick(e, r)} className="item" key={r._id}>
303
+ {v}
304
+ </div>
305
+ );
306
+ })}
307
+ </div>
308
+ )}
309
+ <DialogProvider>
310
+ {isSelectorOpen && (
311
+ <Dialog
312
+ title={t('relationField.select.title', 'Sélectionner {{modelName}}', { modelName: model?.name || modelName })}
313
+ onClose={() => setSelectorOpen(false)}
314
+ isModal={true}
315
+ className="relation-selector-dialog"
316
+ >
317
+ <ModelProvider>
318
+ <RelationSelectorWidget
319
+ modelName={field.relation}
320
+ initialSelection={field.multiple ? selectedValues : (value ? [value] : [])}
321
+ isMultiple={field.multiple}
322
+ onValidate={handleValidateSelection}
323
+ onCancel={() => setSelectorOpen(false)}
324
+ />
325
+ </ModelProvider>
326
+ </Dialog>
327
+ )}
328
+ </DialogProvider>
329
+ </div>
330
+ {field.multiple && selectedValues.length > 0 && (
331
+ <div ref={ref} tabIndex={0} className="selected-values flex flex-border flex-row flex-no-gap flex-start flex-1">
332
+ <Draggable items={selectedValues} renderItem={(id,i) =>{
333
+ const val = history.find(f => f._id === id) || dataByModel[modelName]?.find(f => f._id === id);
334
+ if (isLoadingInitialData && !val) {
335
+ return <div className="flex selected-value-loading" key={id}>...</div>;
336
+ }
337
+ if (!val) {
338
+ return <div className="flex" key={id}>data non chargée<FaTrash onClick={() => handleRemove(id)} /></div>;
339
+ }
340
+ const v = getDataAsString(models.find(m => m.name === modelName && m._user === me?.username), val, tr, models);
341
+ return <div onClick={() => handleRemove(id)} className="selected-value flex" key={id}>
342
+ <span className="flex-1">{v}</span>
343
+ <FaTrash className="cursor-pointer" />
344
+ </div>;
345
+ }} onChange={(arr) => {
346
+ setSelectedValues(arr);
347
+ onChange({ name, value: arr });
348
+ }} />
349
+ </div>
350
+ )}
351
+ </div>
352
+ );
353
+ };
354
+
355
355
  export default RelationField;