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,259 +1,355 @@
1
- import React, { useState, useEffect, useRef } from 'react';
2
- import { useQuery, useQueryClient } from 'react-query';
3
- import { 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
-
13
- const RelationField = ({ field, help, onFocus, onBlur, onChange, value = null }) => {
14
- const { models, dataByModel, setOnSuccessCallbacks, relationIds, setRelationIds } = useModelContext();
15
- const { name, relation: modelName } = field;
16
- const queryClient = useQueryClient();
17
- const [selectedValues, setSelectedValues] = useState([]);
18
- const [showResults, setResultsVisible] = useState(false);
19
- const [searchValue, setSearchValue] = useState('');
20
- const [history, setHistory] = useState([]);
21
- const { me } = useAuthContext();
22
- const tr = useTranslation();
23
- const { t, i18n } = tr;
24
- const model = models?.find(f => f.name === modelName && f._user === me?.username);
25
-
26
- // Fetch related data based on search value
27
- const { data: results = [], isError, refetch } = useQuery(
28
- // La clé de la requête inclut maintenant le filtre de relation pour une mise en cache correcte
29
- ['api/search', model?.name, field?.name, searchValue, JSON.stringify(field.relationFilter)],
30
- async ({ signal }) => {
31
- if (!model) return [];
32
-
33
- // --- DÉBUT DE LA NOUVELLE LOGIQUE DE FILTRAGE ---
34
-
35
- // 1. On récupère le filtre permanent défini dans le modèle.
36
- // S'il n'y en a pas, on utilise un objet vide qui n'aura aucun effet.
37
- const permanentFilter = field.relationFilter || {};
38
-
39
- // 2. On construit le filtre basé sur la recherche de l'utilisateur.
40
- const searchFilter = {};
41
- if (searchValue) {
42
- const orConditions = [];
43
- // Recherche sur les champs principaux (asMain)
44
- model.fields.forEach(f => {
45
- if (f.asMain && mainFieldsTypes.includes(f.type)) {
46
- orConditions.push({"$regexMatch": {input: "$" + f.name, regex: searchValue}});
47
- }
48
- });
49
- // Si aucun champ principal, recherche sur les champs texte
50
- if (orConditions.length === 0) {
51
- model.fields.forEach(f => {
52
- if (["string", "string_t", "richtext", "url"].includes(f.type)) {
53
- orConditions.push({"$regexMatch": {input: "$" + f.name, regex: searchValue}});
54
- }
55
- });
56
- }
57
- // Si toujours rien, on cherche sur l'ID (utile pour les développeurs)
58
- if (orConditions.length === 0) {
59
- orConditions.push({ "_id": searchValue });
60
- }
61
- searchFilter['$or'] = orConditions;
62
- }
63
-
64
- // 3. On combine les deux filtres avec un opérateur $and.
65
- // Un document devra correspondre au filtre permanent ET au filtre de recherche.
66
- const finalFilter = {
67
- "$and": [
68
- permanentFilter,
69
- searchFilter
70
- ]
71
- };
72
-
73
- // --- FIN DE LA NOUVELLE LOGIQUE DE FILTRAGE ---
74
-
75
- const params = new URLSearchParams();
76
- params.append('model', field.relation);
77
- params.append('limit', '100'); // Limite raisonnable pour les suggestions
78
- params.append('depth', '2');
79
-
80
- return fetch(`/api/data/search?${params.toString()}`, {
81
- // On envoie le filtre final et complet
82
- body: JSON.stringify({ filter: finalFilter }),
83
- method: 'POST',
84
- signal: signal,
85
- headers: { 'Content-Type': 'application/json' },
86
- })
87
- .then(e => e.json())
88
- .then(e => e.data);
89
- },
90
- { enabled: !!model && showResults } // La requête ne s'exécute que si le panneau de résultats est visible
91
- );
92
-
93
- // ... (le reste du composant reste identique) ...
94
-
95
- useEffect(() => {
96
- setSearchValue('');
97
- setHistory([]);
98
- }, [modelName]);
99
-
100
- useEffect(()=>{
101
- if(searchValue==='' && !value && !field.multiple ){
102
- onChange({ name, value: null });
103
- }
104
- },[searchValue])
105
-
106
- useEffect(() => {
107
- if (results?.length > 0) {
108
- setHistory([...new Set([...history, ...results])]);
109
- }
110
- }, [results]);
111
-
112
- useEffect(() => {
113
- updateValue();
114
- }, [history]);
115
-
116
- useEffect(() => {
117
- if( value)
118
- updateValue();
119
- }, [value]);
120
- useEffect(() => {
121
- if( value === null ){
122
- onChange({name, value: null});
123
- setSearchValue('');
124
- }
125
- }, [value]);
126
-
127
- const updateValue = () => {
128
- if (!field.multiple) {
129
- const v = history.find(d => d._id === value);
130
- if (v) {
131
- setSearchValue(getDataAsString(model, v, tr, models) || '');
132
- onChange({ name, value });
133
- } else {
134
- // Ne rien faire si la valeur n'est pas dans l'historique pour éviter d'effacer
135
- }
136
- } else {
137
- if (Array.isArray(value)) {
138
- setSelectedValues(value);
139
- onChange({ name, value });
140
- } else {
141
- setSelectedValues([]);
142
- onChange({ name, value: [] });
143
- }
144
- }
145
- };
146
-
147
- const handleClick = (e, data) => {
148
- if (!field.multiple) {
149
- setSearchValue(getDataAsString(model, history.find(d => d._id === data._id), tr, models) || '');
150
- onChange({ name, value: data._id });
151
- } else {
152
- if (!selectedValues.includes(data._id)) {
153
- setSelectedValues(values => {
154
- const value = [...values, data._id];
155
- onChange({ name, value });
156
- return value;
157
- });
158
- } else {
159
- onChange({ name, value: selectedValues });
160
- }
161
- }
162
- setResultsVisible(false);
163
- ref.current?.focus();
164
- e.preventDefault();
165
- };
166
-
167
- const handleRemove = (element) => {
168
- if (!field.multiple) return;
169
- setSelectedValues(values => {
170
- const value = values.filter(f => f !== element);
171
- onChange({ name, value });
172
- return value;
173
- });
174
- };
175
-
176
- const ref = useRef();
177
- const inputRef = useRef();
178
-
179
- useEffect(() => {
180
- if( inputRef?.current ){
181
- inputRef.current.ref.setAttribute('autocomplete', 'off');
182
- }
183
- }, [inputRef]);
184
-
185
- return (
186
- <div onFocus={onFocus} onBlur={onBlur} className="field field-relation flex flex-row flex-start flex-1">
187
- {help && <div className={"flex help"}>{help}</div>}
188
-
189
- <div className="inner">
190
- <TextField
191
- required={!field.multiple && field.required}
192
- ref={inputRef}
193
- multiline={field.multiline}
194
- autocomplete="none"
195
- name={field.name}
196
- id={field.name}
197
- onFocus={e => {
198
- setResultsVisible(true);
199
- }}
200
- value={searchValue}
201
- onChange={e => {
202
- if (!e.target.value) onChange({ name, value: null });
203
- setResultsVisible(true);
204
- setSearchValue(e.target.value);
205
- }}
206
- onBlur={(e) => {
207
- setTimeout(() => setResultsVisible(false), 150); // Léger délai pour permettre le clic sur un résultat
208
- }}
209
- />
210
- <Button
211
- type="button" className="btn-form btn-last" onClick={() => {
212
- setSearchValue('');
213
- onChange({ name, value: null });
214
- setResultsVisible(!showResults);
215
- }}><FaEdit /></Button>
216
-
217
- {showResults && (
218
- <div className="results" onKeyDown={e => {
219
- if( e.key === 'Escape' )
220
- setResultsVisible(false);
221
- }} >
222
- {!isError &&
223
- (results || []).map(r => {
224
- const v = getDataAsString(models.find(m => m.name === modelName && m._user === me?.username), r, tr, models);
225
- return (
226
- <div tabIndex={0} onKeyDown={e => {
227
- if( e.key === 'Enter')
228
- handleClick(e, r);
229
- }} onMouseDown={e => handleClick(e, r)} className="item" key={r._id}>
230
- {v}
231
- </div>
232
- );
233
- })}
234
- </div>
235
- )}
236
- </div>
237
- {field.multiple && selectedValues.length > 0 && (
238
- <div ref={ref} tabIndex={0} className="selected-values flex flex-border flex-row flex-no-gap flex-start flex-1">
239
- <Draggable items={selectedValues} renderItem={(id,i) =>{
240
- const val = history.find(f => f._id === id) || dataByModel[modelName]?.find(f => f._id === id);
241
- if (!val) {
242
- return <div className="flex" key={id}>data non chargée<FaTrash onClick={() => handleRemove(id)} /></div>;
243
- }
244
- const v = getDataAsString(models.find(m => m.name === modelName && m._user === me?.username), val, tr, models);
245
- return <div onClick={() => handleRemove(id)} className="selected-value flex" key={id}>
246
- <span className="flex-1">{v}</span>
247
- <FaTrash className="cursor-pointer" />
248
- </div>;
249
- }} onChange={(arr) => {
250
- setSelectedValues(arr);
251
- onChange({ name, value: arr });
252
- }} />
253
- </div>
254
- )}
255
- </div>
256
- );
257
- };
258
-
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
+
259
355
  export default RelationField;