data-primals-engine 1.5.2 → 1.6.1
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/README.md +938 -915
- package/client/README.md +136 -136
- package/client/index.js +0 -1
- package/client/public/doc/API.postman_collection.json +213 -213
- package/client/public/react.svg +9 -9
- package/client/src/AddWidgetTypeModal.jsx +47 -47
- package/client/src/App.css +42 -42
- package/client/src/App.jsx +92 -150
- package/client/src/App.scss +6 -0
- package/client/src/AssistantChat.jsx +362 -363
- package/client/src/Button.jsx +12 -12
- package/client/src/Dashboard.jsx +480 -480
- package/client/src/DashboardHtmlViewItem.jsx +146 -146
- package/client/src/DashboardView.jsx +654 -654
- package/client/src/DataEditor.jsx +383 -383
- package/client/src/DataLayout.jsx +779 -808
- package/client/src/DataTable.jsx +782 -822
- package/client/src/DataTable.scss +186 -186
- package/client/src/GeolocationField.jsx +93 -93
- package/client/src/HistoryDialog.jsx +307 -307
- package/client/src/HistoryDialog.scss +319 -319
- package/client/src/HtmlViewBuilderModal.jsx +90 -90
- package/client/src/HtmlViewBuilderModal.scss +17 -17
- package/client/src/HtmlViewCard.jsx +43 -43
- package/client/src/ModelCreator.jsx +683 -686
- package/client/src/ModelCreator.scss +1 -1
- package/client/src/ModelCreatorField.jsx +950 -950
- package/client/src/ModelImporter.jsx +3 -0
- package/client/src/ModelList.jsx +280 -280
- package/client/src/Notification.jsx +136 -136
- package/client/src/PackGallery.jsx +391 -391
- package/client/src/PackGallery.scss +231 -231
- package/client/src/Pagination.jsx +143 -143
- package/client/src/RelationField.jsx +354 -354
- package/client/src/RelationSelectorWidget.jsx +172 -172
- package/client/src/RelationValue.jsx +2 -1
- package/client/src/contexts/CommandContext.jsx +260 -0
- package/client/src/contexts/ModelContext.jsx +4 -1
- package/client/src/contexts/UIContext.jsx +72 -72
- package/client/src/filter.js +263 -263
- package/client/src/index.css +90 -90
- package/package.json +6 -5
- package/perf/artillery-hooks.js +37 -37
- package/perf/setup.yml +25 -25
- package/src/constants.js +4 -0
- package/src/defaultModels.js +7 -0
- package/src/engine.js +335 -335
- package/src/events.js +232 -137
- package/src/filter.js +274 -274
- package/src/index.js +1 -0
- package/src/modules/assistant/assistant.js +225 -83
- package/src/modules/auth-google/index.js +50 -50
- package/src/modules/auth-microsoft/index.js +81 -81
- package/src/modules/data/data.core.js +118 -118
- package/src/modules/data/data.history.js +555 -555
- package/src/modules/data/data.operations.js +112 -9
- package/src/modules/data/data.relations.js +686 -686
- package/src/modules/data/data.routes.js +1879 -1879
- package/src/modules/data/data.validation.js +2 -2
- package/src/modules/file.js +247 -247
- package/src/modules/user.js +14 -9
- package/src/packs.js +2 -2
- package/src/providers.js +2 -2
- package/src/services/stripe.js +196 -196
- package/src/sso.js +193 -193
- package/test/data.history.integration.test.js +264 -264
- package/test/data.integration.test.js +1281 -1206
- package/test/events.test.js +108 -10
- package/test/model.integration.test.js +377 -377
- package/test/user.test.js +106 -1
|
@@ -1,173 +1,173 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react';
|
|
2
|
-
import { useMutation, useQueryClient } from 'react-query';
|
|
3
|
-
import { ModelProvider, useModelContext } from './contexts/ModelContext.jsx';
|
|
4
|
-
import { DataTable } from './DataTable.jsx';
|
|
5
|
-
import { DataEditor } from './DataEditor.jsx';
|
|
6
|
-
import Button from './Button.jsx';
|
|
7
|
-
import { useTranslation } from 'react-i18next';
|
|
8
|
-
import { FaCheck, FaTimes, FaPlus, FaSearch } from 'react-icons/fa';
|
|
9
|
-
import { getDefaultForType } from '../../src/data.js';
|
|
10
|
-
import { useAuthContext } from './contexts/AuthContext.jsx';
|
|
11
|
-
|
|
12
|
-
// These API functions can be moved to a dedicated service file later.
|
|
13
|
-
const insertDataAPI = async (modelName, data) => {
|
|
14
|
-
const response = await fetch(`/api/data`, {
|
|
15
|
-
method: 'POST',
|
|
16
|
-
headers: { 'Content-Type': 'application/json' },
|
|
17
|
-
body: JSON.stringify({model: modelName,data})
|
|
18
|
-
});
|
|
19
|
-
if (!response.ok) {
|
|
20
|
-
const error = await response.json();
|
|
21
|
-
throw new Error(error.error || 'Failed to insert data');
|
|
22
|
-
}
|
|
23
|
-
return response.json();
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const searchDataAPI = async (modelName, filter) => {
|
|
27
|
-
const params = new URLSearchParams({ model: modelName, depth: '1' });
|
|
28
|
-
const response = await fetch(`/api/data/search?${params.toString()}`, {
|
|
29
|
-
method: 'POST',
|
|
30
|
-
headers: { 'Content-Type': 'application/json' },
|
|
31
|
-
body: JSON.stringify({ filter })
|
|
32
|
-
});
|
|
33
|
-
if (!response.ok) {
|
|
34
|
-
const error = await response.json();
|
|
35
|
-
throw new Error(error.error || 'Failed to search data');
|
|
36
|
-
}
|
|
37
|
-
return response.json();
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const RelationSelectorWidget = ({ modelName, initialSelection, isMultiple, onValidate, onCancel }) => {
|
|
41
|
-
const { t } = useTranslation();
|
|
42
|
-
const { models, setSelectedModel } = useModelContext();
|
|
43
|
-
const queryClient = useQueryClient();
|
|
44
|
-
const { me } = useAuthContext();
|
|
45
|
-
const [isCreating, setIsCreating] = useState(false);
|
|
46
|
-
const [newRecord, setNewRecord] = useState(null);
|
|
47
|
-
|
|
48
|
-
const [filterValues, setFilterValues] = useState({});
|
|
49
|
-
|
|
50
|
-
// DataTable's `checkedItems` expects an array of objects with at least an `_id` property.
|
|
51
|
-
// `initialSelection` is an array of string IDs. We convert it.
|
|
52
|
-
const [selection, setSelection] = useState(() => initialSelection.map(id => ({ _id: id })));
|
|
53
|
-
|
|
54
|
-
const model = models.find(m => m.name === modelName);
|
|
55
|
-
|
|
56
|
-
useEffect(() => {
|
|
57
|
-
if (model) {
|
|
58
|
-
setSelectedModel(model);
|
|
59
|
-
}
|
|
60
|
-
}, [model, setSelectedModel]);
|
|
61
|
-
|
|
62
|
-
const mutation = useMutation(
|
|
63
|
-
(formData) => insertDataAPI(model.name, formData),
|
|
64
|
-
{
|
|
65
|
-
onSuccess: async (result) => {
|
|
66
|
-
if (result.success && result.insertedIds?.length > 0) {
|
|
67
|
-
queryClient.invalidateQueries(['api/data', modelName, 'page']);
|
|
68
|
-
const newId = result.insertedIds[0];
|
|
69
|
-
const searchResult = await searchDataAPI(model.name, { _id: newId });
|
|
70
|
-
if (searchResult.data && searchResult.data.length > 0) {
|
|
71
|
-
handleCreationSuccess(searchResult.data[0]);
|
|
72
|
-
} else {
|
|
73
|
-
console.error('Could not fetch the newly created item.');
|
|
74
|
-
handleCancelCreation();
|
|
75
|
-
}
|
|
76
|
-
} else {
|
|
77
|
-
console.error('Creation failed:', result.error);
|
|
78
|
-
}
|
|
79
|
-
},
|
|
80
|
-
onError: (error) => {
|
|
81
|
-
console.error('Creation mutation failed:', error.message);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
const handleCreationSuccess = (newItem) => {
|
|
87
|
-
setIsCreating(false);
|
|
88
|
-
setNewRecord(null);
|
|
89
|
-
if (isMultiple) {
|
|
90
|
-
setSelection(current => [...current, newItem]);
|
|
91
|
-
} else {
|
|
92
|
-
setSelection([newItem]);
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const handleStartCreation = () => {
|
|
97
|
-
const defaults = model.fields.reduce((acc, field) => {
|
|
98
|
-
acc[field.name] = getDefaultForType(field);
|
|
99
|
-
return acc;
|
|
100
|
-
}, {});
|
|
101
|
-
setNewRecord(defaults);
|
|
102
|
-
setIsCreating(true);
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const handleCancelCreation = () => {
|
|
106
|
-
setIsCreating(false);
|
|
107
|
-
setNewRecord(null);
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const handleSave = (formData) => {
|
|
111
|
-
mutation.mutate(formData);
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
if (!model) {
|
|
115
|
-
return <div>{t('loading', 'Chargement...')}</div>;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const handleValidate = () => {
|
|
119
|
-
// `onValidate` expects an array of full objects to update the display correctly.
|
|
120
|
-
onValidate(selection);
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const handleCheckboxChange = (items) => {
|
|
124
|
-
if (!isMultiple) {
|
|
125
|
-
// For single selection, we only keep the last selected item.
|
|
126
|
-
// DataTable's setCheckedItems gives the full list.
|
|
127
|
-
const lastItem = items.length > 0 ? [items[items.length - 1]] : [];
|
|
128
|
-
setSelection(lastItem);
|
|
129
|
-
} else {
|
|
130
|
-
setSelection(items);
|
|
131
|
-
}
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
if (isCreating) {
|
|
135
|
-
return (
|
|
136
|
-
<div className="relation-selector-widget">
|
|
137
|
-
<Button onClick={handleCancelCreation} className="btn-secondary">
|
|
138
|
-
<FaSearch /> {t('btns.backToSearch', 'Retour à la recherche')}
|
|
139
|
-
</Button>
|
|
140
|
-
<ModelProvider>
|
|
141
|
-
<DataEditor
|
|
142
|
-
model={model}
|
|
143
|
-
formData={newRecord}
|
|
144
|
-
setFormData={setNewRecord}
|
|
145
|
-
onSubmit={handleSave}
|
|
146
|
-
onCancel={handleCancelCreation}
|
|
147
|
-
isLoading={mutation.isLoading}
|
|
148
|
-
hideNewButton={true}
|
|
149
|
-
/>
|
|
150
|
-
</ModelProvider>
|
|
151
|
-
</div>
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return (
|
|
156
|
-
<div className="relation-selector-widget">
|
|
157
|
-
<DataTable model={model} filterValues={filterValues} setFilterValues={setFilterValues} checkedItems={selection} setCheckedItems={handleCheckboxChange} selectionMode={true} />
|
|
158
|
-
<div className="flex actions right">
|
|
159
|
-
<Button onClick={handleStartCreation} className="btn-secondary" style={{ marginRight: 'auto' }}>
|
|
160
|
-
<FaPlus /> {t('btns.create', 'Créer')}
|
|
161
|
-
</Button>
|
|
162
|
-
<Button onClick={onCancel} className="btn-secondary">
|
|
163
|
-
<FaTimes /> {t('btns.cancel', 'Annuler')}
|
|
164
|
-
</Button>
|
|
165
|
-
<Button onClick={handleValidate} className="btn-primary">
|
|
166
|
-
<FaCheck /> {t('btns.validate', 'Valider')}
|
|
167
|
-
</Button>
|
|
168
|
-
</div>
|
|
169
|
-
</div>
|
|
170
|
-
);
|
|
171
|
-
};
|
|
172
|
-
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { useMutation, useQueryClient } from 'react-query';
|
|
3
|
+
import { ModelProvider, useModelContext } from './contexts/ModelContext.jsx';
|
|
4
|
+
import { DataTable } from './DataTable.jsx';
|
|
5
|
+
import { DataEditor } from './DataEditor.jsx';
|
|
6
|
+
import Button from './Button.jsx';
|
|
7
|
+
import { useTranslation } from 'react-i18next';
|
|
8
|
+
import { FaCheck, FaTimes, FaPlus, FaSearch } from 'react-icons/fa';
|
|
9
|
+
import { getDefaultForType } from '../../src/data.js';
|
|
10
|
+
import { useAuthContext } from './contexts/AuthContext.jsx';
|
|
11
|
+
|
|
12
|
+
// These API functions can be moved to a dedicated service file later.
|
|
13
|
+
const insertDataAPI = async (modelName, data) => {
|
|
14
|
+
const response = await fetch(`/api/data`, {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: { 'Content-Type': 'application/json' },
|
|
17
|
+
body: JSON.stringify({model: modelName,data})
|
|
18
|
+
});
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
const error = await response.json();
|
|
21
|
+
throw new Error(error.error || 'Failed to insert data');
|
|
22
|
+
}
|
|
23
|
+
return response.json();
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const searchDataAPI = async (modelName, filter) => {
|
|
27
|
+
const params = new URLSearchParams({ model: modelName, depth: '1' });
|
|
28
|
+
const response = await fetch(`/api/data/search?${params.toString()}`, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
body: JSON.stringify({ filter })
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
const error = await response.json();
|
|
35
|
+
throw new Error(error.error || 'Failed to search data');
|
|
36
|
+
}
|
|
37
|
+
return response.json();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const RelationSelectorWidget = ({ modelName, initialSelection, isMultiple, onValidate, onCancel }) => {
|
|
41
|
+
const { t } = useTranslation();
|
|
42
|
+
const { models, setSelectedModel } = useModelContext();
|
|
43
|
+
const queryClient = useQueryClient();
|
|
44
|
+
const { me } = useAuthContext();
|
|
45
|
+
const [isCreating, setIsCreating] = useState(false);
|
|
46
|
+
const [newRecord, setNewRecord] = useState(null);
|
|
47
|
+
|
|
48
|
+
const [filterValues, setFilterValues] = useState({});
|
|
49
|
+
|
|
50
|
+
// DataTable's `checkedItems` expects an array of objects with at least an `_id` property.
|
|
51
|
+
// `initialSelection` is an array of string IDs. We convert it.
|
|
52
|
+
const [selection, setSelection] = useState(() => initialSelection.map(id => ({ _id: id })));
|
|
53
|
+
|
|
54
|
+
const model = models.find(m => m.name === modelName);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (model) {
|
|
58
|
+
setSelectedModel(model);
|
|
59
|
+
}
|
|
60
|
+
}, [model, setSelectedModel]);
|
|
61
|
+
|
|
62
|
+
const mutation = useMutation(
|
|
63
|
+
(formData) => insertDataAPI(model.name, formData),
|
|
64
|
+
{
|
|
65
|
+
onSuccess: async (result) => {
|
|
66
|
+
if (result.success && result.insertedIds?.length > 0) {
|
|
67
|
+
queryClient.invalidateQueries(['api/data', modelName, 'page']);
|
|
68
|
+
const newId = result.insertedIds[0];
|
|
69
|
+
const searchResult = await searchDataAPI(model.name, { _id: newId });
|
|
70
|
+
if (searchResult.data && searchResult.data.length > 0) {
|
|
71
|
+
handleCreationSuccess(searchResult.data[0]);
|
|
72
|
+
} else {
|
|
73
|
+
console.error('Could not fetch the newly created item.');
|
|
74
|
+
handleCancelCreation();
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
console.error('Creation failed:', result.error);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
onError: (error) => {
|
|
81
|
+
console.error('Creation mutation failed:', error.message);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const handleCreationSuccess = (newItem) => {
|
|
87
|
+
setIsCreating(false);
|
|
88
|
+
setNewRecord(null);
|
|
89
|
+
if (isMultiple) {
|
|
90
|
+
setSelection(current => [...current, newItem]);
|
|
91
|
+
} else {
|
|
92
|
+
setSelection([newItem]);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleStartCreation = () => {
|
|
97
|
+
const defaults = model.fields.reduce((acc, field) => {
|
|
98
|
+
acc[field.name] = getDefaultForType(field);
|
|
99
|
+
return acc;
|
|
100
|
+
}, {});
|
|
101
|
+
setNewRecord(defaults);
|
|
102
|
+
setIsCreating(true);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleCancelCreation = () => {
|
|
106
|
+
setIsCreating(false);
|
|
107
|
+
setNewRecord(null);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleSave = (formData) => {
|
|
111
|
+
mutation.mutate(formData);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (!model) {
|
|
115
|
+
return <div>{t('loading', 'Chargement...')}</div>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const handleValidate = () => {
|
|
119
|
+
// `onValidate` expects an array of full objects to update the display correctly.
|
|
120
|
+
onValidate(selection);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const handleCheckboxChange = (items) => {
|
|
124
|
+
if (!isMultiple) {
|
|
125
|
+
// For single selection, we only keep the last selected item.
|
|
126
|
+
// DataTable's setCheckedItems gives the full list.
|
|
127
|
+
const lastItem = items.length > 0 ? [items[items.length - 1]] : [];
|
|
128
|
+
setSelection(lastItem);
|
|
129
|
+
} else {
|
|
130
|
+
setSelection(items);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
if (isCreating) {
|
|
135
|
+
return (
|
|
136
|
+
<div className="relation-selector-widget">
|
|
137
|
+
<Button onClick={handleCancelCreation} className="btn-secondary">
|
|
138
|
+
<FaSearch /> {t('btns.backToSearch', 'Retour à la recherche')}
|
|
139
|
+
</Button>
|
|
140
|
+
<ModelProvider>
|
|
141
|
+
<DataEditor
|
|
142
|
+
model={model}
|
|
143
|
+
formData={newRecord}
|
|
144
|
+
setFormData={setNewRecord}
|
|
145
|
+
onSubmit={handleSave}
|
|
146
|
+
onCancel={handleCancelCreation}
|
|
147
|
+
isLoading={mutation.isLoading}
|
|
148
|
+
hideNewButton={true}
|
|
149
|
+
/>
|
|
150
|
+
</ModelProvider>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div className="relation-selector-widget">
|
|
157
|
+
<DataTable model={model} filterValues={filterValues} setFilterValues={setFilterValues} checkedItems={selection} setCheckedItems={handleCheckboxChange} selectionMode={true} />
|
|
158
|
+
<div className="flex actions right">
|
|
159
|
+
<Button onClick={handleStartCreation} className="btn-secondary" style={{ marginRight: 'auto' }}>
|
|
160
|
+
<FaPlus /> {t('btns.create', 'Créer')}
|
|
161
|
+
</Button>
|
|
162
|
+
<Button onClick={onCancel} className="btn-secondary">
|
|
163
|
+
<FaTimes /> {t('btns.cancel', 'Annuler')}
|
|
164
|
+
</Button>
|
|
165
|
+
<Button onClick={handleValidate} className="btn-primary">
|
|
166
|
+
<FaCheck /> {t('btns.validate', 'Valider')}
|
|
167
|
+
</Button>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
173
|
export default RelationSelectorWidget;
|
|
@@ -52,7 +52,8 @@ const RelationValue = ({ field, data, align }) => {
|
|
|
52
52
|
});
|
|
53
53
|
},
|
|
54
54
|
{ enabled: !!model && !!value, onSettled: (data) => {
|
|
55
|
-
|
|
55
|
+
if( !data)
|
|
56
|
+
return;
|
|
56
57
|
const dt = {...relations};
|
|
57
58
|
dt['api/data'+model.name+getObjectHash({ids})] = { count: data.length, data };
|
|
58
59
|
setRelations(dt);
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import React, {createContext, useContext, useState, useCallback, useRef, useEffect, useMemo} from 'react';
|
|
2
|
+
import { useQueryClient } from 'react-query';
|
|
3
|
+
import { useNotificationContext } from '../NotificationProvider.jsx';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import {elementsPerPage} from "../../../src/constants.js";
|
|
6
|
+
import {useModelContext} from "./ModelContext.jsx";
|
|
7
|
+
|
|
8
|
+
const CommandContext = createContext({});
|
|
9
|
+
|
|
10
|
+
export const useCommand = () => useContext(CommandContext);
|
|
11
|
+
|
|
12
|
+
// --- Command Manager ---
|
|
13
|
+
class CommandManager {
|
|
14
|
+
constructor() {
|
|
15
|
+
this.history = [];
|
|
16
|
+
this.currentIndex = -1;
|
|
17
|
+
// Les dépendances seront injectées via updateDependencies
|
|
18
|
+
this.addNotification = () => {};
|
|
19
|
+
this.t = (key) => key;
|
|
20
|
+
this.onResetQueryClient = () => {};
|
|
21
|
+
this.queryClient = null; // Ajout pour stocker le queryClient
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
updateDependencies(addNotification, t, onResetQueryClient, queryClient) {
|
|
25
|
+
this.addNotification = addNotification;
|
|
26
|
+
this.t = t;
|
|
27
|
+
this.onResetQueryClient = onResetQueryClient;
|
|
28
|
+
this.queryClient = queryClient; // Injection du client
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async execute(command) {
|
|
32
|
+
try {
|
|
33
|
+
// Supprimer l'historique "redo" si on exécute une nouvelle commande
|
|
34
|
+
this.history = this.history.slice(0, this.currentIndex + 1);
|
|
35
|
+
await command.execute();
|
|
36
|
+
this.history.push(command);
|
|
37
|
+
this.currentIndex++;
|
|
38
|
+
await this.invalidateQueries(command.modelName, command.constructor.name);
|
|
39
|
+
// On vide la sélection APRÈS avoir invalidé, pour éviter les conflits de rendu.
|
|
40
|
+
if (command instanceof DeleteCommand && this.context?.setCheckedItems) {
|
|
41
|
+
this.context.setCheckedItems([]);
|
|
42
|
+
}
|
|
43
|
+
this.addNotification({ title: command.successMessage, status: 'completed' });
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error("Command execution failed:", error);
|
|
46
|
+
this.addNotification({ title: this.t('command.error.execute', 'Erreur d\'exécution'), message: error.message, status: 'error' });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async undo() {
|
|
51
|
+
if (this.canUndo()) {
|
|
52
|
+
try {
|
|
53
|
+
const command = this.history[this.currentIndex];
|
|
54
|
+
await command.undo();
|
|
55
|
+
this.currentIndex--;
|
|
56
|
+
await this.invalidateQueries(command.modelName, command.constructor.name + 'Undo');
|
|
57
|
+
this.addNotification({ title: this.t('command.success.undo', 'Action annulée'), status: 'completed' });
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error("Command undo failed:", error);
|
|
60
|
+
this.addNotification({ title: this.t('command.error.undo', 'Erreur d\'annulation'), message: error.message, status: 'error' });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async redo() {
|
|
66
|
+
if (this.canRedo()) {
|
|
67
|
+
try {
|
|
68
|
+
this.currentIndex++;
|
|
69
|
+
const command = this.history[this.currentIndex];
|
|
70
|
+
await command.execute();
|
|
71
|
+
await this.invalidateQueries(command.modelName, command.constructor.name);
|
|
72
|
+
this.addNotification({ title: this.t('command.success.redo', 'Action rétablie'), status: 'completed' });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error("Command redo failed:", error);
|
|
75
|
+
this.addNotification({ title: this.t('command.error.redo', 'Erreur de rétablissement'), message: error.message, status: 'error' });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
canUndo() {
|
|
81
|
+
return this.currentIndex >= 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
canRedo() {
|
|
85
|
+
return this.currentIndex < this.history.length - 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async invalidateQueries(modelName, commandType) {
|
|
89
|
+
// Solution plus fine : on invalide seulement les requêtes concernées.
|
|
90
|
+
// React Query s'occupera de rafraîchir les données de manière optimisée.
|
|
91
|
+
if (this.queryClient && modelName) {
|
|
92
|
+
// Invalide toutes les requêtes liées à ce modèle (listes, paginations, etc.)
|
|
93
|
+
await this.queryClient.invalidateQueries(['api/data', modelName]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- Command Definitions ---
|
|
99
|
+
|
|
100
|
+
export class InsertCommand {
|
|
101
|
+
constructor(apiCall, modelName, formData) {
|
|
102
|
+
this.apiCall = apiCall;
|
|
103
|
+
this.modelName = modelName;
|
|
104
|
+
this.formData = { ...formData };
|
|
105
|
+
this.insertedItem = null; // On va stocker l'objet complet inséré
|
|
106
|
+
this.successMessage = "Donnée ajoutée";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async execute() {
|
|
110
|
+
// Si on a déjà un item (cas du redo), on le ré-insère. Sinon, on utilise le formData initial.
|
|
111
|
+
const dataToInsert = this.insertedItem ? { ...this.insertedItem, _id: undefined, _hash: undefined } : this.formData;
|
|
112
|
+
|
|
113
|
+
const response = await this.apiCall({ formData: dataToInsert, record: null });
|
|
114
|
+
if (!response.success) throw new Error(response.error || 'Insert failed');
|
|
115
|
+
|
|
116
|
+
// On stocke l'objet complet retourné par l'API, qui inclut le nouvel _id.
|
|
117
|
+
this.insertedItem = response.data;
|
|
118
|
+
if (!this.insertedItem?._id) throw new Error('No inserted data returned from API');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async undo() {
|
|
122
|
+
// On garde une copie de l'item avant de le supprimer pour le redo.
|
|
123
|
+
if (!this.insertedItem?._id) throw new Error("Cannot undo insert: item ID is missing.");
|
|
124
|
+
const response = await fetch(`/api/data/${this.insertedItem._id}`, { method: 'DELETE' });
|
|
125
|
+
const result = await response.json();
|
|
126
|
+
if (!response.ok || !result.success) {
|
|
127
|
+
throw new Error(result.error || 'Undo (delete) failed');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export class UpdateCommand {
|
|
133
|
+
constructor(apiCall, modelName, originalData, newData) {
|
|
134
|
+
this.apiCall = apiCall;
|
|
135
|
+
this.modelName = modelName;
|
|
136
|
+
this.originalData = { ...originalData }; // Copie pour l'undo
|
|
137
|
+
this.newData = { ...newData };
|
|
138
|
+
this.recordId = originalData._id;
|
|
139
|
+
this.successMessage = "Donnée mise à jour";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async execute() {
|
|
143
|
+
const response = await this.apiCall({ formData: this.newData, record: this.originalData });
|
|
144
|
+
if (!response.success) throw new Error(response.error || 'Update failed');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async undo() {
|
|
148
|
+
// Pour annuler, on ré-applique les données originales
|
|
149
|
+
await this.apiCall({ formData: this.originalData, record: this.originalData });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export class DeleteCommand {
|
|
154
|
+
constructor(apiCall, modelName, itemsToDelete) { // queryClient a été retiré
|
|
155
|
+
this.apiCall = apiCall;
|
|
156
|
+
this.modelName = modelName;
|
|
157
|
+
this.itemsToDelete = Array.isArray(itemsToDelete) ? [...itemsToDelete] : [itemsToDelete];
|
|
158
|
+
this.successMessage = "Donnée(s) supprimée(s)";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async execute() {
|
|
162
|
+
const response = await this.apiCall(this.itemsToDelete);
|
|
163
|
+
if (!response.success) throw new Error(response.error || 'Delete failed');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async undo() {
|
|
167
|
+
// Pour annuler, on ré-insère chaque document supprimé
|
|
168
|
+
// Note : cela crée de nouveaux _id, mais restaure les données.
|
|
169
|
+
const reinsertPromises = this.itemsToDelete.map(item => {
|
|
170
|
+
const reinsertData = { ...item };
|
|
171
|
+
delete reinsertData._id; // L'API doit générer un nouvel ID
|
|
172
|
+
delete reinsertData._hash;
|
|
173
|
+
|
|
174
|
+
return fetch('/api/data?_user=system', { // On ajoute _user=system pour tracer l'origine de l'action
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: { 'Content-Type': 'application/json' },
|
|
177
|
+
body: JSON.stringify({ model: this.modelName, data: reinsertData }),
|
|
178
|
+
}).then(res => res.json().then(data => {
|
|
179
|
+
if (!res.ok || !data.success) {
|
|
180
|
+
throw new Error(data.error || `Undo (re-insert) failed for item ${item._id}.`);
|
|
181
|
+
}
|
|
182
|
+
// On retourne l'objet complet retourné par l'API, qui contient le nouvel _id
|
|
183
|
+
return data.data;
|
|
184
|
+
}));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// On attend que toutes les réinsertions soient terminées.
|
|
188
|
+
const reinsertedItems = await Promise.all(reinsertPromises);
|
|
189
|
+
|
|
190
|
+
// *** CORRECTION CRUCIALE ***
|
|
191
|
+
// On met à jour la liste des items de la commande avec les nouvelles données (et les nouveaux _id).
|
|
192
|
+
// Ainsi, si un "redo" est exécuté, il ciblera les bons documents à supprimer.
|
|
193
|
+
this.itemsToDelete = reinsertedItems;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- Singleton Command Manager ---
|
|
198
|
+
// On crée l'instance du manager EN DEHORS du composant React.
|
|
199
|
+
// Ainsi, elle ne sera pas détruite et recréée à chaque re-rendu de l'application,
|
|
200
|
+
// ce qui permet de conserver l'historique des commandes (undo/redo).
|
|
201
|
+
const commandManagerInstance = new CommandManager();
|
|
202
|
+
|
|
203
|
+
// --- Provider Component ---
|
|
204
|
+
export const CommandProvider = ({ children, onResetQueryClient }) => {
|
|
205
|
+
const { addNotification } = useNotificationContext();
|
|
206
|
+
const { t, i18n } = useTranslation();
|
|
207
|
+
const queryClient = useQueryClient(); // On récupère le client ici
|
|
208
|
+
const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
|
|
209
|
+
|
|
210
|
+
// On utilise l'instance unique et on met à jour ses dépendances via useEffect
|
|
211
|
+
// pour s'assurer qu'elle a toujours les dernières fonctions (qui sont recréées à chaque rendu).
|
|
212
|
+
const commandManagerRef = useRef(commandManagerInstance);
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
commandManagerInstance.updateDependencies(addNotification, t, onResetQueryClient, queryClient);
|
|
215
|
+
}, [addNotification, t, onResetQueryClient, queryClient]);
|
|
216
|
+
|
|
217
|
+
const [canUndo, setCanUndo] = useState(commandManagerRef.current.canUndo());
|
|
218
|
+
const [canRedo, setCanRedo] = useState(commandManagerRef.current.canRedo());
|
|
219
|
+
|
|
220
|
+
const updateUndoRedoState = () => {
|
|
221
|
+
setCanUndo(commandManagerRef.current.canUndo());
|
|
222
|
+
setCanRedo(commandManagerRef.current.canRedo());
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const execute = async (command) => {
|
|
226
|
+
await commandManagerRef.current.execute(command);
|
|
227
|
+
updateUndoRedoState();
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const undo = async () => {
|
|
231
|
+
await commandManagerRef.current.undo();
|
|
232
|
+
updateUndoRedoState();
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const redo = async () => {
|
|
236
|
+
await commandManagerRef.current.redo();
|
|
237
|
+
updateUndoRedoState();
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// --- CORRECTION ---
|
|
241
|
+
// On inclut `setManagerContext` directement dans la valeur du contexte
|
|
242
|
+
// et on mémorise l'objet avec `useMemo` pour la stabilité.
|
|
243
|
+
const value = useMemo(() => ({
|
|
244
|
+
execute,
|
|
245
|
+
undo,
|
|
246
|
+
redo,
|
|
247
|
+
canUndo,
|
|
248
|
+
canRedo,
|
|
249
|
+
InsertCommand,
|
|
250
|
+
UpdateCommand,
|
|
251
|
+
DeleteCommand,
|
|
252
|
+
setManagerContext: (context) => {
|
|
253
|
+
commandManagerRef.current.context = context;
|
|
254
|
+
}
|
|
255
|
+
}), [canUndo, canRedo]); // Dépendances pour la mémorisation
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<CommandContext.Provider value={value}>{children}</CommandContext.Provider>
|
|
259
|
+
);
|
|
260
|
+
};
|
|
@@ -102,7 +102,10 @@ export const ModelProvider = ({ children }) => {
|
|
|
102
102
|
params.append("_user", getUserId(me));
|
|
103
103
|
params.append("depth", '1')
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
// SOLUTION : On trie les IDs pour garantir une queryKey stable,
|
|
106
|
+
// peu importe l'ordre dans lequel les relations ont été chargées.
|
|
107
|
+
let ids = [...new Set(rels.map(r => r._id || r))].sort().join(',');
|
|
108
|
+
|
|
106
109
|
if (rels && rels.length > 0) {
|
|
107
110
|
// Cas des relations : requête par IDs, sans pagination
|
|
108
111
|
params.append('ids', ids);
|