data-primals-engine 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +37 -0
  2. package/client/src/AddWidgetTypeModal.jsx +47 -43
  3. package/client/src/App.jsx +2 -6
  4. package/client/src/App.scss +13 -1
  5. package/client/src/AssistantChat.jsx +363 -323
  6. package/client/src/AssistantChat.scss +27 -10
  7. package/client/src/Dashboard.jsx +480 -396
  8. package/client/src/Dashboard.scss +1 -1
  9. package/client/src/DashboardHtmlViewItem.jsx +147 -0
  10. package/client/src/DashboardView.jsx +654 -569
  11. package/client/src/DataEditor.jsx +10 -3
  12. package/client/src/DataLayout.jsx +807 -755
  13. package/client/src/DataLayout.scss +14 -0
  14. package/client/src/DataTable.jsx +39 -75
  15. package/client/src/Dialog.scss +1 -1
  16. package/client/src/Field.jsx +2057 -1825
  17. package/client/src/FlexViewCard.jsx +44 -0
  18. package/client/src/HistoryDialog.jsx +69 -14
  19. package/client/src/HtmlViewBuilderModal.jsx +91 -0
  20. package/client/src/HtmlViewBuilderModal.scss +18 -0
  21. package/client/src/HtmlViewCard.jsx +44 -0
  22. package/client/src/HtmlViewCard.scss +35 -0
  23. package/client/src/KanbanCard.jsx +1 -2
  24. package/client/src/ModelCreator.jsx +5 -4
  25. package/client/src/ModelCreatorField.jsx +51 -4
  26. package/client/src/ModelList.jsx +280 -236
  27. package/client/src/Notification.jsx +136 -136
  28. package/client/src/Notification.scss +0 -18
  29. package/client/src/Pagination.jsx +5 -3
  30. package/client/src/RelationField.jsx +354 -258
  31. package/client/src/RelationSelectorWidget.jsx +173 -0
  32. package/client/src/contexts/ModelContext.jsx +10 -1
  33. package/client/src/contexts/UIContext.jsx +72 -63
  34. package/client/src/filter.js +263 -212
  35. package/client/src/hooks/useValidation.js +75 -0
  36. package/client/src/translations.js +24 -24
  37. package/package.json +7 -6
  38. package/src/constants.js +1 -1
  39. package/src/core.js +8 -1
  40. package/src/defaultModels.js +1596 -1544
  41. package/src/engine.js +85 -43
  42. package/src/events.js +137 -113
  43. package/src/i18n.js +710 -10
  44. package/src/index.js +3 -0
  45. package/src/modules/assistant/assistant.js +253 -134
  46. package/src/modules/assistant/constants.js +2 -1
  47. package/src/modules/bucket.js +2 -1
  48. package/src/modules/data/data.core.js +118 -92
  49. package/src/modules/data/data.history.js +555 -492
  50. package/src/modules/data/data.js +3 -53
  51. package/src/modules/data/data.operations.js +3381 -3231
  52. package/src/modules/data/data.relations.js +686 -686
  53. package/src/modules/data/data.routes.js +1879 -1821
  54. package/src/modules/data/data.validation.js +81 -2
  55. package/src/modules/file.js +247 -238
  56. package/src/modules/user.js +1 -0
  57. package/src/modules/workflow.js +2 -2
  58. package/src/openai.jobs.js +3 -2
  59. package/src/packs.js +5482 -5478
  60. package/src/sso.js +2 -2
  61. package/src/workers/import-export-worker.js +1 -1
  62. package/test/data.history.integration.test.js +264 -192
  63. package/test/data.integration.test.js +149 -3
