data-primals-engine 1.3.4 → 1.4.0

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