data-primals-engine 1.3.0 → 1.3.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 (43) hide show
  1. package/CHANGELOG.md +440 -0
  2. package/Dockerfile +19 -9
  3. package/DockerfileTest +19 -9
  4. package/README.md +58 -24
  5. package/client/package-lock.json +172 -1
  6. package/client/package.json +1 -0
  7. package/client/src/App.scss +4 -0
  8. package/client/src/CalendarConfigModal.jsx +60 -21
  9. package/client/src/CalendarView.jsx +107 -0
  10. package/client/src/CalendarView.scss +9 -0
  11. package/client/src/DataLayout.jsx +54 -27
  12. package/client/src/DataTable.jsx +19 -2
  13. package/client/src/Dialog.scss +4 -3
  14. package/client/src/Field.jsx +3 -2
  15. package/client/src/HistoryDialog.jsx +253 -0
  16. package/client/src/HistoryDialog.scss +320 -0
  17. package/client/src/ModelCreator.jsx +36 -15
  18. package/client/src/ModelCreatorField.jsx +5 -6
  19. package/client/src/ViewSwitcher.jsx +6 -6
  20. package/client/src/hooks/useTutorials.jsx +1 -1
  21. package/client/src/translations.js +298 -1
  22. package/package.json +2 -2
  23. package/src/email.js +1 -1
  24. package/src/engine.js +6 -6
  25. package/src/events.js +15 -5
  26. package/src/gameObject.js +0 -29
  27. package/src/modules/data/data.history.js +490 -0
  28. package/src/modules/data/data.js +139 -108
  29. package/src/modules/data/data.routes.js +4 -4
  30. package/src/modules/mongodb.js +17 -1
  31. package/src/modules/user.js +9 -3
  32. package/src/packs.js +2 -1
  33. package/src/services/stripe.js +67 -13
  34. package/src/workers/crypto-worker.js +3 -3
  35. package/test/data.backup.integration.test.js +5 -0
  36. package/test/data.history.integration.test.js +193 -0
  37. package/test/data.integration.test.js +79 -39
  38. package/test/events.test.js +27 -27
  39. package/test/globalTeardown.js +1 -0
  40. package/test/model.integration.test.js +5 -0
  41. package/test/workflow.actions.integration.test.js +2 -0
  42. package/test/workflow.integration.test.js +2 -0
  43. package/test/workflow.robustness.test.js +2 -0