@@ -1,756 +1,808 @@
1
- import React, {forwardRef, useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react';
2
-
3
- import "./App.scss";
4
- import {useMutation, useQuery, useQueryClient} from "react-query";
5
- import ModelCreator from "./ModelCreator.jsx";
6
- import {useModelContext} from "./contexts/ModelContext.jsx";
7
- import {Dialog, DialogProvider} from "./Dialog.jsx";
8
- import {Pagination} from "./Pagination.jsx";
9
- import {Event} from "../../src/events.js";
10
-
11
- import {
12
- FaEye,
13
- FaFilter, FaInfo,
14
- } from "react-icons/fa";
15
- import {getDefaultForType, getUserHash, getUserId} from "../../src/data.js";
16
- import {Trans, useTranslation} from "react-i18next";
17
-
18
- import {getObjectHash} from "../../src/core.js";
19
- import Button from "./Button.jsx";
20
- import {useAuthContext} from "./contexts/AuthContext.jsx";
21
- import APIInfo from "./APIInfo.jsx";
22
- import {useNotificationContext} from "./NotificationProvider.jsx";
23
- import {ModelImporter} from "./ModelImporter.jsx";
24
- import {DataTable} from "./DataTable.jsx";
25
- import {ModelList} from "./ModelList.jsx";
26
- import {DataEditor} from "./DataEditor.jsx";
27
- import TourSpotlight from "./TourSpotlight.jsx";
28
- import useLocalStorage from "./hooks/useLocalStorage.js";
29
- import {useUI} from "./contexts/UIContext.jsx";
30
- import {useTutorials} from "./hooks/useTutorials.jsx";
31
- import ViewSwitcher from "./ViewSwitcher.jsx";
32
- import KanbanConfigModal from "./KanbanConfigModal.jsx";
33
- import CalendarConfigModal from "./CalendarConfigModal.jsx";
34
- import KanbanView from "./KanbanView.jsx";
35
- import CalendarView from "./CalendarView.jsx";
36
- import {useLocation, useParams, useSearchParams} from "react-router-dom";
37
- import { useNavigate } from "react-router-dom";
38
-
39
-
40
- const NotConfiguredPlaceholder = ({ type, onConfigure }) => (
41
- <div className="p-4 border border-dashed rounded-md mt-4 text-center bg-gray-50">
42
- <h4><Trans i18nKey="dataview.notConfiguredTitle" values={{ type }}>Vue {{type}} non configurée</Trans></h4>
43
- <p className="text-sm text-gray-600"><Trans i18nKey="dataview.notConfiguredText">Veuillez configurer cette vue pour l'utiliser.</Trans></p>
44
- <Button onClick={onConfigure} className="mt-2">
45
- <Trans i18nKey="dataview.configureButton" values={{ type }}>Configurer {{type}}</Trans>
46
- </Button>
47
- </div>
48
- );
49
-
50
- function DataLayout({refreshUI}) {
51
- const [ searchParams, setSearchParams ] = useSearchParams();
52
- const [viewSettings, setViewSettings] = useLocalStorage('viewSettings', {});
53
-
54
- const [isCalendarModalOpen, setCalendarModalOpen] = useState(false);
55
- const [isKanbanModalOpen, setKanbanModalOpen] = useState(false);
56
-
57
- const { triggerTutorialCheck } = useTutorials();
58
- const { t, i18n } = useTranslation();
59
- const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
60
-
61
- // Stocke la vue sélectionnée pour chaque modèle. Ex: { "contacts": "calendar", "tasks": "kanban" }
62
- const [viewsByModel, setViewsByModel] = useLocalStorage('dataLayout_viewsByModel', {});
63
-
64
- const [filterValues, setFilterValues] = useState({});
65
- const { dataByModel,paginatedDataByModel,
66
- setRelationFilters, setSelectedModel, selectedModel,
67
- setFilteredDatasToLoad,
68
- setRelationIds,
69
- setOnSuccessCallbacks,
70
- datasToLoad,
71
- setDatasToLoad,page, setPage, countByModel, relationIds,
72
- pagedFilters, pagedSort,
73
- elementsPerPage,
74
- setRelations,
75
- generatedModels,
76
- models
77
- } = useModelContext(); // Utilisez le contexte
78
- const queryClient = useQueryClient();
79
-
80
- const isDataLoaded = true;
81
- const [checkedItems, setCheckedItems] = useState([])
82
-
83
- const [refreshTime, setRefreshTime] = useState(0);
84
- const [formData, setFormData] = useState({});
85
- const [recordToEdit, setRecordToEdit] = useState(null); // New state for record to edit
86
-
87
- const mainPartRef = useRef();
88
- const modelCreatorRef = useRef();
89
-
90
- const [importModalVisible, setImportModalVisible] = useState(false);
91
- const [editionMode, setEditionMode] = useState(false);
92
- const [showDataEditor, setDataEditorVisible] = useState(false);
93
- const [showAPIInfo, setAPIInfoVisible] = useState(false);
94
- const nav = useNavigate();
95
- const mod = searchParams.get('model');
96
- const loc = useLocation();
97
- useEffect(() =>{
98
- if (selectedModel?.name) {
99
- nav('/user/' + getUserHash(me) + '/?model=' + selectedModel?.name);
100
- }
101
- }, [selectedModel?.name])
102
-
103
-
104
- useEffect(() => {
105
- setSelectedModel(null)
106
- setDataEditorVisible(false);
107
- setAPIInfoVisible(false);
108
- setEditionMode(true);
109
- }, []);
110
-
111
- useEffect(() =>{
112
- const m = models.find(f => f.name === mod);
113
- setSelectedModel(m);
114
- setEditionMode(!m);
115
- }, [mod, models, searchParams])
116
-
117
-
118
- // La vue courante est dérivée du modèle sélectionné et des préférences stockées.
119
- const currentView = useMemo(() => {
120
- if (!selectedModel) return 'table';
121
- return viewsByModel[selectedModel.name] || 'table';
122
- }, [selectedModel, viewsByModel]);
123
-
124
- // Met à jour la vue pour le modèle actuellement sélectionné.
125
- const setCurrentView = (viewName) => {
126
- if (!selectedModel) return;
127
- setViewsByModel(prev => ({
128
- ...prev,
129
- [selectedModel.name]: viewName,
130
- }));
131
- };
132
-
133
- // --- MODIFICATION : Logique de changement de vue mise à jour ---
134
- const handleSwitchView = (viewName) => {
135
- if (viewName === 'table') {
136
- setCurrentView('table');
137
- return;
138
- }
139
-
140
- if (!selectedModel) {
141
- addNotification({ title: t('datalayout.selectModelFirst', 'Veuillez d\'abord sélectionner un modèle.'), status: 'warning' });
142
- return;
143
- }
144
-
145
- const modelSettings = viewSettings[selectedModel.name] || {};
146
-
147
- if (viewName === 'calendar') {
148
- if (modelSettings.calendar?.titleField && modelSettings.calendar?.startField && modelSettings.calendar?.endField) {
149
- setCurrentView('calendar');
150
- } else {
151
- setCalendarModalOpen(true);
152
- }
153
- } else if (viewName === 'kanban') {
154
- if (modelSettings.kanban?.groupByField) {
155
- setCurrentView('kanban');
156
- } else {
157
- setKanbanModalOpen(true);
158
- }
159
- }
160
- };
161
-
162
- // --- MODIFICATION : Sauvegarde dans localStorage ---
163
- const handleSaveCalendarConfig = (config) => {
164
- if (!selectedModel) return;
165
- const isConfigValid = config && config.titleField && config.startField && config.endField;
166
-
167
- if (isConfigValid) {
168
- setViewSettings(prev => ({
169
- ...prev,
170
- [selectedModel.name]: {
171
- ...(prev[selectedModel.name] || {}),
172
- calendar: config,
173
- },
174
- }));
175
- setCalendarModalOpen(false);
176
- setCurrentView('calendar');
177
- } else {
178
- // Si la configuration n'est pas valide, afficher une notification et garder la modale ouverte
179
- addNotification({
180
- title: t('datalayout.invalidConfigTitle', 'Configuration invalide'),
181
- message: t('datalayout.invalidConfigMessage', 'Veuillez vous assurer que les champs pour le titre, la date de début et la date de fin sont tous sélectionnés.'),
182
- status: 'error'
183
- });
184
- }
185
- };
186
-
187
- const handleSaveKanbanConfig = (config) => {
188
- if (!selectedModel) return;
189
- setViewSettings(prev => ({
190
- ...prev,
191
- [selectedModel.name]: {
192
- ...(prev[selectedModel.name] || {}),
193
- kanban: config,
194
- },
195
- }));
196
- setKanbanModalOpen(false);
197
- setCurrentView('kanban');
198
- };
199
-
200
- // --- MODIFICATION : Dérive les settings du modèle courant depuis l'at global ---
201
- const currentModelViewSettings = useMemo(() => {
202
- if (!selectedModel) return {};
203
- return viewSettings[selectedModel.name] || {};
204
- }, [viewSettings, selectedModel]);
205
-
206
- // --- MODIFICATION : Vérifie si les vues sont configurées pour le modèle courant ---
207
- const configuredViews = useMemo(() => {
208
- if (!selectedModel) return { calendar: false, kanban: false };
209
- const modelSettings = viewSettings[selectedModel.name] || {};
210
- return {
211
- calendar: !!modelSettings.calendar?.titleField && !!modelSettings.calendar?.startField && !!modelSettings.calendar?.endField,
212
- kanban: !!modelSettings.kanban?.groupByField,
213
- };
214
- }, [viewSettings, selectedModel]);
215
-
216
- // --- AJOUT : Logique de rendu de la vue courante ---
217
- const renderCurrentView = () => {
218
- if (!selectedModel) return null;
219
-
220
- switch (currentView) {
221
- case 'calendar':
222
- return configuredViews.calendar
223
- ? <CalendarView settings={currentModelViewSettings.calendar} onEditData={(model, data) => handleAddData(model,data)} model={selectedModel} />
224
- : <NotConfiguredPlaceholder type="calendar" onConfigure={() => setCalendarModalOpen(true)} />;
225
- case 'kanban':
226
- return configuredViews.kanban
227
- ? <KanbanView settings={currentModelViewSettings.kanban} model={selectedModel} />
228
- : <NotConfiguredPlaceholder type="kanban" onConfigure={() => setKanbanModalOpen(true)} />;
229
- case 'table':
230
- default:
231
- // Le DataTable existant est retourné par défaut
232
- return <DataTable
233
- checkedItems={checkedItems}
234
- setCheckedItems={setCheckedItems}
235
- filterValues={filterValues}
236
- setFilterValues={setFilterValues}
237
- model={selectedModel}
238
- onAddData={(model) => {
239
- mainPartRef.current.scrollIntoView({behavior: "smooth"});
240
- handleAddData(model);
241
- }}
242
- onDuplicateData={(data) => {
243
- mainPartRef.current.scrollIntoView({behavior: "smooth"});
244
- handleAddData(selectedModel, data);
245
- }}
246
- onShowAPI={() => {
247
- setAPIInfoVisible(true);
248
- setDataEditorVisible(false);
249
- setEditionMode(false);
250
- }}
251
- onEdit={(item) => {
252
- mainPartRef.current.scrollIntoView({behavior: "smooth"});
253
- setRecordToEdit(item);
254
- setFormData(item);
255
- setDataEditorVisible(true);
256
- }}
257
- onDelete={(item) => {
258
- queryClient.invalidateQueries(['api/data', selectedModel.name, 'page', page, elementsPerPage, pagedFilters[selectedModel.name], pagedSort[selectedModel.name]]);
259
- }}
260
- />;
261
- }
262
- };
263
-
264
- const handleModelSelect = (model) => {
265
- setRelationFilters({});
266
- setCheckedItems([])
267
- setFilterValues({});
268
- if (!model) {
269
- setSelectedModel(null);
270
- return;
271
- }
272
-
273
- // Maintient la vue actuelle si elle est configurée pour le nouveau modèle, sinon revient à la vue "table"
274
- const modelSettings = viewSettings[model.name] || {};
275
- if (currentView === 'calendar' && (!modelSettings.calendar?.titleField || !modelSettings.calendar?.startField || !modelSettings.calendar?.endField)) {
276
- setCurrentView('table');
277
- } else if (currentView === 'kanban' && !modelSettings.kanban?.groupByField) {
278
- setCurrentView('table');
279
- }
280
- const dt = [];
281
- const t = [...model.fields].reduce((acc, field, index) => {
282
- if (field.type === "relation") {
283
- dt.push(field.relation);
284
- acc[field.name] = dataByModel[field.relation]?.length > 0 ? dataByModel[field.relation][0]._id : null;
285
- } else {
286
- acc[field.name] = getDefaultForType(field);
287
- }
288
- return acc;
289
- }, {});
290
- setPage(1);
291
- setFormData(t);
292
- setFilteredDatasToLoad([model.name]);
293
-
294
- setRecordToEdit(null); // Clear record to edit when model changes
295
-
296
- let tl = [];
297
- model.fields.forEach(field => {
298
- if (field.type === 'relation') {
299
- if (!tl.includes(field.relation))
300
- tl.push(field.relation);
301
- }
302
- });
303
- setDatasToLoad(tl);
304
-
305
- const cb = ({data}) => {
306
-
307
- updateRelationIds(model, data);
308
-
309
- if( datasToLoad.length === 0 ) {
310
- model.fields.forEach(field => {
311
- if (field.type === 'relation') {
312
- if (!tl.includes(field.relation))
313
- tl.push(field.relation);
314
- }
315
- });
316
- setDatasToLoad(tl);
317
- }
318
- };
319
- // add new data
320
- setOnSuccessCallbacks(cbs => {
321
- const c = {...cbs};
322
- if( !c['api/data/' + model.name + '/paged'])
323
- c['api/data/' + model.name + '/paged'] = {};
324
- c['api/data/' + model.name + '/paged'][model.name] = cb;
325
- return c;
326
- })
327
- setSelectedModel(model);
328
-
329
- gtag("event", "select_content", {
330
- content_type: "model",
331
- content_id: model.name
332
- });
333
- //queryClient.invalidateQueries(['api/data', model.name, 'page', page]);
334
- }
335
-
336
- const { me } = useAuthContext();
337
-
338
- const { addNotification } = useNotificationContext();
339
-
340
- const { mutate: insertOrUpdateMutation, isLoading} = useMutation(({formData,record}) => {
341
- const method = record ? 'PUT' : 'POST'; // Determine method based on record
342
- const url = record ? `/api/data/${record._id}` : `/api/data`; // Determine URL
343
-
344
- try {
345
- const fd = new FormData();
346
-
347
- let obj = {};
348
- for (const key in formData) {
349
- if (formData[key] !== undefined)
350
- obj[key] = formData[key];
351
- }
352
- fd.append("_data", JSON.stringify({...obj, _hash: undefined, _id: undefined}));
353
-
354
- Array.from(document.querySelectorAll('.field-file input[data-field]')).forEach(input =>{
355
- const fieldName = input.dataset['field'];
356
- if (input.files.length > 0) {
357
- Array.from(input.files).forEach((file, index) => {
358
- if( file)
359
- fd.append(`${fieldName}[${index}]`, file);
360
- });
361
- }
362
- });
363
- fd.append('model', selectedModel.name);
364
-
365
- ///fd.append("files", fd2);
366
- return fetch(`${url}?lang=${lang}&_user=${encodeURIComponent(getUserId(me))}`, {
367
- method,
368
- body: fd
369
- }).then(e => e.json());
370
-
371
- } catch (error) {
372
- console.error('Erreur lors de l\'enregistrement des données:', error);
373
- // Handle error, e.g., display error message to the user
374
- }
375
- }, {
376
- onError: (err)=>{
377
- const notificationData = {
378
- title: 'Erreur lors de l\'enregistrement des données',
379
- status: 'error'
380
- };
381
- addNotification(notificationData);
382
- },
383
- onSuccess: async (data) => {
384
-
385
- console.log('Données enregistrées:', data, selectedModel);
386
-
387
- await Event.Trigger(recordToEdit ? 'API_ADD_DATA' : 'API_ADD_DATA', "custom", "data", {
388
- model: selectedModel.name,
389
- });
390
-
391
- gtag("event", "select_content", {
392
- content_type: "edit_data",
393
- content_id: selectedModel.name
394
- });
395
-
396
- const notificationData = {
397
- title: data.success ? t('dataimporter.success', 'Données enregistrées') : t(data.error, data.error),
398
- icon: data.success ? <FaInfo /> : undefined,
399
- status: data.success ? 'completed': 'error'
400
- };
401
- addNotification(notificationData);
402
-
403
- updateRelationIds(selectedModel, formData);
404
- setDatasToLoad([...datasToLoad, selectedModel.name]);
405
- queryClient.invalidateQueries(['api/data', selectedModel.name, 'page', page, elementsPerPage, pagedFilters[selectedModel.name], pagedSort[selectedModel.name]]);
406
-
407
- if(data.inserted) {
408
- const t = [...selectedModel.fields].reduce((acc, field, index) => {
409
- if (field.type === "relation") {
410
- acc[field.name] = field.multiple ? [] : null;
411
- } else {
412
- acc[field.name] = getDefaultForType(field);
413
- }
414
- return acc;
415
- }, {});
416
- setFormData(t)
417
- }
418
-
419
- setRelations({});
420
-
421
- }})
422
- const handleFormSubmit = async (formData, record) => { // Add record parameter
423
- insertOrUpdateMutation({formData, record})
424
- };
425
-
426
- const updateRelationIds = (model, data) => {
427
- const r = {...relationIds};
428
- let datas = !Array.isArray(data) ? [data] : data;
429
- model.fields.forEach(field => {
430
- if( field.type !== "relation")
431
- return;
432
- datas.forEach(d => {
433
- const value = d[field.name];
434
- if (field.multiple && Array.isArray(value)) {
435
- for (let i = 0; i < value.length; i++) {
436
- if (!r[field.relation]){
437
- r[field.relation] = [];
438
- }
439
- if (!r[field.relation].includes(value[i])) {
440
- r[field.relation].push(value[i]);
441
- }
442
- }
443
- } else if (!field.multiple && value) {
444
- if (!r[field.relation]){
445
- r[field.relation] = [];
446
- }
447
- if (!r[field.relation].includes(value)) {
448
- r[field.relation].push(value);
449
- }
450
- }
451
- });
452
- });
453
- setRelationIds(r);
454
- queryClient.invalidateQueries(['api/data', model.name, r]);
455
- }
456
-
457
- const deleteMutation = useMutation((selectedModels) => {
458
- return fetch('/api/data/'+checkedItems.map(m => m._id).join(',')+'?lang='+lang+'&_user='+encodeURIComponent(getUserId(me)), {
459
- method: 'DELETE', headers: {
460
- 'Content-Type': 'application/json'
461
- }
462
- }).then(e => e.json());
463
- }, { onSuccess: (data) => {
464
- if( data.success ){
465
- queryClient.invalidateQueries(['api/data', selectedModel?.name, 'page', page, elementsPerPage, pagedFilters[selectedModel?.name], pagedSort[selectedModel?.name]]);
466
- }
467
-
468
- const notificationData = {
469
- id: 'dataimporter.success',
470
- title: t('dataimporter.success', 'Données supprimées'),
471
- icon: <FaInfo />,
472
- status: 'completed'
473
- };
474
- addNotification(notificationData);
475
- }, onError:(err)=>{
476
- const notificationData = {
477
- id: 'dataimporter.error',
478
- title: err.message,
479
- status: 'error'
480
- };
481
- addNotification(notificationData);
482
- }});
483
- const handleDeletion = () => {
484
- deleteMutation.mutate();
485
- setCheckedItems([]);
486
- }
487
- const importModelsMutation = useMutation((selectedModels) => {
488
- return fetch('/api/models/import', { method: 'POST', headers: {
489
- 'Content-Type': 'application/json'
490
- },
491
- body: JSON.stringify({ models: selectedModels.map(m => m.name) })
492
- })
493
- });
494
-
495
- const handleConfigureCurrentView = () => {
496
- if (!selectedModel) return;
497
-
498
- switch (currentView) {
499
- case 'kanban':
500
- setKanbanModalOpen(true);
501
- break;
502
- case 'calendar':
503
- setCalendarModalOpen(true);
504
- break;
505
- default:
506
- // Pas de configuration pour la vue 'table'
507
- break;
508
- }
509
- };
510
-
511
- const handleAddData = (model, data)=>{
512
-
513
- if (!selectedModel || model.name !== selectedModel.name) {
514
- handleModelSelect(model);
515
- }
516
-
517
- if( data ){
518
- setFormData(data);
519
- }else {
520
- const t = model ? [...model.fields].reduce((acc, field) => {
521
- if (field.type === "relation") {
522
- acc[field.name] = field.multiple ? [] : null;
523
- } else {
524
- acc[field.name] = getDefaultForType(field);
525
- }
526
- return acc;
527
- }, {}) : [];
528
- setFormData(t)
529
- }
530
- setEditionMode(false);
531
- setDataEditorVisible(true);
532
- setAPIInfoVisible(false);
533
- gtag("event", "select_content", {
534
- content_type: "select_model",
535
- content_id: model.name
536
- });
537
- }
538
-
539
- const onImportModels = (models) => {
540
- importModelsMutation.mutateAsync(models).then(e => {
541
- queryClient.invalidateQueries('api/models');
542
- });
543
- setImportModalVisible(false);
544
- }
545
-
546
- useEffect(() => {
547
- if( showDataEditor && mainPartRef.current ){
548
-
549
- }
550
- }, [showDataEditor,mainPartRef.current]);
551
-
552
-
553
- const [currentProfile, setCurrentProfile] = useLocalStorage('profile', null);
554
- const {isTourOpen, setIsTourOpen, currentTourSteps, allTourSteps, setTourStepIndex, setCurrentTourSteps, currentTour,setCurrentTour, addLaunchedTour} = useUI();
555
-
556
- const startTour = () => {
557
- setIsTourOpen(true);
558
- };
559
-
560
- const closeTour = (completedTourName) => {
561
- // On génère le nom du tour de démo pour le comparer
562
- // This function is called when ANY tour is closed.
563
- // It needs to persist the fact that the tour has been seen.
564
-
565
- // 1. Add the tour's unique name to the list of launched tours.
566
- // This list is managed by the UI context and stored in localStorage.
567
- if (addLaunchedTour) { // Check if the function exists to be safe
568
- addLaunchedTour(completedTourName);
569
- }
570
-
571
- // 2. Close the tour's UI.
572
- setIsTourOpen(false);
573
-
574
- // 3. Specific logic for the very first "demo" tour:
575
- // We also set the user's profile to mark that they are no longer a brand new user.
576
- // This prevents the demo tour from trying to launch on every page load.
577
- const demoTourName = `tour_${getObjectHash({steps: allTourSteps.demo || []})}`;
578
-
579
- // Si le tour qui vient de se terminer est le tour de démo
580
- if (completedTourName === demoTourName) {
581
-
582
- setIsTourOpen(false);
583
- setCurrentProfile({ lastSeen: new Date().toISOString() });
584
- }
585
- };
586
-
587
- useEffect(() => {
588
- if( !currentProfile ){
589
- setIsTourOpen(true);
590
- }
591
- }, [currentProfile])
592
-
593
- useEffect(() => {
594
- // Si un modèle est sélectionné...
595
- if (selectedModel) {
596
- // Récupérer les étapes du tour "datapacks"
597
- const datapacksSteps = allTourSteps.guides;
598
- // S'assurer que ce tour existe et a des étapes
599
- if (datapacksSteps && datapacksSteps.length > 0) {
600
- // 1. Définir les étapes du tour qui doit être affiché
601
- setCurrentTourSteps(datapacksSteps);
602
- // 2. Calculer le nom unique du tour et le définir comme tour courant
603
- // C'est l'clé qui manquait.
604
- const tourName = `tour_${getObjectHash({ steps: datapacksSteps })}`;
605
- setCurrentTour(tourName);
606
- // 3. Activer l'affichage du composant de tour
607
- setIsTourOpen(true);
608
- }
609
- }
610
- }, [selectedModel, allTourSteps]); // Ajout de allTourSteps aux dépendances pour la bonne pratique
611
-
612
- return (
613
- <>
614
- <>{/^demo[0-9]{1,2}$/.test(me.username) && (
615
- <TourSpotlight
616
- name={"tour_"+getObjectHash({steps:currentTourSteps})}
617
- steps={currentTourSteps}
618
- isOpen={isTourOpen}
619
- onClose={closeTour}
620
- />)}</>
621
- <div className="datalayout flex flex-start">
622
- <ModelList tourSteps={currentTourSteps}
623
- onAPIInfo={(model) => {
624
- setAPIInfoVisible(true);
625
- setDataEditorVisible(false);
626
- setEditionMode(false);
627
- setSelectedModel(model);
628
- gtag("event", "select_content", {
629
- content_type: "api",
630
- content_id: model.name
631
- });
632
- }} onImportModel={() => {
633
- setImportModalVisible(true);
634
- }} onCreateModel={() => {
635
- setSelectedModel(null);
636
- setAPIInfoVisible(false);
637
- setDataEditorVisible(false);
638
- setEditionMode(true);
639
- mainPartRef.current.scrollIntoView({behavior: 'smooth'});
640
- gtag("event", "select_content", {
641
- content_type: "create_model",
642
- });
643
- }} onEditModel={(model) => {
644
- handleModelSelect(model);
645
- setDataEditorVisible(false);
646
- setAPIInfoVisible(false);
647
- setEditionMode(true);
648
- mainPartRef.current.scrollIntoView({behavior: 'smooth'});
649
- gtag("event", "select_content", {
650
- content_type: "edit_model",
651
- content_id: model.name
652
- });
653
- }} onModelSelect={(model) => {
654
- handleModelSelect(model);
655
- setDataEditorVisible(false);
656
- setEditionMode(false);
657
- setAPIInfoVisible(false);
658
- mainPartRef.current.scrollIntoView({behavior: 'smooth'});
659
- gtag("event", "select_content", {
660
- content_type: "select_model",
661
- content_id: model.name
662
- });
663
- }} onNewData={(model) => {
664
- mainPartRef.current.scrollIntoView({behavior: 'smooth'});
665
- handleAddData(model);
666
- }}/>
667
- {(editionMode) && (
668
- <ModelCreator ref={modelCreatorRef} onModelGenerated={() =>{
669
- modelCreatorRef.current.scrollIntoView({behavior: 'smooth'});
670
- }} initialModel={selectedModel} onModelSaved={(model) => {
671
- setRefreshTime(new Date().getTime());
672
- handleModelSelect(model);
673
- }}/>)}
674
-
675
- <div className="hidden-anchor" ref={mainPartRef}></div>
676
-
677
- {showDataEditor && (<DataEditor
678
- key={selectedModel?.name}
679
- isLoading={isLoading}
680
- model={selectedModel}
681
- formData={formData}
682
- setFormData={setFormData}
683
- refreshTime={refreshTime}
684
- onSubmit={handleFormSubmit}
685
- setRecord={setRecordToEdit}
686
- record={recordToEdit} // Pass record to edit to DataEditor
687
- />)}
688
-
689
-
690
- {selectedModel && showAPIInfo && <APIInfo/>}
691
- {selectedModel && !showAPIInfo && !generatedModels.some(g => g.name === selectedModel?.name) && (<div className="datas">
692
-
693
- {<ViewSwitcher
694
- currentView={currentView}
695
- onViewChange={handleSwitchView}
696
- configuredViews={configuredViews}
697
- onConfigureView={handleConfigureCurrentView}
698
- />}
699
- <h2>{t(`model_${selectedModel?.name}`, selectedModel?.name)} <>({countByModel?.[selectedModel?.name]})</></h2>
700
-
701
-
702
- {renderCurrentView()}
703
-
704
- {isDataLoaded && currentView === 'table' && (<>
705
- {selectedModel && (<Pagination showElementsPerPage={true} onChange={page => {
706
- setPage(page);
707
- setCheckedItems([]);
708
- gtag("event", "select_content", {
709
- content_type: "change_page",
710
- content_id: page
711
- });
712
- queryClient.invalidateQueries(['api/data', selectedModel.name, 'page', page, pagedFilters[selectedModel.name], pagedSort[selectedModel.name]]);
713
- }} page={page} setPage={setPage} totalCount={countByModel[selectedModel.name]}
714
- hasPreviousNext={true} visibleItemsCount={5}
715
- elementsPerPage={elementsPerPage}/>)}
716
- <div className="actions flex">
717
- <Button onClick={() => {
718
- setCheckedItems(paginatedDataByModel[selectedModel.name]);
719
- }}><Trans i18nKey={"datatable.selectAll"}>Tout sélectionner</Trans></Button>
720
- <Button onClick={handleDeletion} disabled={!checkedItems?.length}><Trans
721
- i18nKey={"datatable.deleteSelection"}>Supprimer la sélection</Trans></Button>
722
- </div>
723
- </>)}
724
- </div>)}
725
- </div>
726
-
727
- <DialogProvider>
728
- {importModalVisible && (<Dialog onClose={() => {
729
- setImportModalVisible(false)
730
- }} isModal={true} isClosable={true}>
731
- <ModelImporter onImport={onImportModels}/>
732
- </Dialog>)}
733
-
734
- <CalendarConfigModal
735
- isOpen={isCalendarModalOpen}
736
- onClose={() => setCalendarModalOpen(false)}
737
- onSave={handleSaveCalendarConfig}
738
- modelFields={selectedModel?.fields ||[]}
739
- initialSettings={currentModelViewSettings.calendar}
740
- />
741
-
742
- <KanbanConfigModal
743
- isOpen={isKanbanModalOpen}
744
- onClose={() => setKanbanModalOpen(false)}
745
- onSave={handleSaveKanbanConfig}
746
- model={selectedModel}
747
- modelFields={selectedModel?.fields||[]}
748
- initialSettings={currentModelViewSettings.kanban}
749
- />
750
- </DialogProvider>
751
- </>
752
- );
753
- }
754
-
755
-
1
+ import React, {forwardRef, useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react';
2
+
3
+ import "./App.scss";
4
+ import {useMutation, useQuery, useQueryClient} from "react-query";
5
+ import ModelCreator from "./ModelCreator.jsx";
6
+ import {useModelContext} from "./contexts/ModelContext.jsx";
7
+ import {Dialog, DialogProvider} from "./Dialog.jsx";
8
+ import {Pagination} from "./Pagination.jsx";
9
+ import {Event} from "../../src/events.js";
10
+
11
+ import {
12
+ FaBook,
13
+ FaBoxOpen, FaDatabase,
14
+ FaEye, FaFileImport,
15
+ FaFilter, FaInfo, FaPlus,
16
+ } from "react-icons/fa";
17
+ import {getDefaultForType, getUserHash, getUserId} from "../../src/data.js";
18
+ import {Trans, useTranslation} from "react-i18next";
19
+
20
+ import {getObjectHash} from "../../src/core.js";
21
+ import Button from "./Button.jsx";
22
+ import {useAuthContext} from "./contexts/AuthContext.jsx";
23
+ import APIInfo from "./APIInfo.jsx";
24
+ import {useNotificationContext} from "./NotificationProvider.jsx";
25
+ import {ModelImporter} from "./ModelImporter.jsx";
26
+ import {DataTable} from "./DataTable.jsx";
27
+ import {ModelList} from "./ModelList.jsx";
28
+ import {DataEditor} from "./DataEditor.jsx";
29
+ import TourSpotlight from "./TourSpotlight.jsx";
30
+ import useLocalStorage from "./hooks/useLocalStorage.js";
31
+ import {useUI} from "./contexts/UIContext.jsx";
32
+ import {useTutorials} from "./hooks/useTutorials.jsx";
33
+ import ViewSwitcher from "./ViewSwitcher.jsx";
34
+ import KanbanConfigModal from "./KanbanConfigModal.jsx";
35
+ import CalendarConfigModal from "./CalendarConfigModal.jsx";
36
+ import KanbanView from "./KanbanView.jsx";
37
+ import CalendarView from "./CalendarView.jsx";
38
+ import {useLocation, useParams, useSearchParams} from "react-router-dom";
39
+ import { useNavigate } from "react-router-dom";
40
+ import {Tooltip} from "react-tooltip";
41
+ import PackGallery from "./PackGallery.jsx";
42
+ import TutorialsMenu from "./TutorialsMenu.jsx";
43
+ import {FaBookAtlas} from "react-icons/fa6";
44
+ import {AssistantChat, NotificationList} from "../index.js";
45
+
46
+ import "./DataLayout.scss"
47
+
48
+ const NotConfiguredPlaceholder = ({ type, onConfigure }) => (
49
+ <div className="p-4 border border-dashed rounded-md mt-4 text-center bg-gray-50">
50
+ <h4><Trans i18nKey="dataview.notConfiguredTitle" values={{ type }}>Vue {{type}} non configurée</Trans></h4>
51
+ <p className="text-sm text-gray-600"><Trans i18nKey="dataview.notConfiguredText">Veuillez configurer cette vue pour l'utiliser.</Trans></p>
52
+ <Button onClick={onConfigure} className="mt-2">
53
+ <Trans i18nKey="dataview.configureButton" values={{ type }}>Configurer {{type}}</Trans>
54
+ </Button>
55
+ </div>
56
+ );
57
+
58
+ function DataLayout({refreshUI}) {
59
+ const [ searchParams, setSearchParams ] = useSearchParams();
60
+ const [viewSettings, setViewSettings] = useLocalStorage('viewSettings', {});
61
+
62
+ const [isCalendarModalOpen, setCalendarModalOpen] = useState(false);
63
+ const [isKanbanModalOpen, setKanbanModalOpen] = useState(false);
64
+ const [showPackGallery, setShowPackGallery] = useState(false);
65
+
66
+ const { triggerTutorialCheck } = useTutorials();
67
+ const { t, i18n } = useTranslation();
68
+ const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
69
+
70
+ // Stocke la vue sélectionnée pour chaque modèle. Ex: { "contacts": "calendar", "tasks": "kanban" }
71
+ const [viewsByModel, setViewsByModel] = useLocalStorage('dataLayout_viewsByModel', {});
72
+
73
+ const [filterValues, setFilterValues] = useState({});
74
+ const { dataByModel,paginatedDataByModel,
75
+ setRelationFilters, setSelectedModel, selectedModel,
76
+ setFilteredDatasToLoad,
77
+ setRelationIds,
78
+ setOnSuccessCallbacks,
79
+ datasToLoad,
80
+ setDatasToLoad,page, setPage, countByModel, relationIds,
81
+ pagedFilters, pagedSort,
82
+ elementsPerPage,
83
+ setRelations,
84
+ generatedModels,
85
+ models
86
+ } = useModelContext(); // Utilisez le contexte
87
+ const queryClient = useQueryClient();
88
+
89
+ const isDataLoaded = true;
90
+ const [checkedItems, setCheckedItems] = useState([])
91
+
92
+ const [refreshTime, setRefreshTime] = useState(0);
93
+ const [formData, setFormData] = useState({});
94
+ const [recordToEdit, setRecordToEdit] = useState(null); // New state for record to edit
95
+
96
+ const mainPartRef = useRef();
97
+ const modelCreatorRef = useRef();
98
+
99
+ const [tutorialDialogVisible, setTutorialDialogVisible] = useState(false);
100
+ const [importModalVisible, setImportModalVisible] = useState(false);
101
+ const [editionMode, setEditionMode] = useState(false);
102
+ const [showDataEditor, setDataEditorVisible] = useState(false);
103
+ const [showAPIInfo, setAPIInfoVisible] = useState(false);
104
+ const nav = useNavigate();
105
+ const mod = searchParams.get('model');
106
+ const loc = useLocation();
107
+ useEffect(() =>{
108
+ if (selectedModel?.name) {
109
+ nav('/user/' + getUserHash(me) + '/?model=' + selectedModel?.name);
110
+ }
111
+ }, [selectedModel?.name])
112
+
113
+
114
+ useEffect(() => {
115
+ setSelectedModel(null)
116
+ setDataEditorVisible(false);
117
+ setAPIInfoVisible(false);
118
+ setEditionMode(true);
119
+ }, []);
120
+
121
+ useEffect(() =>{
122
+ const m = models.find(f => f.name === mod);
123
+ setSelectedModel(m);
124
+ setEditionMode(!m);
125
+ }, [mod, models, searchParams])
126
+
127
+
128
+ // La vue courante est dérivée du modèle sélectionné et des préférences stockées.
129
+ const currentView = useMemo(() => {
130
+ if (!selectedModel) return 'table';
131
+ return viewsByModel[selectedModel.name] || 'table';
132
+ }, [selectedModel, viewsByModel]);
133
+
134
+ // Met à jour la vue pour le modèle actuellement sélectionné.
135
+ const setCurrentView = (viewName) => {
136
+ if (!selectedModel) return;
137
+ setViewsByModel(prev => ({
138
+ ...prev,
139
+ [selectedModel.name]: viewName,
140
+ }));
141
+ };
142
+
143
+ // --- MODIFICATION : Logique de changement de vue mise à jour ---
144
+ const handleSwitchView = (viewName) => {
145
+ if (viewName === 'table') {
146
+ setCurrentView('table');
147
+ return;
148
+ }
149
+
150
+ if (!selectedModel) {
151
+ addNotification({ title: t('datalayout.selectModelFirst', 'Veuillez d\'abord sélectionner un modèle.'), status: 'warning' });
152
+ return;
153
+ }
154
+
155
+ const modelSettings = viewSettings[selectedModel.name] || {};
156
+
157
+ if (viewName === 'calendar') {
158
+ if (modelSettings.calendar?.titleField && modelSettings.calendar?.startField && modelSettings.calendar?.endField) {
159
+ setCurrentView('calendar');
160
+ } else {
161
+ setCalendarModalOpen(true);
162
+ }
163
+ } else if (viewName === 'kanban') {
164
+ if (modelSettings.kanban?.groupByField) {
165
+ setCurrentView('kanban');
166
+ } else {
167
+ setKanbanModalOpen(true);
168
+ }
169
+ }
170
+ };
171
+
172
+ // --- MODIFICATION : Sauvegarde dans localStorage ---
173
+ const handleSaveCalendarConfig = (config) => {
174
+ if (!selectedModel) return;
175
+ const isConfigValid = config && config.titleField && config.startField && config.endField;
176
+
177
+ if (isConfigValid) {
178
+ setViewSettings(prev => ({
179
+ ...prev,
180
+ [selectedModel.name]: {
181
+ ...(prev[selectedModel.name] || {}),
182
+ calendar: config,
183
+ },
184
+ }));
185
+ setCalendarModalOpen(false);
186
+ setCurrentView('calendar');
187
+ } else {
188
+ // Si la configuration n'est pas valide, afficher une notification et garder la modale ouverte
189
+ addNotification({
190
+ title: t('datalayout.invalidConfigTitle', 'Configuration invalide'),
191
+ message: t('datalayout.invalidConfigMessage', 'Veuillez vous assurer que les champs pour le titre, la date de début et la date de fin sont tous sélectionnés.'),
192
+ status: 'error'
193
+ });
194
+ }
195
+ };
196
+
197
+ const handleSaveKanbanConfig = (config) => {
198
+ if (!selectedModel) return;
199
+ setViewSettings(prev => ({
200
+ ...prev,
201
+ [selectedModel.name]: {
202
+ ...(prev[selectedModel.name] || {}),
203
+ kanban: config,
204
+ },
205
+ }));
206
+ setKanbanModalOpen(false);
207
+ setCurrentView('kanban');
208
+ };
209
+
210
+ // --- MODIFICATION : Dérive les settings du modèle courant depuis l'at global ---
211
+ const currentModelViewSettings = useMemo(() => {
212
+ if (!selectedModel) return {};
213
+ return viewSettings[selectedModel.name] || {};
214
+ }, [viewSettings, selectedModel]);
215
+
216
+ // --- MODIFICATION : Vérifie si les vues sont configurées pour le modèle courant ---
217
+ const configuredViews = useMemo(() => {
218
+ if (!selectedModel) return { calendar: false, kanban: false };
219
+ const modelSettings = viewSettings[selectedModel.name] || {};
220
+ return {
221
+ calendar: !!modelSettings.calendar?.titleField && !!modelSettings.calendar?.startField && !!modelSettings.calendar?.endField,
222
+ kanban: !!modelSettings.kanban?.groupByField,
223
+ };
224
+ }, [viewSettings, selectedModel]);
225
+
226
+ // --- AJOUT : Logique de rendu de la vue courante ---
227
+ const renderCurrentView = () => {
228
+ if (!selectedModel) return null;
229
+
230
+ switch (currentView) {
231
+ case 'calendar':
232
+ return configuredViews.calendar
233
+ ? <CalendarView settings={currentModelViewSettings.calendar} onEditData={(model, data) => handleAddData(model,data)} model={selectedModel} />
234
+ : <NotConfiguredPlaceholder type="calendar" onConfigure={() => setCalendarModalOpen(true)} />;
235
+ case 'kanban':
236
+ return configuredViews.kanban
237
+ ? <KanbanView settings={currentModelViewSettings.kanban} model={selectedModel} />
238
+ : <NotConfiguredPlaceholder type="kanban" onConfigure={() => setKanbanModalOpen(true)} />;
239
+ case 'table':
240
+ default:
241
+ // Le DataTable existant est retourné par défaut
242
+ return <DataTable
243
+ checkedItems={checkedItems}
244
+ setCheckedItems={setCheckedItems}
245
+ filterValues={filterValues}
246
+ setFilterValues={setFilterValues}
247
+ model={selectedModel}
248
+ onAddData={(model) => {
249
+ mainPartRef.current.scrollIntoView({behavior: "smooth"});
250
+ handleAddData(model);
251
+ }}
252
+ onDuplicateData={(data) => {
253
+ mainPartRef.current.scrollIntoView({behavior: "smooth"});
254
+ handleAddData(selectedModel, data);
255
+ }}
256
+ onEdit={(item) => {
257
+ mainPartRef.current.scrollIntoView({behavior: "smooth"});
258
+ setRecordToEdit(item);
259
+ setFormData(item);
260
+ setDataEditorVisible(true);
261
+ }}
262
+ onDelete={(item) => {
263
+ queryClient.invalidateQueries(['api/data', selectedModel.name, 'page', page, elementsPerPage, pagedFilters[selectedModel.name], pagedSort[selectedModel.name]]);
264
+ }}
265
+ />;
266
+ }
267
+ };
268
+
269
+ const handleModelSelect = (model) => {
270
+ setRelationFilters({});
271
+ setCheckedItems([])
272
+ setFilterValues({});
273
+ setEditionMode(false);
274
+ if (!model) {
275
+ setSelectedModel(null);
276
+ return;
277
+ }
278
+
279
+ // Maintient la vue actuelle si elle est configurée pour le nouveau modèle, sinon revient à la vue "table"
280
+ const modelSettings = viewSettings[model.name] || {};
281
+ if (currentView === 'calendar' && (!modelSettings.calendar?.titleField || !modelSettings.calendar?.startField || !modelSettings.calendar?.endField)) {
282
+ setCurrentView('table');
283
+ } else if (currentView === 'kanban' && !modelSettings.kanban?.groupByField) {
284
+ setCurrentView('table');
285
+ }
286
+ const dt = [];
287
+ const t = [...model.fields].reduce((acc, field, index) => {
288
+ if (field.type === "relation") {
289
+ dt.push(field.relation);
290
+ acc[field.name] = dataByModel[field.relation]?.length > 0 ? dataByModel[field.relation][0]._id : null;
291
+ } else {
292
+ acc[field.name] = getDefaultForType(field);
293
+ }
294
+ return acc;
295
+ }, {});
296
+ setPage(1);
297
+ setFormData(t);
298
+ setFilteredDatasToLoad([model.name]);
299
+
300
+ setRecordToEdit(null); // Clear record to edit when model changes
301
+
302
+ let tl = [];
303
+ model.fields.forEach(field => {
304
+ if (field.type === 'relation') {
305
+ if (!tl.includes(field.relation))
306
+ tl.push(field.relation);
307
+ }
308
+ });
309
+ setDatasToLoad(tl);
310
+
311
+ const cb = ({data}) => {
312
+
313
+ updateRelationIds(model, data);
314
+
315
+ if( datasToLoad.length === 0 ) {
316
+ model.fields.forEach(field => {
317
+ if (field.type === 'relation') {
318
+ if (!tl.includes(field.relation))
319
+ tl.push(field.relation);
320
+ }
321
+ });
322
+ setDatasToLoad(tl);
323
+ }
324
+ };
325
+ // add new data
326
+ setOnSuccessCallbacks(cbs => {
327
+ const c = {...cbs};
328
+ if( !c['api/data/' + model.name + '/paged'])
329
+ c['api/data/' + model.name + '/paged'] = {};
330
+ c['api/data/' + model.name + '/paged'][model.name] = cb;
331
+ return c;
332
+ })
333
+ setSelectedModel(model);
334
+
335
+ gtag("event", "select_content", {
336
+ content_type: "model",
337
+ content_id: model.name
338
+ });
339
+ //queryClient.invalidateQueries(['api/data', model.name, 'page', page]);
340
+ }
341
+
342
+ const { me } = useAuthContext();
343
+
344
+ const { addNotification } = useNotificationContext();
345
+
346
+ const { mutate: insertOrUpdateMutation, isLoading} = useMutation(({formData,record}) => {
347
+ const method = record ? 'PUT' : 'POST'; // Determine method based on record
348
+ const url = record ? `/api/data/${record._id}` : `/api/data`; // Determine URL
349
+
350
+ try {
351
+ const fd = new FormData();
352
+
353
+ let obj = {};
354
+ for (const key in formData) {
355
+ if (formData[key] !== undefined)
356
+ obj[key] = formData[key];
357
+ }
358
+ Array.from(document.querySelectorAll('.field-file input[data-field]')).forEach(input =>{
359
+ const fieldName = input.dataset['field'];
360
+ for (let x = 0; x < input.files.length; x++) {
361
+ fd.append(`${fieldName}[${x}]`, input.files[x]);
362
+ }
363
+ obj[fieldName] = null;
364
+ });
365
+ fd.append("_data", JSON.stringify({...obj, _hash: undefined, _id: undefined}));
366
+ fd.append('model', selectedModel.name);
367
+
368
+ ///fd.append("files", fd2);
369
+ return fetch(`${url}?lang=${lang}&_user=${encodeURIComponent(getUserId(me))}`, {
370
+ method,
371
+ body: fd
372
+ }).then(e => e.json());
373
+
374
+ } catch (error) {
375
+ console.error('Erreur lors de l\'enregistrement des données:', error);
376
+ // Handle error, e.g., display error message to the user
377
+ }
378
+ }, {
379
+ onError: (err)=>{
380
+ const notificationData = {
381
+ title: 'Erreur lors de l\'enregistrement des données',
382
+ status: 'error'
383
+ };
384
+ addNotification(notificationData);
385
+ },
386
+ onSuccess: async (data) => {
387
+
388
+ console.log('Données enregistrées:', data, selectedModel);
389
+
390
+ await Event.Trigger(recordToEdit ? 'API_ADD_DATA' : 'API_ADD_DATA', "custom", "data", {
391
+ model: selectedModel.name,
392
+ });
393
+
394
+ gtag("event", "select_content", {
395
+ content_type: "edit_data",
396
+ content_id: selectedModel.name
397
+ });
398
+
399
+ const notificationData = {
400
+ title: data.success ? t('dataimporter.success', 'Données enregistrées') : t(data.error, data.error),
401
+ icon: data.success ? <FaInfo /> : undefined,
402
+ status: data.success ? 'completed': 'error'
403
+ };
404
+ addNotification(notificationData);
405
+
406
+ updateRelationIds(selectedModel, formData);
407
+ setDatasToLoad([...datasToLoad, selectedModel.name]);
408
+ queryClient.invalidateQueries(['api/data', selectedModel.name, 'page', page, elementsPerPage, pagedFilters[selectedModel.name], pagedSort[selectedModel.name]]);
409
+
410
+ if(data.inserted) {
411
+ const t = [...selectedModel.fields].reduce((acc, field, index) => {
412
+ if (field.type === "relation") {
413
+ acc[field.name] = field.multiple ? [] : null;
414
+ } else {
415
+ acc[field.name] = getDefaultForType(field);
416
+ }
417
+ return acc;
418
+ }, {});
419
+ setFormData(t)
420
+ }
421
+
422
+ setRelations({});
423
+
424
+ }})
425
+ const handleFormSubmit = async (formData, record) => { // Add record parameter
426
+ insertOrUpdateMutation({formData, record})
427
+ };
428
+
429
+ const updateRelationIds = (model, data) => {
430
+ const r = {...relationIds};
431
+ let datas = !Array.isArray(data) ? [data] : data;
432
+ model.fields.forEach(field => {
433
+ if( field.type !== "relation")
434
+ return;
435
+ datas.forEach(d => {
436
+ const value = d[field.name];
437
+ if (field.multiple && Array.isArray(value)) {
438
+ for (let i = 0; i < value.length; i++) {
439
+ if (!r[field.relation]){
440
+ r[field.relation] = [];
441
+ }
442
+ if (!r[field.relation].includes(value[i])) {
443
+ r[field.relation].push(value[i]);
444
+ }
445
+ }
446
+ } else if (!field.multiple && value) {
447
+ if (!r[field.relation]){
448
+ r[field.relation] = [];
449
+ }
450
+ if (!r[field.relation].includes(value)) {
451
+ r[field.relation].push(value);
452
+ }
453
+ }
454
+ });
455
+ });
456
+ setRelationIds(r);
457
+ queryClient.invalidateQueries(['api/data', model.name, r]);
458
+ }
459
+
460
+ const deleteMutation = useMutation((selectedModels) => {
461
+ return fetch('/api/data/'+checkedItems.map(m => m._id).join(',')+'?lang='+lang+'&_user='+encodeURIComponent(getUserId(me)), {
462
+ method: 'DELETE', headers: {
463
+ 'Content-Type': 'application/json'
464
+ }
465
+ }).then(e => e.json());
466
+ }, { onSuccess: (data) => {
467
+ if( data.success ){
468
+ queryClient.invalidateQueries(['api/data', selectedModel?.name, 'page', page, elementsPerPage, pagedFilters[selectedModel?.name], pagedSort[selectedModel?.name]]);
469
+ }
470
+
471
+ const notificationData = {
472
+ id: 'dataimporter.success',
473
+ title: t('dataimporter.success', 'Données supprimées'),
474
+ icon: <FaInfo />,
475
+ status: 'completed'
476
+ };
477
+ addNotification(notificationData);
478
+ }, onError:(err)=>{
479
+ const notificationData = {
480
+ id: 'dataimporter.error',
481
+ title: err.message,
482
+ status: 'error'
483
+ };
484
+ addNotification(notificationData);
485
+ }});
486
+ const handleDeletion = () => {
487
+ deleteMutation.mutate();
488
+ setCheckedItems([]);
489
+ }
490
+ const importModelsMutation = useMutation((selectedModels) => {
491
+ return fetch('/api/models/import', { method: 'POST', headers: {
492
+ 'Content-Type': 'application/json'
493
+ },
494
+ body: JSON.stringify({ models: selectedModels.map(m => m.name) })
495
+ })
496
+ });
497
+
498
+ const handleConfigureCurrentView = () => {
499
+ if (!selectedModel) return;
500
+
501
+ switch (currentView) {
502
+ case 'kanban':
503
+ setKanbanModalOpen(true);
504
+ break;
505
+ case 'calendar':
506
+ setCalendarModalOpen(true);
507
+ break;
508
+ default:
509
+ // Pas de configuration pour la vue 'table'
510
+ break;
511
+ }
512
+ };
513
+
514
+ const handleAddData = (model, data)=>{
515
+
516
+ if (!selectedModel || model.name !== selectedModel.name) {
517
+ handleModelSelect(model);
518
+ }
519
+
520
+ if( data ){
521
+ setFormData(data);
522
+ }else {
523
+ const t = model ? [...model.fields].reduce((acc, field) => {
524
+ if (field.type === "relation") {
525
+ acc[field.name] = field.multiple ? [] : null;
526
+ } else {
527
+ acc[field.name] = getDefaultForType(field);
528
+ }
529
+ return acc;
530
+ }, {}) : [];
531
+ setFormData(t)
532
+ }
533
+ setEditionMode(false);
534
+ setDataEditorVisible(true);
535
+ setAPIInfoVisible(false);
536
+ gtag("event", "select_content", {
537
+ content_type: "select_model",
538
+ content_id: model.name
539
+ });
540
+ }
541
+
542
+ const onImportModels = (models) => {
543
+ importModelsMutation.mutateAsync(models).then(e => {
544
+ queryClient.invalidateQueries('api/models');
545
+ });
546
+ setImportModalVisible(false);
547
+ }
548
+
549
+ useEffect(() => {
550
+ if( showDataEditor && mainPartRef.current ){
551
+
552
+ }
553
+ }, [showDataEditor,mainPartRef.current]);
554
+
555
+
556
+ const [currentProfile, setCurrentProfile] = useLocalStorage('profile', null);
557
+ const {isTourOpen, setIsTourOpen, currentTourSteps, allTourSteps, setTourStepIndex, setCurrentTourSteps, currentTour,setCurrentTour, addLaunchedTour, assistantConfig, setAssistantConfig} = useUI();
558
+
559
+ const startTour = () => {
560
+ setIsTourOpen(true);
561
+ };
562
+
563
+ const closeTour = (completedTourName) => {
564
+ // On génère le nom du tour de démo pour le comparer
565
+ // This function is called when ANY tour is closed.
566
+ // It needs to persist the fact that the tour has been seen.
567
+
568
+ // 1. Add the tour's unique name to the list of launched tours.
569
+ // This list is managed by the UI context and stored in localStorage.
570
+ if (addLaunchedTour) { // Check if the function exists to be safe
571
+ addLaunchedTour(completedTourName);
572
+ }
573
+
574
+ // 2. Close the tour's UI.
575
+ setIsTourOpen(false);
576
+
577
+ // 3. Specific logic for the very first "demo" tour:
578
+ // We also set the user's profile to mark that they are no longer a brand new user.
579
+ // This prevents the demo tour from trying to launch on every page load.
580
+ const demoTourName = `tour_${getObjectHash({steps: allTourSteps.demo || []})}`;
581
+
582
+ // Si le tour qui vient de se terminer est le tour de démo
583
+ if (completedTourName === demoTourName) {
584
+
585
+ setIsTourOpen(false);
586
+ setCurrentProfile({ lastSeen: new Date().toISOString() });
587
+ }
588
+ };
589
+
590
+ useEffect(() => {
591
+ if( !currentProfile ){
592
+ setIsTourOpen(true);
593
+ }
594
+ }, [currentProfile])
595
+
596
+ useEffect(() => {
597
+ // Si un modèle est sélectionné...
598
+ if (selectedModel) {
599
+ // Récupérer les étapes du tour "datapacks"
600
+ const datapacksSteps = allTourSteps.guides;
601
+ // S'assurer que ce tour existe et a des étapes
602
+ if (datapacksSteps && datapacksSteps.length > 0) {
603
+ // 1. Définir les étapes du tour qui doit être affiché
604
+ setCurrentTourSteps(datapacksSteps);
605
+ // 2. Calculer le nom unique du tour et le définir comme tour courant
606
+ // C'est l'clé qui manquait.
607
+ const tourName = `tour_${getObjectHash({ steps: datapacksSteps })}`;
608
+ setCurrentTour(tourName);
609
+ // 3. Activer l'affichage du composant de tour
610
+ setIsTourOpen(true);
611
+ }
612
+ }
613
+ }, [selectedModel, allTourSteps]); // Ajout de allTourSteps aux dépendances pour la bonne pratique
614
+
615
+ const handleShowTutorialMenu = () => {
616
+ setTutorialDialogVisible(!tutorialDialogVisible);
617
+ }
618
+ return (
619
+ <>
620
+ <Tooltip id="tooltipField" />
621
+ <>{/^demo[0-9]{1,2}$/.test(me.username) && (
622
+ <TourSpotlight
623
+ name={"tour_"+getObjectHash({steps:currentTourSteps})}
624
+ steps={currentTourSteps}
625
+ isOpen={isTourOpen}
626
+ onClose={closeTour}
627
+ />)}</>
628
+ <div className="flex actions">
629
+
630
+ {<ViewSwitcher
631
+ currentView={currentView}
632
+ onViewChange={handleSwitchView}
633
+ configuredViews={configuredViews}
634
+ onConfigureView={handleConfigureCurrentView}
635
+ />}
636
+ <Button onClick={() => {
637
+ setImportModalVisible(true);
638
+ }} className="btn tourStep-import-model"><FaFileImport/><Trans
639
+ i18nKey="btns.importModels">Modèles</Trans></Button>
640
+ <Button onClick={() => {
641
+ setShowPackGallery(true);
642
+ }} className="btn tourStep-import-pack"><FaBoxOpen/><Trans
643
+ i18nKey="btns.importPacks">Packs</Trans></Button>
644
+ <Button className={"tourStep-tutorials btn"} onClick={handleShowTutorialMenu} title={t("btns.showTutos")}><FaBookAtlas/><Trans
645
+ i18nKey="btns.showTutos">Tutoriels</Trans></Button>
646
+
647
+ <DialogProvider>
648
+ {tutorialDialogVisible && (
649
+ <Dialog isClosable={true} isModal={true} onClose={() => setTutorialDialogVisible(false)}>
650
+ <TutorialsMenu />
651
+ </Dialog>
652
+ )}
653
+ </DialogProvider>
654
+ <Button className="btn" onClick={() => {
655
+ setAPIInfoVisible(true);
656
+ setDataEditorVisible(false);
657
+ setEditionMode(false);
658
+ }}><FaBook/> {t('btns.api', 'API')}</Button>
659
+ </div>
660
+
661
+ <div className="datalayout flex flex-start">
662
+ <ModelList tourSteps={currentTourSteps}
663
+ editionMode={editionMode}
664
+ onAPIInfo={(model) => {
665
+ setAPIInfoVisible(true);
666
+ setDataEditorVisible(false);
667
+ setEditionMode(false);
668
+ setSelectedModel(model);
669
+ gtag("event", "select_content", {
670
+ content_type: "api",
671
+ content_id: model.name
672
+ });
673
+ }} onImportModel={() => {
674
+ setImportModalVisible(true);
675
+ }} onCreateModel={() => {
676
+ setSelectedModel(null);
677
+ setAPIInfoVisible(false);
678
+ setDataEditorVisible(false);
679
+ setEditionMode(true);
680
+ mainPartRef.current.scrollIntoView({behavior: 'smooth'});
681
+ gtag("event", "select_content", {
682
+ content_type: "create_model",
683
+ });
684
+ }} onEditModel={(model) => {
685
+ handleModelSelect(model);
686
+ setDataEditorVisible(false);
687
+ setAPIInfoVisible(false);
688
+ setEditionMode(true);
689
+ mainPartRef.current.scrollIntoView({behavior: 'smooth'});
690
+ gtag("event", "select_content", {
691
+ content_type: "edit_model",
692
+ content_id: model.name
693
+ });
694
+ }} onModelSelect={(model) => {
695
+ handleModelSelect(model);
696
+ setDataEditorVisible(false);
697
+ setEditionMode(false);
698
+ setAPIInfoVisible(false);
699
+ mainPartRef.current.scrollIntoView({behavior: 'smooth'});
700
+ gtag("event", "select_content", {
701
+ content_type: "select_model",
702
+ content_id: model.name
703
+ });
704
+ }} onImportPack={() => {
705
+ setShowPackGallery(true);
706
+ }} onNewData={(model) => {
707
+ mainPartRef.current.scrollIntoView({behavior: 'smooth'});
708
+ handleAddData(model);
709
+ }}/>
710
+ {(editionMode) && (
711
+ <ModelCreator ref={modelCreatorRef} onModelGenerated={() =>{
712
+ modelCreatorRef.current.scrollIntoView({behavior: 'smooth'});
713
+ }} initialModel={selectedModel} onModelSaved={(model) => {
714
+ setRefreshTime(new Date().getTime());
715
+ handleModelSelect(model);
716
+ }}/>)}
717
+
718
+ <div className="hidden-anchor" ref={mainPartRef}></div>
719
+
720
+ {showDataEditor && (<DataEditor
721
+ key={selectedModel?.name}
722
+ isLoading={isLoading}
723
+ model={selectedModel}
724
+ formData={formData}
725
+ setFormData={setFormData}
726
+ refreshTime={refreshTime}
727
+ onSubmit={handleFormSubmit}
728
+ setRecord={setRecordToEdit}
729
+ record={recordToEdit} // Pass record to edit to DataEditor
730
+ />)}
731
+
732
+
733
+ {selectedModel && showAPIInfo && <APIInfo/>}
734
+ {selectedModel && !showAPIInfo && !generatedModels.some(g => g.name === selectedModel?.name) && (<div className="datas">
735
+
736
+ <h2 className={"field-bg p-2"}>{t(`model_${selectedModel?.name}`, selectedModel?.name)} <>({countByModel?.[selectedModel?.name]})</></h2>
737
+
738
+
739
+ {renderCurrentView()}
740
+
741
+ {isDataLoaded && currentView === 'table' && (<>
742
+ {selectedModel && (<Pagination showElementsPerPage={true} onChange={page => {
743
+ setPage(page);
744
+ setCheckedItems([]);
745
+ gtag("event", "select_content", {
746
+ content_type: "change_page",
747
+ content_id: page
748
+ });
749
+ queryClient.invalidateQueries(['api/data', selectedModel.name, 'page', page, pagedFilters[selectedModel.name], pagedSort[selectedModel.name]]);
750
+ }} page={page} setPage={setPage} totalCount={countByModel[selectedModel.name]}
751
+ hasPreviousNext={true} visibleItemsCount={5}
752
+ elementsPerPage={elementsPerPage}/>)}
753
+ <div className="actions flex">
754
+ <Button onClick={() => {
755
+ setCheckedItems(paginatedDataByModel[selectedModel.name]);
756
+ }}><Trans i18nKey={"datatable.selectAll"}>Tout sélectionner</Trans></Button>
757
+ <Button onClick={handleDeletion} disabled={!checkedItems?.length}><Trans
758
+ i18nKey={"datatable.deleteSelection"}>Supprimer la sélection</Trans></Button>
759
+ </div>
760
+ </>)}
761
+ </div>)}
762
+ </div>
763
+
764
+ <div className={"fabs"}>
765
+ <Button data-tooltip-id={"tooltipField"} data-tooltip-html={t("btns.addData", "Ajouter une donnée au modèle")} className="fab plus-fab" onClick={() => {
766
+ mainPartRef.current.scrollIntoView({behavior: "smooth"});
767
+ handleAddData(selectedModel);
768
+ }}><FaPlus/></Button>
769
+ {me && <AssistantChat config={assistantConfig} />}
770
+ <NotificationList />
771
+ </div>
772
+
773
+ <DialogProvider>
774
+ {importModalVisible && (<Dialog onClose={() => {
775
+ setImportModalVisible(false)
776
+ }} isModal={true} isClosable={true}>
777
+ <ModelImporter onImport={onImportModels}/>
778
+ </Dialog>)}
779
+
780
+ {showPackGallery && (
781
+ <Dialog isClosable={true} isModal={true} onClose={() => setShowPackGallery(false)}>
782
+ <PackGallery />
783
+ </Dialog>
784
+ )}
785
+
786
+ <CalendarConfigModal
787
+ isOpen={isCalendarModalOpen}
788
+ onClose={() => setCalendarModalOpen(false)}
789
+ onSave={handleSaveCalendarConfig}
790
+ modelFields={selectedModel?.fields ||[]}
791
+ initialSettings={currentModelViewSettings.calendar}
792
+ />
793
+
794
+ <KanbanConfigModal
795
+ isOpen={isKanbanModalOpen}
796
+ onClose={() => setKanbanModalOpen(false)}
797
+ onSave={handleSaveKanbanConfig}
798
+ model={selectedModel}
799
+ modelFields={selectedModel?.fields||[]}
800
+ initialSettings={currentModelViewSettings.kanban}
801
+ />
802
+ </DialogProvider>
803
+ </>
804
+ );
805
+ }
806
+
807
+
756
808
  export default DataLayout;