data-primals-engine 1.3.1 → 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.
@@ -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") {
@@ -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);
@@ -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
  }
@@ -1,96 +1,253 @@
1
- import { useEffect, useState } from "react";
2
- import { Dialog } from "./Dialog.jsx";
3
- import { FaHistory } from "react-icons/fa";
4
- import {Trans, useTranslation} from "react-i18next";
5
- import "./HistoryDialog.scss";
6
-
7
- // Sub-component for displaying a single history entry
8
- const HistoryEntry = ({ entry }) => {
9
- const { t } = useTranslation();
10
- // The backend has transformed the data for us.
11
- // _id: history doc id, _v: version, _op: operation type (i,u,d), _rid: original record id
12
- const { _id, _v, _op, _updatedAt, _user, _rid, ...data } = entry; // ...data collects the remaining fields
13
-
14
- const operationDetails = {
15
- // The keys 'i', 'u', 'd' correspond to what the backend sends now.
16
- 'i': { label: t('history.op.create', 'Creation'), className: 'op-insert' },
17
- 'u': { label: t('history.op.update', 'Update'), className: 'op-update' },
18
- 'd': { label: t('history.op.delete', 'Deletion'), className: 'op-delete' },
19
- };
20
- const opInfo = operationDetails[_op] || { label: _op, className: '' };
21
-
22
- return (
23
- <div className="history-entry">
24
- <div className="history-entry-header">
25
- <span className={`op-badge ${opInfo.className}`}>{opInfo.label}</span>
26
- <span className="history-date">{t('history.datePrefix', 'On')} {new Date(_updatedAt).toLocaleString()}</span>
27
- {_user && <span className="history-user">{t('history.byUser', 'by')} <strong>{_user}</strong></span>}
28
- <span className="history-version">{t('history.version', 'Version:')} {_v}</span>
29
- </div>
30
- <div className="history-entry-data">
31
- <pre>{JSON.stringify(data, null, 2)}</pre>
32
- </div>
33
- </div>
34
- );
35
- };
36
-
37
- export const HistoryDialog = ({ modelName, recordId, onClose }) => {
38
- const [history, setHistory] = useState([]);
39
- const [loading, setLoading] = useState(true);
40
- const [error, setError] = useState(null);
41
- const { t, i18n } = useTranslation();
42
-
43
- useEffect(() => {
44
- if (!modelName || !recordId) return;
45
-
46
- const fetchHistory = async () => {
47
- setLoading(true);
48
- setError(null);
49
- try {
50
- // Assuming the API endpoint for history is structured like this
51
- const query = await fetch(`/api/data/history/${modelName}/${recordId}`, {
52
- headers: {
53
- "Content-Type": "application/json"
54
- }
55
- });
56
- const response = await query.json();
57
- if (response.success) {
58
- // L'API devrait retourner l'historique trié du plus récent au plus ancien
59
- setHistory(response.data);
60
- } else {
61
- setError(response.error || i18n.t("history.error", "Could not load history."));
62
- }
63
- } catch (err) {
64
- setError(i18n.t("history.error", "Could not load history."));
65
- console.error(err);
66
- } finally {
67
- setLoading(false);
68
- }
69
- };
70
-
71
- fetchHistory();
72
- }, [modelName, recordId]);
73
-
74
- return (
75
- <Dialog
76
- title={<><FaHistory style={{ marginRight: '8px' }} /> <Trans i18nKey={"history.title"}>Historique de l'enregistrement</Trans></>}
77
- onClose={onClose}
78
- isClosable={true}
79
- className="history-dialog"
80
- >
81
- <div className="history-dialog-content">
82
- {loading && <div><Trans i18nKey={"history.loading"}>Chargement de l'historique..</Trans></div>}
83
- {error && <div className="error-message">{error}</div>}
84
- {!loading && !error && (
85
- <div className="history-list">
86
- {history.length > 0 ? (
87
- history.map(entry => <HistoryEntry key={entry._id} entry={entry} />)
88
- ) : (
89
- <div><Trans i18nKey={"history.noHistory"}>Aucun historique trouvé pour cet enregistrement.</Trans></div>
90
- )}
91
- </div>
92
- )}
93
- </div>
94
- </Dialog>
95
- );
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
+ );
96
253
  };