data-primals-engine 1.5.2 → 1.6.0

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