@@ -0,0 +1,107 @@
1
+ import React, { useMemo } from 'react';
2
+ import { useQuery } from 'react-query';
3
+ import { Calendar, dateFnsLocalizer } from 'react-big-calendar';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { format, parse, startOfWeek, getDay } from 'date-fns';
6
+ // Importer les locales dont vous avez besoin
7
+
8
+ import 'react-big-calendar/lib/css/react-big-calendar.css';
9
+ import { useAuthContext } from "./contexts/AuthContext.jsx";
10
+ import './CalendarView.scss';
11
+
12
+ import { enUS, fr } from 'date-fns/locale';
13
+ // Configurer le localizer en fournissant les fonctions de date-fns
14
+ const locales = {
15
+ 'en-US': enUS,
16
+ 'en': enUS,
17
+ 'fr': fr,
18
+ };
19
+
20
+ const localizer = dateFnsLocalizer({
21
+ format,
22
+ parse,
23
+ startOfWeek,
24
+ getDay,
25
+ locales,
26
+ });
27
+
28
+ /**
29
+ * Traite les données brutes pour les structurer pour le calendrier.
30
+ * @param {Array<Object>} data - La liste des éléments du modèle.
31
+ * @param {Object} settings - Les paramètres de la vue, contenant titleField, startField, endField.
32
+ * @returns {Array<Object>} Un tableau d'objets événement pour react-big-calendar.
33
+ */
34
+ const processDataForCalendar = (data, settings) => {
35
+ if (!data || !settings.titleField || !settings.startField || !settings.endField) {
36
+ return [];
37
+ }
38
+
39
+ return data.map(item => {
40
+ const title = item[settings.titleField]?.value || item[settings.titleField] || 'Sans titre';
41
+ const start = item[settings.startField] ? new Date(item[settings.startField]) : null;
42
+ const end = item[settings.endField] ? new Date(item[settings.endField]) : null;
43
+
44
+ // S'assurer que les dates sont valides
45
+ if (!start || !end || isNaN(start.getTime()) || isNaN(end.getTime())) {
46
+ return null;
47
+ }
48
+
49
+ return {
50
+ title,
51
+ start,
52
+ end,
53
+ resource: item, // Garder l'objet original pour plus de détails au clic
54
+ };
55
+ }).filter(Boolean); // Filtrer les éléments qui n'ont pas pu être convertis
56
+ };
57
+
58
+ const CalendarView = ({ settings, model, onEditData }) => {
59
+ const { me } = useAuthContext();
60
+ const { i18n } = useTranslation();
61
+
62
+ // Récupérer toutes les données du modèle nécessaires pour le calendrier
63
+ const { data: calendarData, isLoading, isError } = useQuery(
64
+ ['calendarData', model.name],
65
+ () => fetch(`/api/data/search?_user=${me.username}&depth=1`, {
66
+ method: 'POST',
67
+ headers: { 'Content-Type': 'application/json' },
68
+ // Note : Pour de très grands ensembles de données, vous pourriez vouloir implémenter une récupération par plage de dates.
69
+ body: JSON.stringify({ model: model.name, page: 1 })
70
+ }).then(res => res.json()),
71
+ {
72
+ enabled: !!model?.name && !!settings.titleField && !!settings.startField && !!settings.endField,
73
+ select: (data) => data.data || [],
74
+ }
75
+ );
76
+
77
+ const { events, culture } = useMemo(() => {
78
+ const processedEvents = calendarData ? processDataForCalendar(calendarData, settings) : [];
79
+ const lang = i18n.language.split(/[-_]/)[0];
80
+ const culture = Object.keys(locales).includes(lang) ? lang : 'en';
81
+ return { events: processedEvents, culture };
82
+ }, [calendarData, settings, i18n.language]);
83
+
84
+ if (isLoading) return <div>Chargement du calendrier...</div>;
85
+ if (isError) return <div>Erreur lors du chargement des données.</div>;
86
+ if (!settings.titleField || !settings.startField || !settings.endField) {
87
+ return <div>La vue calendrier n'est pas configurée. Veuillez définir les champs pour le titre, la date de début et la date de fin dans les paramètres de la vue.</div>
88
+ }
89
+
90
+ return (
91
+ <div className="calendar-view-container">
92
+ <Calendar
93
+ localizer={localizer}
94
+ events={events}
95
+ culture={culture}
96
+ startAccessor="start"
97
+ endAccessor="end"
98
+ // Vous pouvez ajouter un gestionnaire onSelectEvent pour afficher une modale avec les détails de l'événement
99
+ onSelectEvent={event => {
100
+ onEditData(model, event.resource);
101
+ }}
102
+ />
103
+ </div>
104
+ );
105
+ };
106
+
107
+ export default CalendarView;
@@ -0,0 +1,9 @@
1
+ .calendar-view-container {
2
+ padding: 1rem;
3
+ height: 100%;
4
+ box-sizing: border-box;
5
+
6
+ .rbc-calendar {
7
+ height: 100% !important; // Pour s'assurer que le calendrier prend toute la hauteur disponible
8
+ }
9
+ }
@@ -31,16 +31,9 @@ import ViewSwitcher from "./ViewSwitcher.jsx";
31
31
  import KanbanConfigModal from "./KanbanConfigModal.jsx";
32
32
  import CalendarConfigModal from "./CalendarConfigModal.jsx";
33
33
  import KanbanView from "./KanbanView.jsx";
34
+ import CalendarView from "./CalendarView.jsx";
34
35
 
35
36
 
