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.
- package/CHANGELOG.md +440 -0
- package/Dockerfile +19 -9
- package/DockerfileTest +19 -9
- package/README.md +58 -24
- package/client/package-lock.json +172 -1
- package/client/package.json +1 -0
- package/client/src/App.scss +4 -0
- package/client/src/CalendarConfigModal.jsx +60 -21
- package/client/src/CalendarView.jsx +107 -0
- package/client/src/CalendarView.scss +9 -0
- package/client/src/DataLayout.jsx +54 -27
- package/client/src/DataTable.jsx +19 -2
- package/client/src/Dialog.scss +4 -3
- package/client/src/Field.jsx +3 -2
- package/client/src/HistoryDialog.jsx +253 -0
- package/client/src/HistoryDialog.scss +320 -0
- package/client/src/ModelCreator.jsx +36 -15
- package/client/src/ModelCreatorField.jsx +5 -6
- package/client/src/ViewSwitcher.jsx +6 -6
- package/client/src/hooks/useTutorials.jsx +1 -1
- package/client/src/translations.js +298 -1
- package/package.json +2 -2
- package/src/email.js +1 -1
- package/src/engine.js +6 -6
- package/src/events.js +15 -5
- package/src/gameObject.js +0 -29
- package/src/modules/data/data.history.js +490 -0
- package/src/modules/data/data.js +139 -108
- package/src/modules/data/data.routes.js +4 -4
- package/src/modules/mongodb.js +17 -1
- package/src/modules/user.js +9 -3
- package/src/packs.js +2 -1
- package/src/services/stripe.js +67 -13
- package/src/workers/crypto-worker.js +3 -3
- package/test/data.backup.integration.test.js +5 -0
- package/test/data.history.integration.test.js +193 -0
- package/test/data.integration.test.js +79 -39
- package/test/events.test.js +27 -27
- package/test/globalTeardown.js +1 -0
- package/test/model.integration.test.js +5 -0
- package/test/workflow.actions.integration.test.js +2 -0
- package/test/workflow.integration.test.js +2 -0
- 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;
|
|
@@ -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?.
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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?.
|
|
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
|
-
|
|
492
|
+
if (!selectedModel || model.name !== selectedModel.name) {
|
|
493
|
+
handleModelSelect(model);
|
|
494
|
+
}
|
|
468
495
|
|
|
469
496
|
if( data ){
|
|
470
497
|
setFormData(data);
|
package/client/src/DataTable.jsx
CHANGED
|
@@ -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)}
|
package/client/src/Dialog.scss
CHANGED
|
@@ -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:
|
|
57
|
-
|
|
56
|
+
padding: 0;
|
|
57
|
+
align-items: center;
|
|
58
|
+
margin: 8px;
|
|
58
59
|
font-size: 100%;
|
|
59
60
|
line-height: 208%;
|
|
60
61
|
}
|
package/client/src/Field.jsx
CHANGED
|
@@ -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-
|
|
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
|
+
};
|