data-primals-engine 1.4.2 → 1.4.3
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 +14 -3
- package/client/package-lock.json +33 -0
- package/client/package.json +1 -0
- package/client/src/App.scss +12 -4
- package/client/src/ConditionBuilder.jsx +1 -1
- package/client/src/ConditionBuilder2.jsx +2 -1
- package/client/src/DataEditor.jsx +376 -368
- package/client/src/DataLayout.jsx +3 -2
- package/client/src/DataTable.jsx +836 -817
- package/client/src/Field.jsx +7 -3
- package/client/src/GeolocationField.jsx +94 -0
- package/client/src/ModelCreatorField.jsx +1 -0
- package/client/src/RelationField.jsx +2 -2
- package/client/src/constants.js +2 -2
- package/client/src/filter.js +0 -155
- package/client/src/translations.js +12 -2
- package/package.json +1 -1
- package/src/constants.js +262 -4
- package/src/defaultModels.js +12 -12
- package/src/engine.js +5 -0
- package/src/filter.js +272 -260
- package/src/i18n.js +187 -177
- package/src/modules/data/data.core.js +2 -1
- package/src/modules/data/data.operations.js +2999 -2789
- package/src/modules/data/data.validation.js +3 -0
- package/swagger-en.yml +133 -0
- package/test/model.integration.test.js +377 -221
package/client/src/DataTable.jsx
CHANGED
|
@@ -1,817 +1,836 @@
|
|
|
1
|
-
// Composant pour afficher les données (DataTable basique)
|
|
2
|
-
import {useModelContext} from "./contexts/ModelContext.jsx";
|
|
3
|
-
import {Trans, useTranslation} from "react-i18next";
|
|
4
|
-
import {useMutation, useQuery, useQueryClient} from "react-query";
|
|
5
|
-
import {useAuthContext} from "./contexts/AuthContext.jsx";
|
|
6
|
-
import React, {useEffect, useMemo, useRef, useState} from "react";
|
|
7
|
-
import {getUserId} from "../../src/data.js";
|
|
8
|
-
import cronstrue from 'cronstrue/i18n';
|
|
9
|
-
import {Event} from "../../src/events.js";
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
FaBook,
|
|
13
|
-
FaCopy,
|
|
14
|
-
FaDatabase, FaEraser,
|
|
15
|
-
FaFileExport,
|
|
16
|
-
FaFileImport,
|
|
17
|
-
FaFilter, FaHistory,
|
|
18
|
-
FaInfo,
|
|
19
|
-
FaPlus,
|
|
20
|
-
FaTrash, FaWrench
|
|
21
|
-
} from "react-icons/fa";
|
|
22
|
-
import {useNotificationContext} from "./NotificationProvider.jsx";
|
|
23
|
-
import {
|
|
24
|
-
elementsPerPage,
|
|
25
|
-
kilobytes,
|
|
26
|
-
maxBytesPerSecondThrottleData,
|
|
27
|
-
maxFileSize,
|
|
28
|
-
maxRequestData
|
|
29
|
-
} from "../../src/constants.js";
|
|
30
|
-
import Button from "./Button.jsx";
|
|
31
|
-
import {Dialog, DialogProvider} from "./Dialog.jsx";
|
|
32
|
-
import {
|
|
33
|
-
CheckboxField,
|
|
34
|
-
CodeField,
|
|
35
|
-
ColorField,
|
|
36
|
-
FileField,
|
|
37
|
-
FilterField,
|
|
38
|
-
ModelField,
|
|
39
|
-
PhoneField,
|
|
40
|
-
TextField
|
|
41
|
-
} from "./Field.jsx";
|
|
42
|
-
import RelationValue from "./RelationValue.jsx";
|
|
43
|
-
import {FaGear, FaPencil, FaTriangleExclamation} from "react-icons/fa6";
|
|
44
|
-
import RestoreConfirmationModal from "./RestoreConfirmationModal.jsx";
|
|
45
|
-
import {event_trigger, isLightColor} from "../../src/core.js";
|
|
46
|
-
import {Tooltip} from "react-tooltip";
|
|
47
|
-
import ExportDialog from "./ExportDialog.jsx";
|
|
48
|
-
import Captions from "yet-another-react-lightbox/plugins/captions";
|
|
49
|
-
import {Zoom} from "yet-another-react-lightbox/plugins";
|
|
50
|
-
import Lightbox from "yet-another-react-lightbox";
|
|
51
|
-
|
|
52
|
-
import "./DataTable.scss"
|
|
53
|
-
import useLocalStorage from "./hooks/useLocalStorage.js";
|
|
54
|
-
import TutorialsMenu from "../src/TutorialsMenu.jsx";
|
|
55
|
-
import {useTutorials} from "./hooks/useTutorials.jsx";
|
|
56
|
-
import PackGallery from "./PackGallery.jsx";
|
|
57
|
-
import {HiddenableCell} from "./HiddenableCell.jsx";
|
|
58
|
-
import ConditionBuilder from "./ConditionBuilder.jsx";
|
|
59
|
-
import {pagedFilterToMongoConds} from "./filter.js";
|
|
60
|
-
import {isConditionMet} from "../../src/filter";
|
|
61
|
-
import {DataImporter} from "./DataImporter.jsx";
|
|
62
|
-
import {HistoryDialog} from "./HistoryDialog.jsx";
|
|
63
|
-
|
|
64
|
-
const Header = ({
|
|
65
|
-
reversed = false,
|
|
66
|
-
handleFilter,
|
|
67
|
-
model,
|
|
68
|
-
setCheckedItems,
|
|
69
|
-
checkedItems,
|
|
70
|
-
data,
|
|
71
|
-
filterActive,
|
|
72
|
-
onChangeFilterValue,
|
|
73
|
-
setFilterValues,
|
|
74
|
-
filterValues,
|
|
75
|
-
advanced=true
|
|
76
|
-
}) => {
|
|
77
|
-
|
|
78
|
-
const {t} = useTranslation()
|
|
79
|
-
const { me } = useAuthContext()
|
|
80
|
-
const {
|
|
81
|
-
models,
|
|
82
|
-
countByModel,
|
|
83
|
-
selectedModel,
|
|
84
|
-
setPagedFilters,
|
|
85
|
-
pagedFilters,
|
|
86
|
-
page
|
|
87
|
-
} = useModelContext();
|
|
88
|
-
let totalCol = 0;
|
|
89
|
-
|
|
90
|
-
const [advancedFilterVisible, setAdvancedFilterVisible] = useState(false);
|
|
91
|
-
|
|
92
|
-
const handleAdvancedFilter = () => {
|
|
93
|
-
setAdvancedFilterVisible(true);
|
|
94
|
-
}
|
|
95
|
-
return <><tr className={reversed ? ' reversed' : ''}>
|
|
96
|
-
{advanced && (<th className={"mini"}>
|
|
97
|
-
<div className="flex flex-row">
|
|
98
|
-
|
|
99
|
-
<Button onClick={handleFilter} className={filterActive ? ' active' : ''}><FaFilter/></Button>
|
|
100
|
-
{filterActive && <Button onClick={() => handleAdvancedFilter()}><FaWrench /></Button>}
|
|
101
|
-
<CheckboxField checked={checkedItems?.length === data.length} onChange={e => {
|
|
102
|
-
if (checkedItems?.length === data.length) {
|
|
103
|
-
setCheckedItems([]);
|
|
104
|
-
} else {
|
|
105
|
-
setCheckedItems(data);
|
|
106
|
-
}
|
|
107
|
-
}}/>
|
|
108
|
-
|
|
109
|
-
<DialogProvider>
|
|
110
|
-
{advancedFilterVisible && (
|
|
111
|
-
<Dialog title={t("datatable.advancedFilter.title")} isClosable={true} onClose={() => setAdvancedFilterVisible(false)}>
|
|
112
|
-
<h3>Edit filter</h3>
|
|
113
|
-
<div className="msg">
|
|
114
|
-
<Trans i18nKey={"datatable.advancedFilter.desc"}>{t("datatable.advancedFilter.desc")}</Trans>
|
|
115
|
-
</div>
|
|
116
|
-
<ConditionBuilder onChange={(c) => {
|
|
117
|
-
setPagedFilters(pagedFilters => ({
|
|
118
|
-
...pagedFilters,
|
|
119
|
-
[model.name]: c || pagedFilters[model.name] || {} }));
|
|
120
|
-
}} initialValue={{ $and: pagedFilterToMongoConds(pagedFilters, model)}} models={models} model={model.name} checkedItems={checkedItems} setCheckedItems={setCheckedItems} data={data} filterActive={filterActive} onChangeFilterValue={onChangeFilterValue} setFilterValues={setFilterValues}/>
|
|
121
|
-
<Button onClick={() =>{
|
|
122
|
-
setPagedFilters(pagedFilters => ({
|
|
123
|
-
...pagedFilters,
|
|
124
|
-
[model.name]: {}}));
|
|
125
|
-
}}><Trans i18nKey={"btns.reset"}>Reset</Trans></Button>
|
|
126
|
-
</Dialog>
|
|
127
|
-
)}
|
|
128
|
-
</DialogProvider>
|
|
129
|
-
</div>
|
|
130
|
-
</th>)}
|
|
131
|
-
{(model?.fields || []).map(field => {
|
|
132
|
-
if (field.type === 'password')
|
|
133
|
-
return <></>;
|
|
134
|
-
if( field.type === 'relation' && !models.find(f => f.name === field.relation && f._user === me?.username ))
|
|
135
|
-
return <th className={"empty"} key={"datatable-th-"+model.name+":"+field.name}>
|
|
136
|
-
<div className="flex flex-mini-gap flex-centered">
|
|
137
|
-
<FaTriangleExclamation color={"#df8107"} data-tooltip-id={`tooltip-desc`} data-tooltip-content={"\""+field.relation + "\" model not found"} />
|
|
138
|
-
{field.name}
|
|
139
|
-
<Tooltip id={"tooltip-desc"} render={({content}) => {
|
|
140
|
-
gtag('render hint '+field.relation);
|
|
141
|
-
return content;
|
|
142
|
-
}} />
|
|
143
|
-
</div>
|
|
144
|
-
</th>;
|
|
145
|
-
|
|
146
|
-
totalCol++;
|
|
147
|
-
return <FilterField advanced={advanced} reversed={reversed} filterValues={filterValues} setFilterValues={setFilterValues}
|
|
148
|
-
model={model} field={field} active={filterActive}
|
|
149
|
-
onChangeFilterValue={onChangeFilterValue}/>;
|
|
150
|
-
})}
|
|
151
|
-
{advanced && (<th><Trans i18nKey="actions">Actions</Trans>
|
|
152
|
-
{Object.keys(pagedFilters[model.name] || {})
|
|
153
|
-
.filter(f=> Object.keys(pagedFilters[model.name]?.[f] || {}).length > 0).length > 0 && <div>
|
|
154
|
-
<button onClick={() => {
|
|
155
|
-
setFilterValues({});
|
|
156
|
-
setPagedFilters(pagedFilters => ({...pagedFilters, [model.name]: {}}));
|
|
157
|
-
}}><FaEraser />
|
|
158
|
-
</button>
|
|
159
|
-
</div>}</th>)}
|
|
160
|
-
</tr>
|
|
161
|
-
{reversed && (
|
|
162
|
-
<tr>
|
|
163
|
-
{countByModel?.[selectedModel?.name] === 0 &&
|
|
164
|
-
<td colSpan={totalCol + 2}><Trans i18nKey="no_data">Aucune donnée enregistrée</Trans></td>}
|
|
165
|
-
</tr>)}
|
|
166
|
-
</>
|
|
167
|
-
;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const formatDuration = (totalSeconds, t) => {
|
|
171
|
-
if (totalSeconds === null || totalSeconds === undefined || isNaN(totalSeconds) || totalSeconds === '') {
|
|
172
|
-
return '';
|
|
173
|
-
}
|
|
174
|
-
const total = parseInt(totalSeconds, 10);
|
|
175
|
-
if (total === 0) return '0s';
|
|
176
|
-
|
|
177
|
-
const d = Math.floor(total / 86400);
|
|
178
|
-
const h = Math.floor((total % 86400) / 3600);
|
|
179
|
-
const m = Math.floor((total % 3600) / 60);
|
|
180
|
-
const s = Math.floor(total % 60);
|
|
181
|
-
|
|
182
|
-
const parts = [];
|
|
183
|
-
if (d > 0) parts.push(t('duration.day', { count: d }));
|
|
184
|
-
if (h > 0) parts.push(t('duration.hour', { count: h }));
|
|
185
|
-
if (m > 0) parts.push(t('duration.minute', { count: m }));
|
|
186
|
-
if (s > 0) parts.push(t('duration.second', { count: s }));
|
|
187
|
-
|
|
188
|
-
if (parts.length === 0) return '0s';
|
|
189
|
-
|
|
190
|
-
return parts.slice(0, 2).join(', ');
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const RichText = ({ value, initialLang }) => {
|
|
195
|
-
const availableLangs = useMemo(() => (value ? Object.keys(value).filter(k => value[k]) : []), [value]);
|
|
196
|
-
|
|
197
|
-
const [selectedLang, setSelectedLang] = useState(() => {
|
|
198
|
-
if (availableLangs.includes(initialLang)) {
|
|
199
|
-
return initialLang;
|
|
200
|
-
}
|
|
201
|
-
return availableLangs[0] || null;
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
useEffect(() => {
|
|
205
|
-
setSelectedLang(initialLang);
|
|
206
|
-
}, [initialLang]);
|
|
207
|
-
|
|
208
|
-
if (!value || availableLangs.length === 0) {
|
|
209
|
-
return null;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (availableLangs.length === 1) {
|
|
213
|
-
return <div className="rte-value" dangerouslySetInnerHTML={{ __html: value[availableLangs[0]] || '' }} />;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return (
|
|
217
|
-
<div className="richtext-t-cell">
|
|
218
|
-
<div className="lang-switcher">
|
|
219
|
-
{availableLangs.map(langCode => (
|
|
220
|
-
<button
|
|
221
|
-
key={langCode}
|
|
222
|
-
className={`lang-btn ${selectedLang === langCode ? 'active' : ''}`}
|
|
223
|
-
onClick={(e) => {
|
|
224
|
-
e.stopPropagation();
|
|
225
|
-
setSelectedLang(langCode);
|
|
226
|
-
}}
|
|
227
|
-
title={langCode.toUpperCase()}
|
|
228
|
-
>
|
|
229
|
-
{langCode.toUpperCase()}
|
|
230
|
-
</button>
|
|
231
|
-
))}
|
|
232
|
-
</div>
|
|
233
|
-
<div className="rte-value" dangerouslySetInnerHTML={{ __html: value[selectedLang] || '' }} />
|
|
234
|
-
</div>
|
|
235
|
-
);
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
export function DataTable({
|
|
239
|
-
model,
|
|
240
|
-
checkedItems,
|
|
241
|
-
setCheckedItems = () => {},
|
|
242
|
-
onEdit,
|
|
243
|
-
onAddData,
|
|
244
|
-
onDuplicateData,
|
|
245
|
-
onDelete,
|
|
246
|
-
onShowAPI,
|
|
247
|
-
filterValues,
|
|
248
|
-
setFilterValues = () => {},
|
|
249
|
-
data: propData, // NOUVEAU: Accepter les données via les props
|
|
250
|
-
advanced=true
|
|
251
|
-
|
|
252
|
-
}) {
|
|
253
|
-
const {
|
|
254
|
-
models,
|
|
255
|
-
elementsPerPage,
|
|
256
|
-
paginatedDataByModel,
|
|
257
|
-
countByModel,
|
|
258
|
-
pagedSort,
|
|
259
|
-
selectedModel,
|
|
260
|
-
pagedFilters,
|
|
261
|
-
setPagedFilters,
|
|
262
|
-
page
|
|
263
|
-
} = useModelContext();
|
|
264
|
-
const {t, i18n} = useTranslation();
|
|
265
|
-
const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
|
|
266
|
-
const queryClient = useQueryClient();
|
|
267
|
-
const {me} = useAuthContext();
|
|
268
|
-
|
|
269
|
-
// Si des données sont passées en props, on les utilise, sinon on prend celles du contexte.
|
|
270
|
-
const data = propData || paginatedDataByModel[model?.name] || [];
|
|
271
|
-
|
|
272
|
-
const isDataLoaded = true;
|
|
273
|
-
const [importVisible, setImportVisible] = useState(false);
|
|
274
|
-
const [filterActive, setFilterActive] = useState(false)
|
|
275
|
-
|
|
276
|
-
const [showPackGallery, setShowPackGallery] = useState(false);
|
|
277
|
-
const [showExportDialog, setExportDialogVisible] = useState(false);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const [selectedRow, setSelectedRow] = useState(null);
|
|
281
|
-
|
|
282
|
-
const [showAddPack, setAddPackVisible] = useState(false);
|
|
283
|
-
const handleAddPack = () => {
|
|
284
|
-
setAddPackVisible(!showAddPack);
|
|
285
|
-
}
|
|
286
|
-
const handleShowPacks = () => {
|
|
287
|
-
setShowPackGallery(true);
|
|
288
|
-
};
|
|
289
|
-
const handleDelete = async (item) => {
|
|
290
|
-
if (model && item && item._id) { // Assurez-vous d'avoir un identifiant unique pour chaque élément
|
|
291
|
-
try {
|
|
292
|
-
const response = await fetch(`/api/data/${item._id}?_user=${encodeURIComponent(getUserId(me))}`, { // Assurez-vous que l'URL est correcte
|
|
293
|
-
method: 'DELETE',
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
if (response.ok) {
|
|
297
|
-
const res = await response.json();
|
|
298
|
-
|
|
299
|
-
if (res.success) {
|
|
300
|
-
onDelete?.(item);
|
|
301
|
-
const notificationData = {
|
|
302
|
-
id: 'datatable.deleteData.success',
|
|
303
|
-
title: t('datatable.deleteData.success', 'Données supprimées avec succès'),
|
|
304
|
-
icon: <FaInfo/>,
|
|
305
|
-
status: 'completed'
|
|
306
|
-
};
|
|
307
|
-
addNotification(notificationData);
|
|
308
|
-
} else {
|
|
309
|
-
const notificationData = {
|
|
310
|
-
title: res.message,
|
|
311
|
-
status: 'error'
|
|
312
|
-
};
|
|
313
|
-
addNotification(notificationData);
|
|
314
|
-
console.log('Données non trouvées');
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
await Event.Trigger('API_DELETE_DATA', "custom", "data",{ model: item._model, id: item._id });
|
|
318
|
-
queryClient.invalidateQueries(['api/data', item._model, 'page', page, elementsPerPage, pagedFilters[item._model], pagedSort[item._model]]);
|
|
319
|
-
|
|
320
|
-
} else {
|
|
321
|
-
console.error('Erreur lors de la suppression des données');
|
|
322
|
-
// Gérer les erreurs (afficher un message à l'utilisateur, etc.)
|
|
323
|
-
}
|
|
324
|
-
} catch (error) {
|
|
325
|
-
const notificationData = {
|
|
326
|
-
title: error.message,
|
|
327
|
-
status: 'error'
|
|
328
|
-
};
|
|
329
|
-
addNotification(notificationData);
|
|
330
|
-
console.error('Erreur lors de la suppression des données:', error);
|
|
331
|
-
}
|
|
332
|
-
} else {
|
|
333
|
-
console.warn("Impossible de supprimer, l'élément ne possède pas d'ID");
|
|
334
|
-
}
|
|
335
|
-
};
|
|
336
|
-
|
|
337
|
-
const handleEdit = (item) => {
|
|
338
|
-
onEdit(item); // Call onEdit with the item to edit
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const onChangeFilterValue = (field, value, tr) => {
|
|
342
|
-
setPagedFilters(pagedFilters => ({
|
|
343
|
-
...pagedFilters, [model.name]: {...pagedFilters[model.name] || {}, [field.name]: value || pagedFilters[model.name]?.[field.name] || undefined}
|
|
344
|
-
}));
|
|
345
|
-
queryClient.invalidateQueries(['api/data', model.name, 'page', page, elementsPerPage, pagedFilters[model.name], pagedSort[model.name]]);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
const {addNotification} = useNotificationContext();
|
|
349
|
-
|
|
350
|
-
const {mutate: exportMutation, isLoading} = useMutation(async (data) => {
|
|
351
|
-
|
|
352
|
-
console.log('Exportation des données');
|
|
353
|
-
const params = new URLSearchParams();
|
|
354
|
-
params.append('model', model.name);
|
|
355
|
-
params.append("_user", getUserId(me));
|
|
356
|
-
|
|
357
|
-
// Cas de la table de données : requête paginée
|
|
358
|
-
params.append('limit', maxRequestData + '');
|
|
359
|
-
params.append('attachment', '1');
|
|
360
|
-
params.append("depth", data.depth+"");
|
|
361
|
-
if( data.withModels )
|
|
362
|
-
params.append("withModels", "1");
|
|
363
|
-
|
|
364
|
-
const ids = data.exportSelection ? checkedItems.map(item => item._id) : [];
|
|
365
|
-
const body = JSON.stringify({filter: pagedFilters[model.name], models: data.models || [], ids});
|
|
366
|
-
|
|
367
|
-
params.append('ids', ids);
|
|
368
|
-
console.log('Fetch des données');
|
|
369
|
-
return fetch(`/api/data/export?lang=${lang}&${params.toString()}`, {
|
|
370
|
-
method: 'POST',
|
|
371
|
-
body,
|
|
372
|
-
headers: {"Content-Type": "application/json"}
|
|
373
|
-
})
|
|
374
|
-
.then(async resp => {
|
|
375
|
-
if( resp.status === 200 )
|
|
376
|
-
return resp.blob();
|
|
377
|
-
else {
|
|
378
|
-
const res = await resp.json();
|
|
379
|
-
throw new Error(res.error || 'something went wrong')
|
|
380
|
-
}
|
|
381
|
-
})
|
|
382
|
-
.then((blob) => {
|
|
383
|
-
const url = window.URL.createObjectURL(blob);
|
|
384
|
-
const a = document.createElement('a');
|
|
385
|
-
a.style.display = 'none';
|
|
386
|
-
a.href = url;
|
|
387
|
-
// the filename you want
|
|
388
|
-
a.download = model.name + '.dump.json';
|
|
389
|
-
document.body.appendChild(a);
|
|
390
|
-
a.click();
|
|
391
|
-
window.URL.revokeObjectURL(url);
|
|
392
|
-
})
|
|
393
|
-
.then(e => {
|
|
394
|
-
const notificationData = {
|
|
395
|
-
title: e.success ? t('dataimporter.success', 'Exportation de ' + model.name + ' réussie'): e.error,
|
|
396
|
-
icon: e.success ? <FaInfo/> : null,
|
|
397
|
-
status: e.success ? 'completed' : 'error'
|
|
398
|
-
};
|
|
399
|
-
addNotification(notificationData);
|
|
400
|
-
return e;
|
|
401
|
-
}).catch(e => {
|
|
402
|
-
const notificationData = {
|
|
403
|
-
title: e.message,
|
|
404
|
-
status: 'error'
|
|
405
|
-
};
|
|
406
|
-
addNotification(notificationData);
|
|
407
|
-
});
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
const handleExport = () => {
|
|
411
|
-
setExportDialogVisible(true)
|
|
412
|
-
}
|
|
413
|
-
const handleImport = () => {
|
|
414
|
-
setImportVisible(true);
|
|
415
|
-
}
|
|
416
|
-
const [isBackupModalOpen, setIsBackupModalOpen] = useState(false);
|
|
417
|
-
|
|
418
|
-
const handleBackup = () => {
|
|
419
|
-
setIsBackupModalOpen(true);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const handleConfirmRestore = async () => {
|
|
424
|
-
try {
|
|
425
|
-
// Make the API call to request the restore link
|
|
426
|
-
const response = await fetch('/api/backup/request-restore', {
|
|
427
|
-
method: 'POST',
|
|
428
|
-
// ... other options ...
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
const result = await response.json();
|
|
432
|
-
if (response.ok && result.message) {
|
|
433
|
-
addNotification({ status: 'completed', title: result.message });
|
|
434
|
-
} else {
|
|
435
|
-
addNotification({ status: 'error', title: result.error || t('backup.restore.requestError', 'Erreur lors de la demande de restauration.') });
|
|
436
|
-
}
|
|
437
|
-
} catch (error) {
|
|
438
|
-
addNotification({ status: 'error', title: t('backup.restore.requestError', 'Erreur lors de la demande de restauration.') });
|
|
439
|
-
console.error('Error requesting restore link:', error);
|
|
440
|
-
} finally {
|
|
441
|
-
setIsBackupModalOpen(false); // Close the modal
|
|
442
|
-
}
|
|
443
|
-
};
|
|
444
|
-
|
|
445
|
-
const handleFilter = () => {
|
|
446
|
-
setFilterActive(!filterActive);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const [lightboxIndex, setLightboxIndex] = useState(0);
|
|
451
|
-
const [lightboxOpened, setLightboxOpened] = useState(false);
|
|
452
|
-
const [lightboxSlides, setLightboxSlides] = useState([]);
|
|
453
|
-
const [tutorialDialogVisible, setTutorialDialogVisible] = useState(false);
|
|
454
|
-
|
|
455
|
-
const [newPackName, setNewPackName] = useState(''); // pack
|
|
456
|
-
|
|
457
|
-
const handleShowTutorialMenu = () => {
|
|
458
|
-
setTutorialDialogVisible(!tutorialDialogVisible);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
if (!model)
|
|
462
|
-
return <></>;
|
|
463
|
-
|
|
464
|
-
// NOUVEAU : La fonction qui gère la duplication
|
|
465
|
-
const handleDuplicate = (originalData) => {
|
|
466
|
-
// 1. Créer une copie superficielle des données de la ligne
|
|
467
|
-
const dataToDuplicate = { ...originalData };
|
|
468
|
-
|
|
469
|
-
// 2. TRÈS IMPORTANT : Supprimer les champs qui ne doivent pas être copiés.
|
|
470
|
-
// - L'_id doit être supprimé pour que le système sache qu'il s'agit d'une NOUVELLE entrée.
|
|
471
|
-
// - Le _hash sera recalculé par le backend.
|
|
472
|
-
// - Les dates de création/mise à jour seront gérées par le backend.
|
|
473
|
-
delete dataToDuplicate._id;
|
|
474
|
-
delete dataToDuplicate._hash;
|
|
475
|
-
delete dataToDuplicate.createdAt; // si ce champ existe
|
|
476
|
-
delete dataToDuplicate.updatedAt; // si ce champ existe
|
|
477
|
-
|
|
478
|
-
onDuplicateData(dataToDuplicate);
|
|
479
|
-
};
|
|
480
|
-
return (
|
|
481
|
-
<div className={`datatable${filterActive ? ' filter-active' : ''}`}>
|
|
482
|
-
{advanced && <div className="flex actions flex-left">
|
|
483
|
-
<Button onClick={() => onAddData(model)}><FaPlus/><Trans i18nKey="btns.addData">Ajouter une
|
|
484
|
-
donnée</Trans></Button>
|
|
485
|
-
<Button onClick={handleImport} title={t("btns.import")}><FaFileImport/><Trans
|
|
486
|
-
i18nKey="btns.import">Importer</Trans></Button>
|
|
487
|
-
<Button disabled={isLoading} onClick={handleExport} title={t("btns.export")}><FaFileExport/><Trans
|
|
488
|
-
i18nKey="btns.export">Exporter</Trans></Button>
|
|
489
|
-
<Button className="tourStep-import-datapack" onClick={handleShowPacks} title={t("btns.addPack")}><FaPlus/><Trans
|
|
490
|
-
i18nKey="btns.addPack">Packs...</Trans></Button>
|
|
491
|
-
|
|
492
|
-
<Button className={"tourStep-tutorials " + (/^demo[0-9]{1,2}$/.test(me.username) ? "btn-primary" : "btn")} onClick={handleShowTutorialMenu} title={t("btns.showTutos")}><FaPlus/><Trans
|
|
493
|
-
i18nKey="btns.showTutos">Tutoriels</Trans></Button>
|
|
494
|
-
{!/^demo[0-9]{1,2}$/.test(me.username) && (<Button onClick={handleBackup} title={t("btns.backup")}><FaDatabase/><Trans
|
|
495
|
-
i18nKey="btns.backup">Backup</Trans></Button>)}
|
|
496
|
-
<DialogProvider>
|
|
497
|
-
{tutorialDialogVisible && (
|
|
498
|
-
<Dialog isClosable={true} isModal={true} onClose={() => setTutorialDialogVisible(false)}>
|
|
499
|
-
<TutorialsMenu />
|
|
500
|
-
</Dialog>
|
|
501
|
-
)}
|
|
502
|
-
</DialogProvider>
|
|
503
|
-
<Button className="btn btn-primary" onClick={() => {
|
|
504
|
-
onShowAPI(selectedModel);
|
|
505
|
-
}}><FaBook/> {t('btns.api', 'API')}</Button>
|
|
506
|
-
</div>}
|
|
507
|
-
<div className={"table-wrapper"}>
|
|
508
|
-
<Tooltip id={"tooltipFile"} clickable={true} />
|
|
509
|
-
<Tooltip id={"tooltipActions"} />
|
|
510
|
-
<Lightbox
|
|
511
|
-
inline={{
|
|
512
|
-
style: { width: "100%", maxWidth: "900px", aspectRatio: "3 / 2" },
|
|
513
|
-
}} plugins={[Captions,Zoom]} index={lightboxIndex} open={lightboxOpened} close={()=> setLightboxOpened(false)}
|
|
514
|
-
slides={lightboxSlides}
|
|
515
|
-
on={{
|
|
516
|
-
click: ()=>{
|
|
517
|
-
|
|
518
|
-
}
|
|
519
|
-
}}
|
|
520
|
-
/>
|
|
521
|
-
{isDataLoaded && (
|
|
522
|
-
<table>
|
|
523
|
-
<thead>
|
|
524
|
-
<Header advanced={advanced} model={model} setCheckedItems={setCheckedItems} filterValues={filterValues} data={data} setFilterValues={setFilterValues} onChangeFilterValue={onChangeFilterValue} checkedItems={checkedItems} filterActive={filterActive} handleFilter={handleFilter}/>
|
|
525
|
-
</thead>
|
|
526
|
-
<tbody>
|
|
527
|
-
{(data || []).map((item) => (
|
|
528
|
-
<><DialogProvider>
|
|
529
|
-
<tr key={item._id} onDoubleClick={() => onEdit(item)} onClick={(e) => {
|
|
530
|
-
const checked = (!e.target.closest('tr').querySelector('td:first-child input')?.checked);
|
|
531
|
-
if (checked) {
|
|
532
|
-
setCheckedItems(items => {
|
|
533
|
-
return [...items, item];
|
|
534
|
-
});
|
|
535
|
-
} else {
|
|
536
|
-
setCheckedItems(items => items.filter(i => i._id !== item._id));
|
|
537
|
-
}
|
|
538
|
-
}}>
|
|
539
|
-
{advanced && (<td className={"mini"}>
|
|
540
|
-
<CheckboxField className={"input-ref"}
|
|
541
|
-
checked={checkedItems?.some(i => i._id === item._id)}
|
|
542
|
-
onChange={(e) => {
|
|
543
|
-
if (e) {
|
|
544
|
-
setCheckedItems(items => {
|
|
545
|
-
return [...items, item];
|
|
546
|
-
});
|
|
547
|
-
} else {
|
|
548
|
-
setCheckedItems(items => items.filter(i => i._id !== item._id));
|
|
549
|
-
}
|
|
550
|
-
}}/></td>)}
|
|
551
|
-
{(model?.fields ||[]).map(field => {
|
|
552
|
-
|
|
553
|
-
if( !isConditionMet(model, field.condition, item, models, me)){
|
|
554
|
-
return <td className={"notmet"} key={item._id + field.name}></td>; // Do not render the header cell if the condition isn't met
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
const hiddenable = (content) => {
|
|
558
|
-
if(field.hiddenable)
|
|
559
|
-
return <HiddenableCell value={content} />;
|
|
560
|
-
return content;
|
|
561
|
-
}
|
|
562
|
-
if( field.type === 'relation' && !models.find(f => f.name === field.relation && f._user === me?.username ))
|
|
563
|
-
return <td className={"empty"} key={item._id + field.name}></td>
|
|
564
|
-
if (field.type === "relation" && typeof field.relation === "string") {
|
|
565
|
-
return <td key={item._id + field.name} style={{backgroundColor: field.color}}>{hiddenable(<RelationValue field={field}
|
|
566
|
-
data={item}/>)}</td>;
|
|
567
|
-
}
|
|
568
|
-
if( field.type === "cronSchedule" ){
|
|
569
|
-
let val = '';
|
|
570
|
-
try {
|
|
571
|
-
val = cronstrue.toString(item[field.name], { locale: lang, throwExceptionOnParseError: true });
|
|
572
|
-
} catch (e) {
|
|
573
|
-
|
|
574
|
-
}
|
|
575
|
-
return <td
|
|
576
|
-
className={isLightColor(field.color)?"lighted":"unlighted"} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}
|
|
577
|
-
key={field.name}>{hiddenable(val)}</td>;
|
|
578
|
-
}
|
|
579
|
-
if (field.type === "date" && item[field.name]) {
|
|
580
|
-
return <td
|
|
581
|
-
className={isLightColor(field.color)?"lighted":"unlighted"} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}
|
|
582
|
-
key={field.name}>{hiddenable(new Date(item[field.name]).toLocaleDateString(i18n.resolvedLanguage || i18n.language, {
|
|
583
|
-
day: "numeric",
|
|
584
|
-
month: "numeric",
|
|
585
|
-
year: "numeric"
|
|
586
|
-
}))}</td>;
|
|
587
|
-
}
|
|
588
|
-
if (field.type === "datetime" && item[field.name]) {
|
|
589
|
-
return <td
|
|
590
|
-
className={isLightColor(field.color)?"lighted":"unlighted"} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}
|
|
591
|
-
key={field.name}>{hiddenable(new Date(item[field.name]).toLocaleDateString(i18n.resolvedLanguage || i18n.language, {
|
|
592
|
-
day: "numeric",
|
|
593
|
-
month: "numeric",
|
|
594
|
-
year: "numeric",
|
|
595
|
-
hour: "numeric",
|
|
596
|
-
minute: "numeric"
|
|
597
|
-
}))}</td>;
|
|
598
|
-
}
|
|
599
|
-
if (field.type === "enum") {
|
|
600
|
-
return <td
|
|
601
|
-
key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(field.items.includes(item[field.name]) ? `${t(item[field.name], item[field.name])}` : `${item[field.name] || ''}`)}</td>;
|
|
602
|
-
}
|
|
603
|
-
if (field.type === "code") {
|
|
604
|
-
const v = typeof(item[field.name]) === "string" ? item[field.name] : (item[field.name] ? JSON.stringify(item[field.name], null, 2) : '');
|
|
605
|
-
return <td
|
|
606
|
-
key={field.name}>{v && hiddenable(<CodeField language={field.language} name={field.name} value={v} disabled={true} />)}</td>;
|
|
607
|
-
}
|
|
608
|
-
if (field.type === "object") {
|
|
609
|
-
return <td key={field.name}>{hiddenable(<CodeField language={'json'} name={field.name} value={item[field.name] ? JSON.stringify(item[field.name], null, 2) : ''} disabled={true} />)}</td>;
|
|
610
|
-
}
|
|
611
|
-
if (field.type === 'email') {
|
|
612
|
-
return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}} className={isLightColor(field.color)?"lighted":""}><a href={"mailto:"+item[field.name]} style={{color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(item[field.name])}</a></td>;
|
|
613
|
-
}
|
|
614
|
-
if (field.type === 'phone') {
|
|
615
|
-
return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}} className={isLightColor(field.color)?"lighted":""}>{hiddenable(item[field.name] && <PhoneField name={"phone"} value={item[field.name]} disabled={true} onChange={() => {}}></PhoneField>)}</td>;
|
|
616
|
-
}
|
|
617
|
-
if (field.type === 'model') {
|
|
618
|
-
return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}} className={isLightColor(field.color)?"lighted":""}>{hiddenable(item[field.name] ? `${t('model_'+item[field.name], item[field.name])} (${item[field.name]})`:'')}</td>;
|
|
619
|
-
}
|
|
620
|
-
if (field.type === '
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
})
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
{hiddenable(item[field.name])}</td>;
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
)}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1
|
+
// Composant pour afficher les données (DataTable basique)
|
|
2
|
+
import {useModelContext} from "./contexts/ModelContext.jsx";
|
|
3
|
+
import {Trans, useTranslation} from "react-i18next";
|
|
4
|
+
import {useMutation, useQuery, useQueryClient} from "react-query";
|
|
5
|
+
import {useAuthContext} from "./contexts/AuthContext.jsx";
|
|
6
|
+
import React, {useEffect, useMemo, useRef, useState} from "react";
|
|
7
|
+
import {getUserId} from "../../src/data.js";
|
|
8
|
+
import cronstrue from 'cronstrue/i18n';
|
|
9
|
+
import {Event} from "../../src/events.js";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
FaBook,
|
|
13
|
+
FaCopy,
|
|
14
|
+
FaDatabase, FaEraser,
|
|
15
|
+
FaFileExport,
|
|
16
|
+
FaFileImport,
|
|
17
|
+
FaFilter, FaHistory,
|
|
18
|
+
FaInfo,
|
|
19
|
+
FaPlus,
|
|
20
|
+
FaTrash, FaWrench
|
|
21
|
+
} from "react-icons/fa";
|
|
22
|
+
import {useNotificationContext} from "./NotificationProvider.jsx";
|
|
23
|
+
import {
|
|
24
|
+
elementsPerPage,
|
|
25
|
+
kilobytes,
|
|
26
|
+
maxBytesPerSecondThrottleData,
|
|
27
|
+
maxFileSize,
|
|
28
|
+
maxRequestData
|
|
29
|
+
} from "../../src/constants.js";
|
|
30
|
+
import Button from "./Button.jsx";
|
|
31
|
+
import {Dialog, DialogProvider} from "./Dialog.jsx";
|
|
32
|
+
import {
|
|
33
|
+
CheckboxField,
|
|
34
|
+
CodeField,
|
|
35
|
+
ColorField,
|
|
36
|
+
FileField,
|
|
37
|
+
FilterField,
|
|
38
|
+
ModelField,
|
|
39
|
+
PhoneField,
|
|
40
|
+
TextField
|
|
41
|
+
} from "./Field.jsx";
|
|
42
|
+
import RelationValue from "./RelationValue.jsx";
|
|
43
|
+
import {FaGear, FaPencil, FaTriangleExclamation} from "react-icons/fa6";
|
|
44
|
+
import RestoreConfirmationModal from "./RestoreConfirmationModal.jsx";
|
|
45
|
+
import {event_trigger, isLightColor} from "../../src/core.js";
|
|
46
|
+
import {Tooltip} from "react-tooltip";
|
|
47
|
+
import ExportDialog from "./ExportDialog.jsx";
|
|
48
|
+
import Captions from "yet-another-react-lightbox/plugins/captions";
|
|
49
|
+
import {Zoom} from "yet-another-react-lightbox/plugins";
|
|
50
|
+
import Lightbox from "yet-another-react-lightbox";
|
|
51
|
+
|
|
52
|
+
import "./DataTable.scss"
|
|
53
|
+
import useLocalStorage from "./hooks/useLocalStorage.js";
|
|
54
|
+
import TutorialsMenu from "../src/TutorialsMenu.jsx";
|
|
55
|
+
import {useTutorials} from "./hooks/useTutorials.jsx";
|
|
56
|
+
import PackGallery from "./PackGallery.jsx";
|
|
57
|
+
import {HiddenableCell} from "./HiddenableCell.jsx";
|
|
58
|
+
import ConditionBuilder from "./ConditionBuilder.jsx";
|
|
59
|
+
import {pagedFilterToMongoConds} from "./filter.js";
|
|
60
|
+
import {isConditionMet} from "../../src/filter";
|
|
61
|
+
import {DataImporter} from "./DataImporter.jsx";
|
|
62
|
+
import {HistoryDialog} from "./HistoryDialog.jsx";
|
|
63
|
+
|
|
64
|
+
const Header = ({
|
|
65
|
+
reversed = false,
|
|
66
|
+
handleFilter,
|
|
67
|
+
model,
|
|
68
|
+
setCheckedItems,
|
|
69
|
+
checkedItems,
|
|
70
|
+
data,
|
|
71
|
+
filterActive,
|
|
72
|
+
onChangeFilterValue,
|
|
73
|
+
setFilterValues,
|
|
74
|
+
filterValues,
|
|
75
|
+
advanced=true
|
|
76
|
+
}) => {
|
|
77
|
+
|
|
78
|
+
const {t} = useTranslation()
|
|
79
|
+
const { me } = useAuthContext()
|
|
80
|
+
const {
|
|
81
|
+
models,
|
|
82
|
+
countByModel,
|
|
83
|
+
selectedModel,
|
|
84
|
+
setPagedFilters,
|
|
85
|
+
pagedFilters,
|
|
86
|
+
page
|
|
87
|
+
} = useModelContext();
|
|
88
|
+
let totalCol = 0;
|
|
89
|
+
|
|
90
|
+
const [advancedFilterVisible, setAdvancedFilterVisible] = useState(false);
|
|
91
|
+
|
|
92
|
+
const handleAdvancedFilter = () => {
|
|
93
|
+
setAdvancedFilterVisible(true);
|
|
94
|
+
}
|
|
95
|
+
return <><tr className={reversed ? ' reversed' : ''}>
|
|
96
|
+
{advanced && (<th className={"mini"}>
|
|
97
|
+
<div className="flex flex-row">
|
|
98
|
+
|
|
99
|
+
<Button onClick={handleFilter} className={filterActive ? ' active' : ''}><FaFilter/></Button>
|
|
100
|
+
{filterActive && <Button onClick={() => handleAdvancedFilter()}><FaWrench /></Button>}
|
|
101
|
+
<CheckboxField checkbox={true} checked={checkedItems?.length === data.length} onChange={e => {
|
|
102
|
+
if (checkedItems?.length === data.length) {
|
|
103
|
+
setCheckedItems([]);
|
|
104
|
+
} else {
|
|
105
|
+
setCheckedItems(data);
|
|
106
|
+
}
|
|
107
|
+
}}/>
|
|
108
|
+
|
|
109
|
+
<DialogProvider>
|
|
110
|
+
{advancedFilterVisible && (
|
|
111
|
+
<Dialog title={t("datatable.advancedFilter.title")} isClosable={true} onClose={() => setAdvancedFilterVisible(false)}>
|
|
112
|
+
<h3>Edit filter</h3>
|
|
113
|
+
<div className="msg">
|
|
114
|
+
<Trans i18nKey={"datatable.advancedFilter.desc"}>{t("datatable.advancedFilter.desc")}</Trans>
|
|
115
|
+
</div>
|
|
116
|
+
<ConditionBuilder onChange={(c) => {
|
|
117
|
+
setPagedFilters(pagedFilters => ({
|
|
118
|
+
...pagedFilters,
|
|
119
|
+
[model.name]: c || pagedFilters[model.name] || {} }));
|
|
120
|
+
}} initialValue={{ $and: pagedFilterToMongoConds(pagedFilters, model)}} models={models} model={model.name} checkedItems={checkedItems} setCheckedItems={setCheckedItems} data={data} filterActive={filterActive} onChangeFilterValue={onChangeFilterValue} setFilterValues={setFilterValues}/>
|
|
121
|
+
<Button onClick={() =>{
|
|
122
|
+
setPagedFilters(pagedFilters => ({
|
|
123
|
+
...pagedFilters,
|
|
124
|
+
[model.name]: {}}));
|
|
125
|
+
}}><Trans i18nKey={"btns.reset"}>Reset</Trans></Button>
|
|
126
|
+
</Dialog>
|
|
127
|
+
)}
|
|
128
|
+
</DialogProvider>
|
|
129
|
+
</div>
|
|
130
|
+
</th>)}
|
|
131
|
+
{(model?.fields || []).map(field => {
|
|
132
|
+
if (field.type === 'password')
|
|
133
|
+
return <></>;
|
|
134
|
+
if( field.type === 'relation' && !models.find(f => f.name === field.relation && f._user === me?.username ))
|
|
135
|
+
return <th className={"empty"} key={"datatable-th-"+model.name+":"+field.name}>
|
|
136
|
+
<div className="flex flex-mini-gap flex-centered">
|
|
137
|
+
<FaTriangleExclamation color={"#df8107"} data-tooltip-id={`tooltip-desc`} data-tooltip-content={"\""+field.relation + "\" model not found"} />
|
|
138
|
+
{field.name}
|
|
139
|
+
<Tooltip id={"tooltip-desc"} render={({content}) => {
|
|
140
|
+
gtag('render hint '+field.relation);
|
|
141
|
+
return content;
|
|
142
|
+
}} />
|
|
143
|
+
</div>
|
|
144
|
+
</th>;
|
|
145
|
+
|
|
146
|
+
totalCol++;
|
|
147
|
+
return <FilterField advanced={advanced} reversed={reversed} filterValues={filterValues} setFilterValues={setFilterValues}
|
|
148
|
+
model={model} field={field} active={filterActive}
|
|
149
|
+
onChangeFilterValue={onChangeFilterValue}/>;
|
|
150
|
+
})}
|
|
151
|
+
{advanced && (<th><Trans i18nKey="actions">Actions</Trans>
|
|
152
|
+
{Object.keys(pagedFilters[model.name] || {})
|
|
153
|
+
.filter(f=> Object.keys(pagedFilters[model.name]?.[f] || {}).length > 0).length > 0 && <div>
|
|
154
|
+
<button onClick={() => {
|
|
155
|
+
setFilterValues({});
|
|
156
|
+
setPagedFilters(pagedFilters => ({...pagedFilters, [model.name]: {}}));
|
|
157
|
+
}}><FaEraser />
|
|
158
|
+
</button>
|
|
159
|
+
</div>}</th>)}
|
|
160
|
+
</tr>
|
|
161
|
+
{reversed && (
|
|
162
|
+
<tr>
|
|
163
|
+
{countByModel?.[selectedModel?.name] === 0 &&
|
|
164
|
+
<td colSpan={totalCol + 2}><Trans i18nKey="no_data">Aucune donnée enregistrée</Trans></td>}
|
|
165
|
+
</tr>)}
|
|
166
|
+
</>
|
|
167
|
+
;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const formatDuration = (totalSeconds, t) => {
|
|
171
|
+
if (totalSeconds === null || totalSeconds === undefined || isNaN(totalSeconds) || totalSeconds === '') {
|
|
172
|
+
return '';
|
|
173
|
+
}
|
|
174
|
+
const total = parseInt(totalSeconds, 10);
|
|
175
|
+
if (total === 0) return '0s';
|
|
176
|
+
|
|
177
|
+
const d = Math.floor(total / 86400);
|
|
178
|
+
const h = Math.floor((total % 86400) / 3600);
|
|
179
|
+
const m = Math.floor((total % 3600) / 60);
|
|
180
|
+
const s = Math.floor(total % 60);
|
|
181
|
+
|
|
182
|
+
const parts = [];
|
|
183
|
+
if (d > 0) parts.push(t('duration.day', { count: d }));
|
|
184
|
+
if (h > 0) parts.push(t('duration.hour', { count: h }));
|
|
185
|
+
if (m > 0) parts.push(t('duration.minute', { count: m }));
|
|
186
|
+
if (s > 0) parts.push(t('duration.second', { count: s }));
|
|
187
|
+
|
|
188
|
+
if (parts.length === 0) return '0s';
|
|
189
|
+
|
|
190
|
+
return parts.slice(0, 2).join(', ');
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
const RichText = ({ value, initialLang }) => {
|
|
195
|
+
const availableLangs = useMemo(() => (value ? Object.keys(value).filter(k => value[k]) : []), [value]);
|
|
196
|
+
|
|
197
|
+
const [selectedLang, setSelectedLang] = useState(() => {
|
|
198
|
+
if (availableLangs.includes(initialLang)) {
|
|
199
|
+
return initialLang;
|
|
200
|
+
}
|
|
201
|
+
return availableLangs[0] || null;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
setSelectedLang(initialLang);
|
|
206
|
+
}, [initialLang]);
|
|
207
|
+
|
|
208
|
+
if (!value || availableLangs.length === 0) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (availableLangs.length === 1) {
|
|
213
|
+
return <div className="rte-value" dangerouslySetInnerHTML={{ __html: value[availableLangs[0]] || '' }} />;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div className="richtext-t-cell">
|
|
218
|
+
<div className="lang-switcher">
|
|
219
|
+
{availableLangs.map(langCode => (
|
|
220
|
+
<button
|
|
221
|
+
key={langCode}
|
|
222
|
+
className={`lang-btn ${selectedLang === langCode ? 'active' : ''}`}
|
|
223
|
+
onClick={(e) => {
|
|
224
|
+
e.stopPropagation();
|
|
225
|
+
setSelectedLang(langCode);
|
|
226
|
+
}}
|
|
227
|
+
title={langCode.toUpperCase()}
|
|
228
|
+
>
|
|
229
|
+
{langCode.toUpperCase()}
|
|
230
|
+
</button>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
<div className="rte-value" dangerouslySetInnerHTML={{ __html: value[selectedLang] || '' }} />
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export function DataTable({
|
|
239
|
+
model,
|
|
240
|
+
checkedItems,
|
|
241
|
+
setCheckedItems = () => {},
|
|
242
|
+
onEdit,
|
|
243
|
+
onAddData,
|
|
244
|
+
onDuplicateData,
|
|
245
|
+
onDelete,
|
|
246
|
+
onShowAPI,
|
|
247
|
+
filterValues,
|
|
248
|
+
setFilterValues = () => {},
|
|
249
|
+
data: propData, // NOUVEAU: Accepter les données via les props
|
|
250
|
+
advanced=true
|
|
251
|
+
|
|
252
|
+
}) {
|
|
253
|
+
const {
|
|
254
|
+
models,
|
|
255
|
+
elementsPerPage,
|
|
256
|
+
paginatedDataByModel,
|
|
257
|
+
countByModel,
|
|
258
|
+
pagedSort,
|
|
259
|
+
selectedModel,
|
|
260
|
+
pagedFilters,
|
|
261
|
+
setPagedFilters,
|
|
262
|
+
page
|
|
263
|
+
} = useModelContext();
|
|
264
|
+
const {t, i18n} = useTranslation();
|
|
265
|
+
const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
|
|
266
|
+
const queryClient = useQueryClient();
|
|
267
|
+
const {me} = useAuthContext();
|
|
268
|
+
|
|
269
|
+
// Si des données sont passées en props, on les utilise, sinon on prend celles du contexte.
|
|
270
|
+
const data = propData || paginatedDataByModel[model?.name] || [];
|
|
271
|
+
|
|
272
|
+
const isDataLoaded = true;
|
|
273
|
+
const [importVisible, setImportVisible] = useState(false);
|
|
274
|
+
const [filterActive, setFilterActive] = useState(false)
|
|
275
|
+
|
|
276
|
+
const [showPackGallery, setShowPackGallery] = useState(false);
|
|
277
|
+
const [showExportDialog, setExportDialogVisible] = useState(false);
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
const [selectedRow, setSelectedRow] = useState(null);
|
|
281
|
+
|
|
282
|
+
const [showAddPack, setAddPackVisible] = useState(false);
|
|
283
|
+
const handleAddPack = () => {
|
|
284
|
+
setAddPackVisible(!showAddPack);
|
|
285
|
+
}
|
|
286
|
+
const handleShowPacks = () => {
|
|
287
|
+
setShowPackGallery(true);
|
|
288
|
+
};
|
|
289
|
+
const handleDelete = async (item) => {
|
|
290
|
+
if (model && item && item._id) { // Assurez-vous d'avoir un identifiant unique pour chaque élément
|
|
291
|
+
try {
|
|
292
|
+
const response = await fetch(`/api/data/${item._id}?_user=${encodeURIComponent(getUserId(me))}`, { // Assurez-vous que l'URL est correcte
|
|
293
|
+
method: 'DELETE',
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
if (response.ok) {
|
|
297
|
+
const res = await response.json();
|
|
298
|
+
|
|
299
|
+
if (res.success) {
|
|
300
|
+
onDelete?.(item);
|
|
301
|
+
const notificationData = {
|
|
302
|
+
id: 'datatable.deleteData.success',
|
|
303
|
+
title: t('datatable.deleteData.success', 'Données supprimées avec succès'),
|
|
304
|
+
icon: <FaInfo/>,
|
|
305
|
+
status: 'completed'
|
|
306
|
+
};
|
|
307
|
+
addNotification(notificationData);
|
|
308
|
+
} else {
|
|
309
|
+
const notificationData = {
|
|
310
|
+
title: res.message,
|
|
311
|
+
status: 'error'
|
|
312
|
+
};
|
|
313
|
+
addNotification(notificationData);
|
|
314
|
+
console.log('Données non trouvées');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
await Event.Trigger('API_DELETE_DATA', "custom", "data",{ model: item._model, id: item._id });
|
|
318
|
+
queryClient.invalidateQueries(['api/data', item._model, 'page', page, elementsPerPage, pagedFilters[item._model], pagedSort[item._model]]);
|
|
319
|
+
|
|
320
|
+
} else {
|
|
321
|
+
console.error('Erreur lors de la suppression des données');
|
|
322
|
+
// Gérer les erreurs (afficher un message à l'utilisateur, etc.)
|
|
323
|
+
}
|
|
324
|
+
} catch (error) {
|
|
325
|
+
const notificationData = {
|
|
326
|
+
title: error.message,
|
|
327
|
+
status: 'error'
|
|
328
|
+
};
|
|
329
|
+
addNotification(notificationData);
|
|
330
|
+
console.error('Erreur lors de la suppression des données:', error);
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
console.warn("Impossible de supprimer, l'élément ne possède pas d'ID");
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const handleEdit = (item) => {
|
|
338
|
+
onEdit(item); // Call onEdit with the item to edit
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const onChangeFilterValue = (field, value, tr) => {
|
|
342
|
+
setPagedFilters(pagedFilters => ({
|
|
343
|
+
...pagedFilters, [model.name]: {...pagedFilters[model.name] || {}, [field.name]: value || pagedFilters[model.name]?.[field.name] || undefined}
|
|
344
|
+
}));
|
|
345
|
+
queryClient.invalidateQueries(['api/data', model.name, 'page', page, elementsPerPage, pagedFilters[model.name], pagedSort[model.name]]);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const {addNotification} = useNotificationContext();
|
|
349
|
+
|
|
350
|
+
const {mutate: exportMutation, isLoading} = useMutation(async (data) => {
|
|
351
|
+
|
|
352
|
+
console.log('Exportation des données');
|
|
353
|
+
const params = new URLSearchParams();
|
|
354
|
+
params.append('model', model.name);
|
|
355
|
+
params.append("_user", getUserId(me));
|
|
356
|
+
|
|
357
|
+
// Cas de la table de données : requête paginée
|
|
358
|
+
params.append('limit', maxRequestData + '');
|
|
359
|
+
params.append('attachment', '1');
|
|
360
|
+
params.append("depth", data.depth+"");
|
|
361
|
+
if( data.withModels )
|
|
362
|
+
params.append("withModels", "1");
|
|
363
|
+
|
|
364
|
+
const ids = data.exportSelection ? checkedItems.map(item => item._id) : [];
|
|
365
|
+
const body = JSON.stringify({filter: pagedFilters[model.name], models: data.models || [], ids});
|
|
366
|
+
|
|
367
|
+
params.append('ids', ids);
|
|
368
|
+
console.log('Fetch des données');
|
|
369
|
+
return fetch(`/api/data/export?lang=${lang}&${params.toString()}`, {
|
|
370
|
+
method: 'POST',
|
|
371
|
+
body,
|
|
372
|
+
headers: {"Content-Type": "application/json"}
|
|
373
|
+
})
|
|
374
|
+
.then(async resp => {
|
|
375
|
+
if( resp.status === 200 )
|
|
376
|
+
return resp.blob();
|
|
377
|
+
else {
|
|
378
|
+
const res = await resp.json();
|
|
379
|
+
throw new Error(res.error || 'something went wrong')
|
|
380
|
+
}
|
|
381
|
+
})
|
|
382
|
+
.then((blob) => {
|
|
383
|
+
const url = window.URL.createObjectURL(blob);
|
|
384
|
+
const a = document.createElement('a');
|
|
385
|
+
a.style.display = 'none';
|
|
386
|
+
a.href = url;
|
|
387
|
+
// the filename you want
|
|
388
|
+
a.download = model.name + '.dump.json';
|
|
389
|
+
document.body.appendChild(a);
|
|
390
|
+
a.click();
|
|
391
|
+
window.URL.revokeObjectURL(url);
|
|
392
|
+
})
|
|
393
|
+
.then(e => {
|
|
394
|
+
const notificationData = {
|
|
395
|
+
title: e.success ? t('dataimporter.success', 'Exportation de ' + model.name + ' réussie'): e.error,
|
|
396
|
+
icon: e.success ? <FaInfo/> : null,
|
|
397
|
+
status: e.success ? 'completed' : 'error'
|
|
398
|
+
};
|
|
399
|
+
addNotification(notificationData);
|
|
400
|
+
return e;
|
|
401
|
+
}).catch(e => {
|
|
402
|
+
const notificationData = {
|
|
403
|
+
title: e.message,
|
|
404
|
+
status: 'error'
|
|
405
|
+
};
|
|
406
|
+
addNotification(notificationData);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const handleExport = () => {
|
|
411
|
+
setExportDialogVisible(true)
|
|
412
|
+
}
|
|
413
|
+
const handleImport = () => {
|
|
414
|
+
setImportVisible(true);
|
|
415
|
+
}
|
|
416
|
+
const [isBackupModalOpen, setIsBackupModalOpen] = useState(false);
|
|
417
|
+
|
|
418
|
+
const handleBackup = () => {
|
|
419
|
+
setIsBackupModalOpen(true);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
const handleConfirmRestore = async () => {
|
|
424
|
+
try {
|
|
425
|
+
// Make the API call to request the restore link
|
|
426
|
+
const response = await fetch('/api/backup/request-restore', {
|
|
427
|
+
method: 'POST',
|
|
428
|
+
// ... other options ...
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const result = await response.json();
|
|
432
|
+
if (response.ok && result.message) {
|
|
433
|
+
addNotification({ status: 'completed', title: result.message });
|
|
434
|
+
} else {
|
|
435
|
+
addNotification({ status: 'error', title: result.error || t('backup.restore.requestError', 'Erreur lors de la demande de restauration.') });
|
|
436
|
+
}
|
|
437
|
+
} catch (error) {
|
|
438
|
+
addNotification({ status: 'error', title: t('backup.restore.requestError', 'Erreur lors de la demande de restauration.') });
|
|
439
|
+
console.error('Error requesting restore link:', error);
|
|
440
|
+
} finally {
|
|
441
|
+
setIsBackupModalOpen(false); // Close the modal
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const handleFilter = () => {
|
|
446
|
+
setFilterActive(!filterActive);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
const [lightboxIndex, setLightboxIndex] = useState(0);
|
|
451
|
+
const [lightboxOpened, setLightboxOpened] = useState(false);
|
|
452
|
+
const [lightboxSlides, setLightboxSlides] = useState([]);
|
|
453
|
+
const [tutorialDialogVisible, setTutorialDialogVisible] = useState(false);
|
|
454
|
+
|
|
455
|
+
const [newPackName, setNewPackName] = useState(''); // pack
|
|
456
|
+
|
|
457
|
+
const handleShowTutorialMenu = () => {
|
|
458
|
+
setTutorialDialogVisible(!tutorialDialogVisible);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (!model)
|
|
462
|
+
return <></>;
|
|
463
|
+
|
|
464
|
+
// NOUVEAU : La fonction qui gère la duplication
|
|
465
|
+
const handleDuplicate = (originalData) => {
|
|
466
|
+
// 1. Créer une copie superficielle des données de la ligne
|
|
467
|
+
const dataToDuplicate = { ...originalData };
|
|
468
|
+
|
|
469
|
+
// 2. TRÈS IMPORTANT : Supprimer les champs qui ne doivent pas être copiés.
|
|
470
|
+
// - L'_id doit être supprimé pour que le système sache qu'il s'agit d'une NOUVELLE entrée.
|
|
471
|
+
// - Le _hash sera recalculé par le backend.
|
|
472
|
+
// - Les dates de création/mise à jour seront gérées par le backend.
|
|
473
|
+
delete dataToDuplicate._id;
|
|
474
|
+
delete dataToDuplicate._hash;
|
|
475
|
+
delete dataToDuplicate.createdAt; // si ce champ existe
|
|
476
|
+
delete dataToDuplicate.updatedAt; // si ce champ existe
|
|
477
|
+
|
|
478
|
+
onDuplicateData(dataToDuplicate);
|
|
479
|
+
};
|
|
480
|
+
return (
|
|
481
|
+
<div className={`datatable${filterActive ? ' filter-active' : ''}`}>
|
|
482
|
+
{advanced && <div className="flex actions flex-left">
|
|
483
|
+
<Button onClick={() => onAddData(model)}><FaPlus/><Trans i18nKey="btns.addData">Ajouter une
|
|
484
|
+
donnée</Trans></Button>
|
|
485
|
+
<Button onClick={handleImport} title={t("btns.import")}><FaFileImport/><Trans
|
|
486
|
+
i18nKey="btns.import">Importer</Trans></Button>
|
|
487
|
+
<Button disabled={isLoading} onClick={handleExport} title={t("btns.export")}><FaFileExport/><Trans
|
|
488
|
+
i18nKey="btns.export">Exporter</Trans></Button>
|
|
489
|
+
<Button className="tourStep-import-datapack" onClick={handleShowPacks} title={t("btns.addPack")}><FaPlus/><Trans
|
|
490
|
+
i18nKey="btns.addPack">Packs...</Trans></Button>
|
|
491
|
+
|
|
492
|
+
<Button className={"tourStep-tutorials " + (/^demo[0-9]{1,2}$/.test(me.username) ? "btn-primary" : "btn")} onClick={handleShowTutorialMenu} title={t("btns.showTutos")}><FaPlus/><Trans
|
|
493
|
+
i18nKey="btns.showTutos">Tutoriels</Trans></Button>
|
|
494
|
+
{!/^demo[0-9]{1,2}$/.test(me.username) && (<Button onClick={handleBackup} title={t("btns.backup")}><FaDatabase/><Trans
|
|
495
|
+
i18nKey="btns.backup">Backup</Trans></Button>)}
|
|
496
|
+
<DialogProvider>
|
|
497
|
+
{tutorialDialogVisible && (
|
|
498
|
+
<Dialog isClosable={true} isModal={true} onClose={() => setTutorialDialogVisible(false)}>
|
|
499
|
+
<TutorialsMenu />
|
|
500
|
+
</Dialog>
|
|
501
|
+
)}
|
|
502
|
+
</DialogProvider>
|
|
503
|
+
<Button className="btn btn-primary" onClick={() => {
|
|
504
|
+
onShowAPI(selectedModel);
|
|
505
|
+
}}><FaBook/> {t('btns.api', 'API')}</Button>
|
|
506
|
+
</div>}
|
|
507
|
+
<div className={"table-wrapper"}>
|
|
508
|
+
<Tooltip id={"tooltipFile"} clickable={true} />
|
|
509
|
+
<Tooltip id={"tooltipActions"} />
|
|
510
|
+
<Lightbox
|
|
511
|
+
inline={{
|
|
512
|
+
style: { width: "100%", maxWidth: "900px", aspectRatio: "3 / 2" },
|
|
513
|
+
}} plugins={[Captions,Zoom]} index={lightboxIndex} open={lightboxOpened} close={()=> setLightboxOpened(false)}
|
|
514
|
+
slides={lightboxSlides}
|
|
515
|
+
on={{
|
|
516
|
+
click: ()=>{
|
|
517
|
+
|
|
518
|
+
}
|
|
519
|
+
}}
|
|
520
|
+
/>
|
|
521
|
+
{isDataLoaded && (
|
|
522
|
+
<table>
|
|
523
|
+
<thead>
|
|
524
|
+
<Header advanced={advanced} model={model} setCheckedItems={setCheckedItems} filterValues={filterValues} data={data} setFilterValues={setFilterValues} onChangeFilterValue={onChangeFilterValue} checkedItems={checkedItems} filterActive={filterActive} handleFilter={handleFilter}/>
|
|
525
|
+
</thead>
|
|
526
|
+
<tbody>
|
|
527
|
+
{(data || []).map((item) => (
|
|
528
|
+
<><DialogProvider>
|
|
529
|
+
<tr key={item._id} onDoubleClick={() => onEdit(item)} onClick={(e) => {
|
|
530
|
+
const checked = (!e.target.closest('tr').querySelector('td:first-child input')?.checked);
|
|
531
|
+
if (checked) {
|
|
532
|
+
setCheckedItems(items => {
|
|
533
|
+
return [...items, item];
|
|
534
|
+
});
|
|
535
|
+
} else {
|
|
536
|
+
setCheckedItems(items => items.filter(i => i._id !== item._id));
|
|
537
|
+
}
|
|
538
|
+
}}>
|
|
539
|
+
{advanced && (<td className={"mini"}>
|
|
540
|
+
<CheckboxField checkbox={true} className={"input-ref"}
|
|
541
|
+
checked={checkedItems?.some(i => i._id === item._id)}
|
|
542
|
+
onChange={(e) => {
|
|
543
|
+
if (e) {
|
|
544
|
+
setCheckedItems(items => {
|
|
545
|
+
return [...items, item];
|
|
546
|
+
});
|
|
547
|
+
} else {
|
|
548
|
+
setCheckedItems(items => items.filter(i => i._id !== item._id));
|
|
549
|
+
}
|
|
550
|
+
}}/></td>)}
|
|
551
|
+
{(model?.fields ||[]).map(field => {
|
|
552
|
+
|
|
553
|
+
if( !isConditionMet(model, field.condition, item, models, me, false)){
|
|
554
|
+
return <td className={"notmet"} key={item._id + field.name}></td>; // Do not render the header cell if the condition isn't met
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const hiddenable = (content) => {
|
|
558
|
+
if(field.hiddenable)
|
|
559
|
+
return <HiddenableCell value={content} />;
|
|
560
|
+
return content;
|
|
561
|
+
}
|
|
562
|
+
if( field.type === 'relation' && !models.find(f => f.name === field.relation && f._user === me?.username ))
|
|
563
|
+
return <td className={"empty"} key={item._id + field.name}></td>
|
|
564
|
+
if (field.type === "relation" && typeof field.relation === "string") {
|
|
565
|
+
return <td key={item._id + field.name} style={{backgroundColor: field.color}}>{hiddenable(<RelationValue field={field}
|
|
566
|
+
data={item}/>)}</td>;
|
|
567
|
+
}
|
|
568
|
+
if( field.type === "cronSchedule" ){
|
|
569
|
+
let val = '';
|
|
570
|
+
try {
|
|
571
|
+
val = cronstrue.toString(item[field.name], { locale: lang, throwExceptionOnParseError: true });
|
|
572
|
+
} catch (e) {
|
|
573
|
+
|
|
574
|
+
}
|
|
575
|
+
return <td
|
|
576
|
+
className={isLightColor(field.color)?"lighted":"unlighted"} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}
|
|
577
|
+
key={field.name}>{hiddenable(val)}</td>;
|
|
578
|
+
}
|
|
579
|
+
if (field.type === "date" && item[field.name]) {
|
|
580
|
+
return <td
|
|
581
|
+
className={isLightColor(field.color)?"lighted":"unlighted"} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}
|
|
582
|
+
key={field.name}>{hiddenable(new Date(item[field.name]).toLocaleDateString(i18n.resolvedLanguage || i18n.language, {
|
|
583
|
+
day: "numeric",
|
|
584
|
+
month: "numeric",
|
|
585
|
+
year: "numeric"
|
|
586
|
+
}))}</td>;
|
|
587
|
+
}
|
|
588
|
+
if (field.type === "datetime" && item[field.name]) {
|
|
589
|
+
return <td
|
|
590
|
+
className={isLightColor(field.color)?"lighted":"unlighted"} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}
|
|
591
|
+
key={field.name}>{hiddenable(new Date(item[field.name]).toLocaleDateString(i18n.resolvedLanguage || i18n.language, {
|
|
592
|
+
day: "numeric",
|
|
593
|
+
month: "numeric",
|
|
594
|
+
year: "numeric",
|
|
595
|
+
hour: "numeric",
|
|
596
|
+
minute: "numeric"
|
|
597
|
+
}))}</td>;
|
|
598
|
+
}
|
|
599
|
+
if (field.type === "enum") {
|
|
600
|
+
return <td
|
|
601
|
+
key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(field.items.includes(item[field.name]) ? `${t(item[field.name], item[field.name])}` : `${item[field.name] || ''}`)}</td>;
|
|
602
|
+
}
|
|
603
|
+
if (field.type === "code") {
|
|
604
|
+
const v = typeof(item[field.name]) === "string" ? item[field.name] : (item[field.name] ? JSON.stringify(item[field.name], null, 2) : '');
|
|
605
|
+
return <td
|
|
606
|
+
key={field.name}>{v && hiddenable(<CodeField language={field.language} name={field.name} value={v} disabled={true} />)}</td>;
|
|
607
|
+
}
|
|
608
|
+
if (field.type === "object") {
|
|
609
|
+
return <td key={field.name}>{hiddenable(<CodeField language={'json'} name={field.name} value={item[field.name] ? JSON.stringify(item[field.name], null, 2) : ''} disabled={true} />)}</td>;
|
|
610
|
+
}
|
|
611
|
+
if (field.type === 'email') {
|
|
612
|
+
return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}} className={isLightColor(field.color)?"lighted":""}><a href={"mailto:"+item[field.name]} style={{color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(item[field.name])}</a></td>;
|
|
613
|
+
}
|
|
614
|
+
if (field.type === 'phone') {
|
|
615
|
+
return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}} className={isLightColor(field.color)?"lighted":""}>{hiddenable(item[field.name] && <PhoneField name={"phone"} value={item[field.name]} disabled={true} onChange={() => {}}></PhoneField>)}</td>;
|
|
616
|
+
}
|
|
617
|
+
if (field.type === 'model') {
|
|
618
|
+
return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}} className={isLightColor(field.color)?"lighted":""}>{hiddenable(item[field.name] ? `${t('model_'+item[field.name], item[field.name])} (${item[field.name]})`:'')}</td>;
|
|
619
|
+
}
|
|
620
|
+
if (field.type === 'geolocation') {
|
|
621
|
+
const geoData = item[field.name];
|
|
622
|
+
console.log({geoData})
|
|
623
|
+
if (geoData && geoData.coordinates && geoData.coordinates.length === 2) {
|
|
624
|
+
const [lng, lat] = geoData.coordinates;
|
|
625
|
+
const coordinatesText = `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
|
|
626
|
+
const mapUrl = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`;
|
|
627
|
+
return (
|
|
628
|
+
<td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}} className={isLightColor(field.color)?"lighted":""}>
|
|
629
|
+
{hiddenable(
|
|
630
|
+
<a href={mapUrl} target="_blank" rel="noopener noreferrer" style={{color: 'inherit'}}>
|
|
631
|
+
{coordinatesText}
|
|
632
|
+
</a>
|
|
633
|
+
)}
|
|
634
|
+
</td>
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}} className={isLightColor(field.color)?"lighted":""}>{hiddenable('')}</td>;
|
|
638
|
+
}
|
|
639
|
+
if (field.type === 'password') {
|
|
640
|
+
return <></>;
|
|
641
|
+
}
|
|
642
|
+
if (field.type === 'array') {
|
|
643
|
+
let t = <></>
|
|
644
|
+
if (field.itemsType === 'file') {
|
|
645
|
+
const click = (e,i) => {
|
|
646
|
+
setLightboxIndex(i);
|
|
647
|
+
setLightboxOpened(true);
|
|
648
|
+
setLightboxSlides(item[field.name].map(s => ({
|
|
649
|
+
src: `/resources/${s.guid}`,
|
|
650
|
+
title: s.name
|
|
651
|
+
})));
|
|
652
|
+
e.preventDefault();
|
|
653
|
+
};
|
|
654
|
+
t = (item[field.name] || []).map((it,i) => {
|
|
655
|
+
|
|
656
|
+
const r = `
|
|
657
|
+
<strong>filename</strong> : ${it.filename}<br />
|
|
658
|
+
<strong>guid</strong> : ${it.guid}<br />
|
|
659
|
+
<strong>type</strong> : ${it.mimeType}<br />
|
|
660
|
+
<strong>size</strong> : ${it.size} bytes<br />
|
|
661
|
+
<strong>timestamp</strong> : ${it.timestamp ? new Date(it.timestamp).toLocaleString(lang) : ''}
|
|
662
|
+
`;
|
|
663
|
+
return <a key={it.guid} href={`/resources/${it.guid}`} target="_blank"
|
|
664
|
+
data-tooltip-id={"tooltipFile"} data-tooltip-html={r}
|
|
665
|
+
rel="noopener noreferrer" onClick={(e) => click(e,i)}><img
|
|
666
|
+
className="image" src={`/resources/${it.guid}`}
|
|
667
|
+
alt={`${it.name} (${it.guid})`}/></a>
|
|
668
|
+
});
|
|
669
|
+
return <td key={field.name}>
|
|
670
|
+
{hiddenable(<div className="gallery">{t}</div>)}
|
|
671
|
+
</td>;
|
|
672
|
+
}
|
|
673
|
+
return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(item[field.name]?.join(', ') || '')}</td>;
|
|
674
|
+
}
|
|
675
|
+
if (field.type === 'url') {
|
|
676
|
+
return <td key={field.name}>
|
|
677
|
+
{hiddenable(item[field.name] && (<><a href={item[field.name]}
|
|
678
|
+
title={item[field.name]} style={{color: field.color}}
|
|
679
|
+
className={"link-value"}
|
|
680
|
+
target="_blank">{item[field.name]}</a>
|
|
681
|
+
<button title={"Copy URL"} onClick={(e) => {
|
|
682
|
+
navigator.clipboard.writeText(item[field.name]).then(function () {
|
|
683
|
+
console.log('Async: Copying to clipboard was successful!');
|
|
684
|
+
}, function (err) {
|
|
685
|
+
console.error('Async: Could not copy text: ', err);
|
|
686
|
+
});
|
|
687
|
+
}}><FaCopy/></button>
|
|
688
|
+
</>))}</td>;
|
|
689
|
+
}
|
|
690
|
+
if (field.type === "file" && item[field.name]) {
|
|
691
|
+
const r = `
|
|
692
|
+
<strong>filename</strong> : ${item[field.name].filename}<br />
|
|
693
|
+
<strong>guid</strong> : ${item[field.name].guid}<br />
|
|
694
|
+
<strong>type</strong> : ${item[field.name].mimeType}<br />
|
|
695
|
+
<strong>size</strong> : ${item[field.name].size} bytes<br />
|
|
696
|
+
<strong>timestamp</strong> : ${item[field.name].timestamp.toLocaleString(lang)}
|
|
697
|
+
`;
|
|
698
|
+
if (['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/svg+xml', 'image/webp', 'image/bmp', 'image/tiff', 'image/x-icon', 'image/x-windows-bmp'].includes(item[field.name].mimeType))
|
|
699
|
+
return <td key={field.name}>{hiddenable(<a
|
|
700
|
+
data-tooltip-id={"tooltipFile"} data-tooltip-html={r}
|
|
701
|
+
href={`/resources/${item[field.name].guid}`} target="_blank"
|
|
702
|
+
rel="noopener noreferrer"><img className="image"
|
|
703
|
+
src={`/resources/${item[field.name].guid}`}
|
|
704
|
+
alt={`${item[field.name].filename}`}/></a>
|
|
705
|
+
)}</td>;
|
|
706
|
+
return <td key={field.name} className={isLightColor(field.color)?"lighted":""}><a style={{color: field.color}}
|
|
707
|
+
href={`/resources/${item[field.name].guid}`}
|
|
708
|
+
target="_blank"
|
|
709
|
+
rel="noopener noreferrer" data-tooltip-id={"tooltipFile"} data-tooltip-html={r}>{hiddenable(item[field.name].filename)}</a>
|
|
710
|
+
</td>;
|
|
711
|
+
}
|
|
712
|
+
if (field.type === 'number') {
|
|
713
|
+
if (field.delay) {
|
|
714
|
+
return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(formatDuration(item[field.name], t))}</td>;
|
|
715
|
+
}
|
|
716
|
+
if (field.gauge) {
|
|
717
|
+
const value = item[field.name];
|
|
718
|
+
if (value == null) {
|
|
719
|
+
return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color}}></td>;
|
|
720
|
+
}
|
|
721
|
+
const min = field.min || 0;
|
|
722
|
+
const max = field.max || 100;
|
|
723
|
+
const percentage = max > min ? Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)) : 0;
|
|
724
|
+
|
|
725
|
+
const displayValue = field.percent ? `${Math.round(percentage)}%` : value;
|
|
726
|
+
const title = field.percent ? `${value} (${Math.round(percentage)}%)` : `${value} / ${max}`;
|
|
727
|
+
|
|
728
|
+
return (
|
|
729
|
+
<td key={field.name} className={isLightColor(field.color) ? "lighted" : "unlighted"} style={{backgroundColor: field.color}}>
|
|
730
|
+
{hiddenable(
|
|
731
|
+
<div className="gauge-container" data-tooltip-id={"tooltipFile"} data-tooltip-content={title}>
|
|
732
|
+
<div className="gauge-bar" style={{ width: `${percentage}%` }}></div>
|
|
733
|
+
<span className="gauge-label">{displayValue}</span>
|
|
734
|
+
</div>
|
|
735
|
+
)}
|
|
736
|
+
</td>);
|
|
737
|
+
}
|
|
738
|
+
let val = item[field.name];
|
|
739
|
+
if (val && field.unit) {
|
|
740
|
+
let formatter = new Intl.NumberFormat(lang);
|
|
741
|
+
val = formatter.format(item[field.name]);
|
|
742
|
+
}
|
|
743
|
+
return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(val ? `${val} ${field.unit || ''}` : '')}</td>;
|
|
744
|
+
}
|
|
745
|
+
if (field.type === "boolean") {
|
|
746
|
+
return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(item[field.name] ? t('yes') : t('no'))}</td>;
|
|
747
|
+
}
|
|
748
|
+
if (field.type === 'string_t') {
|
|
749
|
+
return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>{hiddenable(item[field.name] ? (item[field.name].value || item[field.name].key) : '')}</td>;
|
|
750
|
+
}
|
|
751
|
+
if (field.type === 'richtext') {
|
|
752
|
+
return <td key={field.name} className={isLightColor(field.color)?"lighted":""}>
|
|
753
|
+
{hiddenable(<div className="rte-value"
|
|
754
|
+
dangerouslySetInnerHTML={{__html: item[field.name]}}></div>)}
|
|
755
|
+
</td>;
|
|
756
|
+
}
|
|
757
|
+
if (field.type === 'richtext_t') {
|
|
758
|
+
return <td key={field.name} className={isLightColor(field.color)?"lighted":""}>
|
|
759
|
+
{hiddenable(<RichText value={item[field.name]} initialLang={lang} />)}
|
|
760
|
+
</td>;
|
|
761
|
+
}
|
|
762
|
+
if (field.type === 'color') {
|
|
763
|
+
return <td key={field.name} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>
|
|
764
|
+
{hiddenable(<ColorField name={field.name} disabled={true}
|
|
765
|
+
value={item[field.name]}/>)}</td>;
|
|
766
|
+
}
|
|
767
|
+
return <td key={field.name} className={isLightColor(field.color)?"lighted":""} style={{backgroundColor: field.color, color: isLightColor(field.color) ? 'black': '#E3E3E3'}}>
|
|
768
|
+
{hiddenable(item[field.name])}</td>;
|
|
769
|
+
})}
|
|
770
|
+
{advanced && (<td>
|
|
771
|
+
<button data-tooltip-id="tooltipActions"
|
|
772
|
+
data-tooltip-content={t('btns.edit', 'Modifier')}
|
|
773
|
+
onClick={() => handleEdit(item)}><FaPencil/></button>
|
|
774
|
+
<button
|
|
775
|
+
onClick={() => handleDuplicate(item)}
|
|
776
|
+
data-tooltip-id="tooltipActions"
|
|
777
|
+
data-tooltip-content={t('btns.duplicate', 'Dupliquer')}
|
|
778
|
+
>
|
|
779
|
+
<FaCopy/>
|
|
780
|
+
</button>
|
|
781
|
+
{selectedModel?.history?.enabled && (<button
|
|
782
|
+
onClick={() => setSelectedRow(item._id)}
|
|
783
|
+
data-tooltip-id="tooltipActions"
|
|
784
|
+
data-tooltip-content={t('btns.showHistory', 'Voir l\'historique')}
|
|
785
|
+
>
|
|
786
|
+
<FaHistory />
|
|
787
|
+
</button>)}
|
|
788
|
+
|
|
789
|
+
<button data-tooltip-id="tooltipActions"
|
|
790
|
+
data-tooltip-content={t('btns.delete', 'Supprimer')}
|
|
791
|
+
onClick={() => handleDelete(item)}><FaTrash/></button>
|
|
792
|
+
</td>)}
|
|
793
|
+
</tr>
|
|
794
|
+
</DialogProvider></>
|
|
795
|
+
))}
|
|
796
|
+
|
|
797
|
+
</tbody>
|
|
798
|
+
|
|
799
|
+
<tfoot>
|
|
800
|
+
{data.length > 10 && (<Header advanced={advanced} reversed={true} model={model} setCheckedItems={setCheckedItems}
|
|
801
|
+
filterValues={filterValues} data={data}
|
|
802
|
+
setFilterValues={setFilterValues}
|
|
803
|
+
onChangeFilterValue={onChangeFilterValue}
|
|
804
|
+
checkedItems={checkedItems} filterActive={filterActive} handleFilter={handleFilter}/>)}
|
|
805
|
+
</tfoot>
|
|
806
|
+
|
|
807
|
+
</table>)}
|
|
808
|
+
{!isDataLoaded && <div className="spinner-loader"></div>}
|
|
809
|
+
<DialogProvider>
|
|
810
|
+
{importVisible && (<DataImporter onClose={() => {
|
|
811
|
+
setImportVisible(false);
|
|
812
|
+
}}/>)}
|
|
813
|
+
|
|
814
|
+
{selectedRow && (
|
|
815
|
+
<HistoryDialog onClose={() => setSelectedRow(null)} modelName={selectedModel?.name} recordId={selectedRow} />
|
|
816
|
+
)}
|
|
817
|
+
<RestoreConfirmationModal
|
|
818
|
+
isOpen={isBackupModalOpen}
|
|
819
|
+
onClose={() => setIsBackupModalOpen(false)}
|
|
820
|
+
onConfirm={handleConfirmRestore}
|
|
821
|
+
/>
|
|
822
|
+
<ExportDialog isOpen={showExportDialog} onClose={() => {
|
|
823
|
+
setExportDialogVisible(false);
|
|
824
|
+
}} availableModels={models} currentModel={selectedModel.name} hasSelection={true} onExport={(data)=>{
|
|
825
|
+
exportMutation(data);
|
|
826
|
+
}} />
|
|
827
|
+
{showPackGallery && (
|
|
828
|
+
<Dialog isClosable={true} isModal={true} onClose={() => setShowPackGallery(false)}>
|
|
829
|
+
<PackGallery />
|
|
830
|
+
</Dialog>
|
|
831
|
+
)}
|
|
832
|
+
</DialogProvider>
|
|
833
|
+
</div>
|
|
834
|
+
</div>
|
|
835
|
+
);
|
|
836
|
+
}
|