36
- const CalendarView = ({ settings, model }) => (
37
- <div className="p-4 border rounded-md mt-4 bg-gray-50">
38
- <h3 className="font-bold">Vue Calendrier</h3>
39
- <p>Affichage des données pour le modèle <strong>{model?.name}</strong> en utilisant le champ de date : <strong>{settings.dateField}</strong>.</p>
40
- {/* L'implémentation réelle du calendrier irait ici */}
41
- </div>
42
- );
43
-
44
37
  const NotConfiguredPlaceholder = ({ type, onConfigure }) => (
45
38
  <div className="p-4 border border-dashed rounded-md mt-4 text-center bg-gray-50">
46
39
  <h4><Trans i18nKey="dataview.notConfiguredTitle" values={{ type }}>Vue {{type}} non configurée</Trans></h4>
@@ -52,9 +45,6 @@ const NotConfiguredPlaceholder = ({ type, onConfigure }) => (
52
45
  );
53
46
 
54
47
  function DataLayout() {
55
- const [currentView, setCurrentView] = useState('table');
56
-
57
- // Nouvelle version (persistante)
58
48
  const [viewSettings, setViewSettings] = useLocalStorage('viewSettings', {});
59
49
 
60
50
  const [isCalendarModalOpen, setCalendarModalOpen] = useState(false);
@@ -64,6 +54,9 @@ function DataLayout() {
64
54
  const { t, i18n } = useTranslation();
65
55
  const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
66
56
 
57
+ // Stocke la vue sélectionnée pour chaque modèle. Ex: { "contacts": "calendar", "tasks": "kanban" }
58
+ const [viewsByModel, setViewsByModel] = useLocalStorage('dataLayout_viewsByModel', {});
59
+
67
60
  const [filterValues, setFilterValues] = useState({});
68
61
  const { dataByModel,paginatedDataByModel,
69
62
  setRelationFilters, setSelectedModel, selectedModel,
@@ -94,6 +87,20 @@ function DataLayout() {
94
87
  const [showDataEditor, setDataEditorVisible] = useState(false);
95
88
  const [showAPIInfo, setAPIInfoVisible] = useState(false);
96
89
 
90
+ // La vue courante est dérivée du modèle sélectionné et des préférences stockées.
91
+ const currentView = useMemo(() => {
92
+ if (!selectedModel) return 'table';
93
+ return viewsByModel[selectedModel.name] || 'table';
94
+ }, [selectedModel, viewsByModel]);
95
+
96
+ // Met à jour la vue pour le modèle actuellement sélectionné.
97
+ const setCurrentView = (viewName) => {
98
+ if (!selectedModel) return;
99
+ setViewsByModel(prev => ({
100
+ ...prev,
101
+ [selectedModel.name]: viewName,
102
+ }));
103
+ };
97
104
 
98
105
  // --- MODIFICATION : Logique de changement de vue mise à jour ---
99
106
  const handleSwitchView = (viewName) => {
@@ -110,7 +117,7 @@ function DataLayout() {
110
117
  const modelSettings = viewSettings[selectedModel.name] || {};
111
118
 
112
119
  if (viewName === 'calendar') {
113
- if (modelSettings.calendar?.dateField) {
120
+ if (modelSettings.calendar?.titleField && modelSettings.calendar?.startField && modelSettings.calendar?.endField) {
114
121
  setCurrentView('calendar');
115
122
  } else {
116
123
  setCalendarModalOpen(true);
@@ -127,15 +134,26 @@ function DataLayout() {
127
134
  // --- MODIFICATION : Sauvegarde dans localStorage ---
128
135
  const handleSaveCalendarConfig = (config) => {
129
136
  if (!selectedModel) return;
130
- setViewSettings(prev => ({
131
- ...prev,
132
- [selectedModel.name]: {
133
- ...(prev[selectedModel.name] || {}),
134
- calendar: config,
135
- },
136
- }));
137
- setCalendarModalOpen(false);
138
- setCurrentView('calendar');
137
+ const isConfigValid = config && config.titleField && config.startField && config.endField;
138
+
139
+ if (isConfigValid) {
140
+ setViewSettings(prev => ({
141
+ ...prev,
142
+ [selectedModel.name]: {
143
+ ...(prev[selectedModel.name] || {}),
144
+ calendar: config,
145
+ },
146
+ }));
147
+ setCalendarModalOpen(false);
148
+ setCurrentView('calendar');
149
+ } else {
150
+ // Si la configuration n'est pas valide, afficher une notification et garder la modale ouverte
151
+ addNotification({
152
+ title: t('datalayout.invalidConfigTitle', 'Configuration invalide'),
153
+ 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.'),
154
+ status: 'error'
155
+ });
156
+ }
139
157
  };
140
158
 
141
159
  const handleSaveKanbanConfig = (config) => {
@@ -162,7 +180,7 @@ function DataLayout() {
162
180
  if (!selectedModel) return { calendar: false, kanban: false };
163
181
  const modelSettings = viewSettings[selectedModel.name] || {};
164
182
  return {
165
- calendar: !!modelSettings.calendar?.dateField,
183
+ calendar: !!modelSettings.calendar?.titleField && !!modelSettings.calendar?.startField && !!modelSettings.calendar?.endField,
166
184
  kanban: !!modelSettings.kanban?.groupByField,
167
185
  };
168
186
  }, [viewSettings, selectedModel]);
@@ -174,7 +192,7 @@ function DataLayout() {
174
192
  switch (currentView) {
175
193
  case 'calendar':
176
194
  return configuredViews.calendar
177
- ? <CalendarView settings={currentModelViewSettings.calendar} model={selectedModel} />
195
+ ? <CalendarView settings={currentModelViewSettings.calendar} onEditData={(model, data) => handleAddData(model,data)} model={selectedModel} />
178
196
  : <NotConfiguredPlaceholder type="calendar" onConfigure={() => setCalendarModalOpen(true)} />;
179
197
  case 'kanban':
180
198
  return configuredViews.kanban
@@ -217,13 +235,20 @@ function DataLayout() {
217
235
 
218
236
  const handleModelSelect = (model) => {
219
237
  setRelationFilters({});
220
- setCurrentView('table');
221
238
  setCheckedItems([])
222
239
  setFilterValues({});
223
240
  if (!model) {
241
+ setSelectedModel(null);
224
242
  return;
225
243
  }
226
244
 
245
+ // Maintient la vue actuelle si elle est configurée pour le nouveau modèle, sinon revient à la vue "table"
246
+ const modelSettings = viewSettings[model.name] || {};
247
+ if (currentView === 'calendar' && (!modelSettings.calendar?.titleField || !modelSettings.calendar?.startField || !modelSettings.calendar?.endField)) {
248
+ setCurrentView('table');
249
+ } else if (currentView === 'kanban' && !modelSettings.kanban?.groupByField) {
250
+ setCurrentView('table');
251
+ }
227
252
  const dt = [];
228
253
  const t = [...model.fields].reduce((acc, field, index) => {
229
254
  if (field.type === "relation") {
@@ -327,11 +352,11 @@ function DataLayout() {
327
352
  };
328
353
  addNotification(notificationData);
329
354
  },
330
- onSuccess: (data) => {
355
+ onSuccess: async (data) => {
331
356
 
332
357
  console.log('Données enregistrées:', data, selectedModel);
333
358
 
334
- Event.Trigger(recordToEdit ? 'API_ADD_DATA' : 'API_ADD_DATA', "custom", "data", {
359
+ await Event.Trigger(recordToEdit ? 'API_ADD_DATA' : 'API_ADD_DATA', "custom", "data", {
335
360
  model: selectedModel.name,
336
361
  });
337
362
 
@@ -464,7 +489,9 @@ function DataLayout() {
464
489
 
465
490
  const handleAddData = (model, data)=>{
466
491
 
467
- handleModelSelect(model);
492
+ if (!selectedModel || model.name !== selectedModel.name) {
493
+ handleModelSelect(model);
494
+ }
468
495
 
469
496
  if( data ){
470
497
  setFormData(data);
@@ -14,7 +14,7 @@ import {
14
14
  FaDatabase, FaEraser,
15
15
  FaFileExport,
16
16
  FaFileImport,
17
- FaFilter,
17
+ FaFilter, FaHistory,
18
18
  FaInfo,
19
19
  FaPlus,
20
20
  FaTrash, FaWrench
@@ -59,6 +59,7 @@ import ConditionBuilder from "./ConditionBuilder.jsx";
59
59
  import {pagedFilterToMongoConds} from "./filter.js";
60
60
  import {isConditionMet} from "../../src/filter";
61
61
  import {DataImporter} from "./DataImporter.jsx";
62
+ import {HistoryDialog} from "./HistoryDialog.jsx";
62
63
 
63
64
  const Header = ({
64
65
  reversed = false,
@@ -238,6 +239,7 @@ export function DataTable({
238
239
  const queryClient = useQueryClient();
239
240
  const {me} = useAuthContext()
240
241
 
242
+
241
243
  const isDataLoaded = true;
242
244
  const [importVisible, setImportVisible] = useState(false);
243
245
  const [filterActive, setFilterActive] = useState(false)
@@ -248,6 +250,8 @@ export function DataTable({
248
250
  const [showExportDialog, setExportDialogVisible] = useState(false);
249
251
 
250
252
 
253
+ const [selectedRow, setSelectedRow] = useState(null);
254
+
251
255
  const [showAddPack, setAddPackVisible] = useState(false);
252
256
  const handleAddPack = () => {
253
257
  setAddPackVisible(!showAddPack);
@@ -283,7 +287,7 @@ export function DataTable({
283
287
  console.log('Données non trouvées');
284
288
  }
285
289
 
286
- Event.Trigger('API_DELETE_DATA', "custom", "data",{ model: item._model, id: item._id });
290
+ await Event.Trigger('API_DELETE_DATA', "custom", "data",{ model: item._model, id: item._id });
287
291
  queryClient.invalidateQueries(['api/data', item._model, 'page', page, elementsPerPage, pagedFilters[item._model], pagedSort[item._model]]);
288
292
 
289
293
  } else {
@@ -697,6 +701,14 @@ export function DataTable({
697
701
  >
698
702
  <FaCopy/>
699
703
  </button>
704
+ {selectedModel?.history?.enabled && (<button
705
+ onClick={() => setSelectedRow(item._id)}
706
+ data-tooltip-id="tooltipActions"
707
+ data-tooltip-content={t('btns.showHistory', 'Voir l\'historique')}
708
+ >
709
+ <FaHistory />
710
+ </button>)}
711
+
700
712
  <button data-tooltip-id="tooltipActions"
701
713
  data-tooltip-content={t('btns.delete', 'Supprimer')}
702
714
  onClick={() => handleDelete(item)}><FaTrash/></button>
@@ -704,6 +716,7 @@ export function DataTable({
704
716
  </tr>
705
717
  </DialogProvider></>
706
718
  ))}
719
+
707
720
  </tbody>
708
721
 
709
722
  <tfoot>
@@ -720,6 +733,10 @@ export function DataTable({
720
733
  {importVisible && (<DataImporter onClose={() => {
721
734
  setImportVisible(false);
722
735
  }}/>)}
736
+
737
+ {selectedRow && (
738
+ <HistoryDialog onClose={() => setSelectedRow(null)} modelName={selectedModel?.name} recordId={selectedRow} />
739
+ )}
723
740
  <RestoreConfirmationModal
724
741
  isOpen={isBackupModalOpen}
725
742
  onClose={() => setIsBackupModalOpen(false)}
@@ -44,7 +44,7 @@
44
44
  #content .dialog.dialog-relation {
45
45
  max-width: 480px;
46
46
  }
47
- .dialog-header {
47
+ #content .dialog-header {
48
48
  display: flex;
49
49
  gap: 8px;
50
50
  justify-content: flex-start;
@@ -53,8 +53,9 @@
53
53
  h2 {
54
54
  display: flex;
55
55
  flex: 1;
56
- padding: 0px;
57
- margin: 0 0 0 16px;
56
+ padding: 0;
57
+ align-items: center;
58
+ margin: 8px;
58
59
  font-size: 100%;
59
60
  line-height: 208%;
60
61
  }
@@ -93,6 +93,7 @@ const TextField = forwardRef(function TextField(
93
93
  searchable,
94
94
  labelProps,
95
95
  showErrors=false,
96
+ after,
96
97
  ...rest
97
98
  },
98
99
  ref,
@@ -186,7 +187,7 @@ const TextField = forwardRef(function TextField(
186
187
  ></textarea>
187
188
  )}
188
189
 
189
- <div className={"flex flex-row flex-1 flex-start"}>
190
+ <div className={"flex flex-1 flex-no-gap flex-start"}>
190
191
  {!mult && (
191
192
  <input
192
193
  ref={inputRef}
@@ -207,7 +208,7 @@ const TextField = forwardRef(function TextField(
207
208
  {...rest}
208
209
  />
209
210
  )}
210
-
211
+ {after}
211
212
  </div>
212
213
  </div>
213
214
  {errors.length > 0 && (
@@ -0,0 +1,253 @@
1
+ import {useCallback, useEffect, useState} from "react";
2
+ import { Dialog } from "./Dialog.jsx";
3
+ import Button from "./Button.jsx";
4
+ import { FaHistory } from "react-icons/fa";
5
+ import {Trans, useTranslation} from "react-i18next";
6
+ import "./HistoryDialog.scss";
7
+ import {Tooltip} from "react-tooltip";
8
+ import {CodeField} from "./Field.jsx";
9
+
10
+ // Helper to render values in a friendly way for the summary list
11
+ const renderSummaryValue = (value) => {
12
+ if (value === undefined || value === null) {
13
+ return <i className="value-empty">empty</i>;
14
+ }
15
+ if (typeof value === 'boolean') {
16
+ return <code className="value-boolean">{value.toString()}</code>;
17
+ }
18
+ if (Array.isArray(value)) {
19
+ return <code className="value-object" title={JSON.stringify(value, null, 2)}>[...] ({value.length} items)</code>
20
+ }
21
+ if (typeof value === 'object' && value !== null) {
22
+ return <code className="value-object" title={JSON.stringify(value, null, 2)}>{`{...}`}</code>
23
+ }
24
+ const strValue = String(value);
25
+ if (strValue.length > 50) {
26
+ return <span className="value-text" title={strValue}>{strValue.substring(0, 47) + '...'}</span>;
27
+ }
28
+ return <span className="value-text">{strValue}</span>;
29
+ };
30
+
31
+ // New sub-component for rendering a single field change
32
+ const ChangeDetail = ({ field, from, to }) => {
33
+ const { t } = useTranslation();
34
+
35
+ const tt = typeof(to) === 'object' ? JSON.stringify(to,null,2):to;
36
+ const f = typeof(from) === 'object' ? JSON.stringify(from,null,2):from;
37
+ return (
38
+ <div className="change-detail">
39
+ <strong className="change-field-name" title={field}>{field}</strong>
40
+ <div className="change-values">
41
+ <div className="value-from" data-tooltip-id={"changeTooltip"} data-tooltip-content={f}>{renderSummaryValue(from)}</div>
42
+ <div className="change-arrow">→</div>
43
+ <div className="value-to" data-tooltip-id={"changeTooltip"} data-tooltip-content={tt}>{renderSummaryValue(to)}</div>
44
+ </div>
45
+ </div>
46
+ );
47
+ };
48
+
49
+ // Sub-component for displaying a single history entry
50
+ const HistoryEntry = ({ entry, onPreview, isSelected }) => {
51
+ const { t } = useTranslation();
52
+ // The backend has transformed the data for us.
53
+ // _id: history doc id, _v: version, _op: operation type (i,u,d), _rid: original record id
54
+ const { _id, _v, _op, _updatedAt, _user, ...data } = entry; // ...data collects the remaining fields
55
+
56
+ const operationDetails = {
57
+ // The keys 'i', 'u', 'd' correspond to what the backend sends now.
58
+ 'i': { label: t('history.op.create', 'Creation'), className: 'op-insert' },
59
+ 'u': { label: t('history.op.update', 'Update'), className: 'op-update' },
60
+ 'd': { label: t('history.op.delete', 'Deletion'), className: 'op-delete' },
61
+ };
62
+ const opInfo = operationDetails[_op] || { label: _op, className: '' };
63
+
64
+ const renderData = () => {
65
+ if (_op === 'u') {
66
+ const changes = Object.entries(data).filter(([, value]) => value && typeof value === 'object' && 'from' in value && 'to' in value);
67
+ if (changes.length === 0) {
68
+ return <div className="no-changes-text">{t('history.noChanges', 'No relevant changes recorded.')}</div>;
69
+ }
70
+ return (
71
+ <div className="changes-list">
72
+ {changes.map(([field, change]) => (
73
+ <ChangeDetail key={field} field={field} from={change.from} to={change.to} />
74
+ ))}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ // For 'i' (create) and 'd' (delete), show a human-friendly summary of the snapshot.
80
+ const snapshotEntries = Object.entries(data);
81
+ if (snapshotEntries.length === 0) {
82
+ return <div className="no-changes-text">{t('history.noData', 'No data in snapshot.')}</div>;
83
+ }
84
+ return (
85
+ <div className="snapshot-list">
86
+ {snapshotEntries.map(([key, value]) => (
87
+ <div className="snapshot-item" key={key}>
88
+ <strong className="snapshot-key" title={key}>{key}:</strong>
89
+ <span className="snapshot-value">{renderSummaryValue(value)}</span>
90
+ </div>
91
+ ))}
92
+ </div>
93
+ );
94
+ };
95
+
96
+ return (
97
+ <div className={`history-entry ${isSelected ? 'selected' : ''}`}>
98
+ <div className="history-entry-header">
99
+ <span className={`op-badge ${opInfo.className}`}>{opInfo.label}</span>
100
+ <span className="history-date">{t('history.datePrefix', 'On')} {new Date(_updatedAt).toLocaleString()}</span>
101
+ {_user && <span className="history-user">{t('history.byUser', 'by')} <strong>{_user}</strong></span>}
102
+ <span className="history-version">{t('history.version', 'Version:')} {_v}</span>
103
+ <Button className="btn-preview" onClick={() => onPreview(_v)}>
104
+ <Trans i18nKey={"history.previewAction"}>Preview</Trans>
105
+ </Button>
106
+ </div>
107
+ <div className="history-entry-data">
108
+ {renderData()}
109
+ </div>
110
+ </div>
111
+ );
112
+ };
113
+
114
+ export const HistoryDialog = ({ modelName, recordId, onClose }) => {
115
+ const [history, setHistory] = useState([]);
116
+ const [loading, setLoading] = useState(true);
117
+ const [error, setError] = useState(null);
118
+ const [previewData, setPreviewData] = useState(null);
119
+ const [previewLoading, setPreviewLoading] = useState(false);
120
+ const [selectedVersion, setSelectedVersion] = useState(null);
121
+ const { t, i18n } = useTranslation();
122
+
123
+ const fetchHistory = useCallback(async () => {
124
+ if (!modelName || !recordId) return;
125
+ setLoading(true);
126
+ setError(null);
127
+ try {
128
+ const query = await fetch(`/api/data/history/${modelName}/${recordId}`);
129
+ const response = await query.json();
130
+ if (response.success) {
131
+ setHistory(response.data);
132
+ } else {
133
+ setError(response.error || i18n.t("history.error", "Could not load history."));
134
+ }
135
+ } catch (err) {
136
+ setError(i18n.t("history.error", "Could not load history."));
137
+ console.error(err);
138
+ } finally {
139
+ setLoading(false);
140
+ }
141
+ }, [modelName, recordId, i18n]);
142
+
143
+ useEffect(() => {
144
+ fetchHistory();
145
+ }, [fetchHistory]);
146
+
147
+ const handlePreview = async (version) => {
148
+ if (selectedVersion === version) return; // Already selected
149
+
150
+ setSelectedVersion(version);
151
+ setPreviewLoading(true);
152
+ setPreviewData(null);
153
+ try {
154
+ const res = await fetch(`/api/data/history/${modelName}/${recordId}/${version}`);
155
+ const response = await res.json();
156
+ if (response.success) {
157
+ setPreviewData(response.data);
158
+ } else {
159
+ setPreviewData({ error: response.error || i18n.t("history.previewError", "Could not load revision preview.") });
160
+ }
161
+ } catch (err) {
162
+ setPreviewData({ error: i18n.t("history.previewError", "Could not load revision preview.") });
163
+ console.error(err);
164
+ } finally {
165
+ setPreviewLoading(false);
166
+ }
167
+ };
168
+
169
+ const handleRevert = async (version) => {
170
+ if (!window.confirm(t('history.confirmRevert', `Are you sure you want to revert to version ${version}? This will create a new version with the content of this revision.`))) {
171
+ return;
172
+ }
173
+
174
+ setPreviewLoading(true); // Reuse preview loading state to indicate activity
175
+ try {
176
+ const res = await fetch(`/api/data/history/${modelName}/${recordId}/revert/${version}`, {
177
+ method: 'POST',
178
+ headers: { 'Content-Type': 'application/json' }
179
+ });
180
+ const response = await res.json();
181
+
182
+ if (response.success) {
183
+ alert(t('history.revertSuccess', 'Document successfully reverted. The history will now be updated.'));
184
+ // Refresh history list and clear preview
185
+ await fetchHistory();
186
+ setPreviewData(null);
187
+ setSelectedVersion(null);
188
+ } else {
189
+ alert(t('history.revertError', `Error reverting: ${response.error || 'Unknown error'}`));
190
+ }
191
+ } catch (err) {
192
+ alert(t('history.revertError', 'An error occurred while reverting.'));
193
+ console.error(err);
194
+ } finally {
195
+ setPreviewLoading(false);
196
+ }
197
+ };
198
+
199
+ return (
200
+ <Dialog
201
+ title={<><FaHistory style={{ marginRight: '8px' }} /> <Trans i18nKey={"history.title"}>Historique de l'enregistrement</Trans></>}
202
+ onClose={onClose}
203
+ isClosable={true}
204
+ className="history-dialog"
205
+ >
206
+ <Tooltip id={"changeTooltip"} clickable={true} />
207
+ <div className="history-dialog-content-split">
208
+ <div className="history-list-container">
209
+ {loading && <div><Trans i18nKey={"history.loading"}>Chargement de l'historique..</Trans></div>}
210
+ {error && <div className="error-message">{error}</div>}
211
+ {!loading && !error && (
212
+ <div className="history-list">
213
+ {history.length > 0 ? (
214
+ history.map(entry =>
215
+ <HistoryEntry
216
+ key={entry._id}
217
+ entry={entry}
218
+ onPreview={handlePreview}
219
+ isSelected={selectedVersion === entry._v}
220
+ />)
221
+ ) : (
222
+ <div><Trans i18nKey={"history.noHistory"}>Aucun historique trouvé pour cet enregistrement.</Trans></div>
223
+ )}
224
+ </div>
225
+ )}
226
+ </div>
227
+ <div className="history-preview-container">
228
+ <div className="preview-header">
229
+ <h4><Trans i18nKey={"history.previewTitle"}>Revision Preview</Trans></h4>
230
+ {previewData && !previewData.error && (
231
+ <Button
232
+ className="btn-revert"
233
+ onClick={() => handleRevert(selectedVersion)}
234
+ disabled={previewLoading}
235
+ title={t('history.revertTooltip', 'Revert the document to this state. This creates a new version.')}
236
+ >
237
+ <Trans i18nKey={"history.revertAction"}>Revert to this version</Trans>
238
+ </Button>
239
+ )}
240
+ </div>
241
+
242
+ <div className="history-preview-area">
243
+ {previewLoading && <div><Trans i18nKey={"history.loadingPreview"}>Loading preview...</Trans></div>}
244
+ {!previewLoading && previewData && <CodeField value={JSON.stringify(previewData,null, 2)} language={"json"} disabled={true} />}
245
+ {!previewLoading && !previewData && (
246
+ <div className="placeholder"><Trans i18nKey={"history.selectRevision"}>Select a revision to preview its full state.</Trans></div>
247
+ )}
248
+ </div>
249
+ </div>
250
+ </div>
251
+ </Dialog>
252
+ );
253
+ };