data-primals-engine 1.5.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/client/src/AddWidgetTypeModal.jsx +47 -43
- package/client/src/App.jsx +2 -6
- package/client/src/App.scss +12 -0
- package/client/src/AssistantChat.jsx +363 -323
- package/client/src/AssistantChat.scss +27 -10
- package/client/src/Dashboard.jsx +480 -396
- package/client/src/Dashboard.scss +1 -1
- package/client/src/DashboardHtmlViewItem.jsx +147 -0
- package/client/src/DashboardView.jsx +654 -569
- package/client/src/DataEditor.jsx +10 -3
- package/client/src/DataLayout.jsx +805 -755
- package/client/src/DataLayout.scss +14 -0
- package/client/src/DataTable.jsx +39 -75
- package/client/src/Dialog.scss +1 -1
- package/client/src/Field.jsx +2057 -1825
- package/client/src/FlexViewCard.jsx +44 -0
- package/client/src/HistoryDialog.jsx +47 -14
- package/client/src/HtmlViewBuilderModal.jsx +91 -0
- package/client/src/HtmlViewBuilderModal.scss +18 -0
- package/client/src/HtmlViewCard.jsx +44 -0
- package/client/src/HtmlViewCard.scss +35 -0
- package/client/src/KanbanCard.jsx +1 -2
- package/client/src/ModelCreator.jsx +5 -4
- package/client/src/ModelCreatorField.jsx +51 -4
- package/client/src/ModelList.jsx +92 -53
- package/client/src/Notification.jsx +136 -136
- package/client/src/Notification.scss +0 -18
- package/client/src/Pagination.jsx +5 -3
- package/client/src/RelationField.jsx +354 -258
- package/client/src/RelationSelectorWidget.jsx +173 -0
- package/client/src/contexts/ModelContext.jsx +10 -1
- package/client/src/contexts/UIContext.jsx +72 -63
- package/client/src/filter.js +262 -212
- package/client/src/hooks/useValidation.js +75 -0
- package/client/src/translations.js +24 -24
- package/package.json +2 -1
- package/src/constants.js +1 -1
- package/src/defaultModels.js +1596 -1544
- package/src/i18n.js +710 -10
- package/src/modules/assistant/assistant.js +148 -18
- package/src/modules/bucket.js +2 -1
- package/src/modules/data/data.core.js +118 -92
- package/src/modules/data/data.history.js +531 -492
- package/src/modules/data/data.js +3 -53
- package/src/modules/data/data.operations.js +77 -26
- package/src/modules/data/data.relations.js +686 -686
- package/src/modules/data/data.routes.js +1879 -1821
- package/src/modules/data/data.validation.js +81 -2
- package/src/modules/file.js +247 -238
- package/src/packs.js +5482 -5478
- package/test/data.integration.test.js +1115 -1060
package/client/src/ModelList.jsx
CHANGED
|
@@ -3,7 +3,7 @@ import {Trans, useTranslation} from "react-i18next";
|
|
|
3
3
|
import {useAuthContext} from "./contexts/AuthContext.jsx";
|
|
4
4
|
import {useMutation, useQueryClient} from "react-query";
|
|
5
5
|
import React, {useEffect, useMemo, useState} from "react";
|
|
6
|
-
import {FaBook, FaEdit, FaFileImport, FaPlus} from "react-icons/fa";
|
|
6
|
+
import {FaBook, FaBoxOpen, FaEdit, FaFileImport, FaPlus} from "react-icons/fa";
|
|
7
7
|
import Button from "./Button.jsx";
|
|
8
8
|
import useLocalStorage from "./hooks/useLocalStorage.js";
|
|
9
9
|
import {Tooltip} from "react-tooltip";
|
|
@@ -12,7 +12,7 @@ import {profiles} from "./constants.js";
|
|
|
12
12
|
import * as FaIcons from "react-icons/fa";
|
|
13
13
|
import * as Fa6Icons from "react-icons/fa6";
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// --- SUGGESTION: Extraire cette logique dans un composant dédié si elle devient plus complexe ---
|
|
16
16
|
// Fonction pour obtenir le composant icône par son nom
|
|
17
17
|
const getIconComponent = (iconName) => {
|
|
18
18
|
if (!iconName) return null;
|
|
@@ -20,8 +20,22 @@ const getIconComponent = (iconName) => {
|
|
|
20
20
|
return IconComponent ? <IconComponent /> : null; // Retourne l'élément React ou null
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
// --- SUGGESTION: Créer un petit composant pour la lisibilité de l'affichage du nom du modèle ---
|
|
24
|
+
const ModelListItemLabel = ({ model, count, isGenerated, t }) => {
|
|
25
|
+
const modelName = t(`model_${model.name}`, model.name);
|
|
26
|
+
|
|
27
|
+
let suffix = '';
|
|
28
|
+
if (isGenerated) {
|
|
29
|
+
suffix = ` (${t('models.status.tmp', 'brouillon')})`; // Utiliser i18n pour 'tmp'
|
|
30
|
+
} else if (count > 0) {
|
|
31
|
+
suffix = ` (${count})`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return <>{modelName}{suffix}</>;
|
|
35
|
+
};
|
|
36
|
+
|
|
23
37
|
|
|
24
|
-
export function ModelList({ onModelSelect, onCreateModel, onImportModel, onEditModel, onAPIInfo, onNewData }) {
|
|
38
|
+
export function ModelList({ onModelSelect, onCreateModel, onImportModel, onEditModel, onAPIInfo, onNewData, onImportPack }) {
|
|
25
39
|
const {allTourSteps, setIsTourOpen,setCurrentTourSteps, setTourStepIndex, currentTour, setCurrentTour} = useUI();
|
|
26
40
|
|
|
27
41
|
const {models, setSelectedModel, selectedModel, countByModel, generatedModels} = useModelContext();
|
|
@@ -29,6 +43,7 @@ export function ModelList({ onModelSelect, onCreateModel, onImportModel, onEditM
|
|
|
29
43
|
const {me} = useAuthContext();
|
|
30
44
|
const queryClient = useQueryClient()
|
|
31
45
|
const [searchTerm, setSearchTerm] = useState('');
|
|
46
|
+
const [selectedTag, setSelectedTag] = useLocalStorage('modelList-selectedTag', 'all');
|
|
32
47
|
|
|
33
48
|
const [currentProfile, setCurrentProfile] = useLocalStorage('profile', null);
|
|
34
49
|
useEffect(() =>{
|
|
@@ -37,20 +52,38 @@ export function ModelList({ onModelSelect, onCreateModel, onImportModel, onEditM
|
|
|
37
52
|
}
|
|
38
53
|
}, [me])
|
|
39
54
|
|
|
40
|
-
const
|
|
55
|
+
const allTags = useMemo(() => {
|
|
41
56
|
if (!models) return [];
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
f.name?.toLowerCase().includes(lowerSearchTerm) ||
|
|
50
|
-
f.hint?.toLowerCase().includes(lowerSearchTerm)) ||
|
|
51
|
-
(model.description && model.description.toLowerCase().includes(lowerSearchTerm)))
|
|
57
|
+
const tagsSet = new Set();
|
|
58
|
+
models
|
|
59
|
+
.filter(model => model._user === me?.username) // On ne prend que les tags des modèles de l'utilisateur
|
|
60
|
+
.forEach(model => {
|
|
61
|
+
if (model.tags && Array.isArray(model.tags)) {
|
|
62
|
+
model.tags.forEach(tag => tagsSet.add(tag));
|
|
63
|
+
}
|
|
52
64
|
});
|
|
53
|
-
|
|
65
|
+
return Array.from(tagsSet).sort();
|
|
66
|
+
}, [models, me?.username]);
|
|
67
|
+
|
|
68
|
+
console.log(allTags,"t");
|
|
69
|
+
const filteredModels = useMemo(() => {
|
|
70
|
+
if (!models) return [];
|
|
71
|
+
let results = models.filter(model => model._user === me?.username);
|
|
72
|
+
|
|
73
|
+
// Filtrage par tag
|
|
74
|
+
if (selectedTag && selectedTag !== 'all') {
|
|
75
|
+
results = results.filter(model => model.tags?.includes(selectedTag));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Filtrage par terme de recherche
|
|
79
|
+
if (searchTerm.trim()) {
|
|
80
|
+
const lowerSearchTerm = searchTerm.toLowerCase();
|
|
81
|
+
results = results.filter(model =>
|
|
82
|
+
(t('model_' + model.name, model.name).toLowerCase().includes(lowerSearchTerm)) ||
|
|
83
|
+
(model.description && model.description.toLowerCase().includes(lowerSearchTerm)));
|
|
84
|
+
}
|
|
85
|
+
return results;
|
|
86
|
+
}, [models, searchTerm, selectedTag, me?.username, t]);
|
|
54
87
|
|
|
55
88
|
const handleSelectModel = (model) => {
|
|
56
89
|
setSelectedModel(model);
|
|
@@ -96,28 +129,6 @@ export function ModelList({ onModelSelect, onCreateModel, onImportModel, onEditM
|
|
|
96
129
|
});
|
|
97
130
|
};
|
|
98
131
|
|
|
99
|
-
const [mods, setMods] = useState([]);
|
|
100
|
-
useEffect(() => {
|
|
101
|
-
if( countByModel && selectedModel)
|
|
102
|
-
{
|
|
103
|
-
setMods((m) => {
|
|
104
|
-
const counts = [...m];
|
|
105
|
-
const mod = counts.find(f => f.mod === selectedModel.name);
|
|
106
|
-
if( mod ){
|
|
107
|
-
mod.count = countByModel[selectedModel.name];
|
|
108
|
-
return counts.map((c => {
|
|
109
|
-
if( c.mod === mod.mod) {
|
|
110
|
-
return {...mod};
|
|
111
|
-
}
|
|
112
|
-
return c;
|
|
113
|
-
}));
|
|
114
|
-
}else{
|
|
115
|
-
return [...m, { mod: selectedModel.name, count: countByModel[selectedModel.name]}];
|
|
116
|
-
}
|
|
117
|
-
})
|
|
118
|
-
}
|
|
119
|
-
}, [countByModel, selectedModel]);
|
|
120
|
-
|
|
121
132
|
// --- NEW: Effect to update tour steps when a profile is already selected on mount ---
|
|
122
133
|
useEffect(() => {
|
|
123
134
|
if (currentProfile && currentTour && /^demo[0-9]{1,2}$/.test(me?.username)) { // Only run on demo user for simplicity
|
|
@@ -163,9 +174,9 @@ export function ModelList({ onModelSelect, onCreateModel, onImportModel, onEditM
|
|
|
163
174
|
}} />
|
|
164
175
|
</div></div>}
|
|
165
176
|
<div className="models">
|
|
166
|
-
<h2><Trans i18nKey="models">Modèles</Trans></h2>
|
|
177
|
+
<h2 className={"field-bg p-2"}><Trans i18nKey="models">Modèles</Trans></h2>
|
|
167
178
|
<div className="model-list-container">
|
|
168
|
-
<div className="model-list-search-bar-container">
|
|
179
|
+
<div className="flex flex-no-wrap model-list-search-bar-container">
|
|
169
180
|
<input
|
|
170
181
|
type="search"
|
|
171
182
|
placeholder={t('models.searchPlaceholder', 'Rechercher un modèle...')}
|
|
@@ -173,6 +184,18 @@ export function ModelList({ onModelSelect, onCreateModel, onImportModel, onEditM
|
|
|
173
184
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
174
185
|
className="model-list-search-input"
|
|
175
186
|
/>
|
|
187
|
+
{allTags.length > 0 && (
|
|
188
|
+
<div className="model-list-tag-filter">
|
|
189
|
+
<select value={selectedTag} onChange={(e) => setSelectedTag(e.target.value)}>
|
|
190
|
+
<option value="all">{t('models.tags.all', 'Tous les tags')}</option>
|
|
191
|
+
{allTags.map(tag => (
|
|
192
|
+
<option key={tag} value={tag}>
|
|
193
|
+
{t(`tags.${tag}`, tag.charAt(0).toUpperCase() + tag.slice(1))}
|
|
194
|
+
</option>
|
|
195
|
+
))}
|
|
196
|
+
</select>
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
176
199
|
</div>
|
|
177
200
|
{filteredModels.length > 0 ? (
|
|
178
201
|
<div className="model-list">
|
|
@@ -185,15 +208,23 @@ export function ModelList({ onModelSelect, onCreateModel, onImportModel, onEditM
|
|
|
185
208
|
|
|
186
209
|
const IconComponent = getIconComponent(model.icon);
|
|
187
210
|
|
|
211
|
+
// --- SUGGESTION: Rendre le comportement du clic prévisible ---
|
|
212
|
+
// Le clic principal sélectionne toujours le modèle. L'édition est une action secondaire via son bouton.
|
|
213
|
+
const handleItemClick = () => {
|
|
214
|
+
onModelSelect(model);
|
|
215
|
+
};
|
|
216
|
+
|
|
188
217
|
return (
|
|
189
218
|
<li data-testid={'model_'+model.name} className={`${model.name === selectedModel?.name ? 'active' : ''}`}
|
|
190
|
-
key={'
|
|
219
|
+
key={'modellist' + model.name} onClick={handleItemClick}>
|
|
191
220
|
<div className="flex flex-center flex-fw">
|
|
192
221
|
<div
|
|
193
222
|
className="flex flex-1 flex-no-wrap break-word gap-2">
|
|
194
223
|
<div className={"icon"}>{IconComponent ? IconComponent : <></>}</div>
|
|
195
|
-
|
|
224
|
+
{/* --- SUGGESTION: Utiliser le composant pour la clarté --- */}
|
|
225
|
+
<div><ModelListItemLabel model={model} count={countByModel?.[model.name]} isGenerated={generatedModels.some(f => f.name === model.name)} t={t} /></div></div>
|
|
196
226
|
<div className="btns">
|
|
227
|
+
{/* Le bouton "Ajouter" est conditionnel, ce qui est bien */}
|
|
197
228
|
{!generatedModels.some(g => g.name === model.name) && (<button data-tooltip-id="tooltipML" data-tooltip-content={t('btns.addData')} onClick={(e) => {
|
|
198
229
|
e.stopPropagation();
|
|
199
230
|
e.preventDefault();
|
|
@@ -216,21 +247,29 @@ export function ModelList({ onModelSelect, onCreateModel, onImportModel, onEditM
|
|
|
216
247
|
</li>)
|
|
217
248
|
})}
|
|
218
249
|
</ul>
|
|
219
|
-
|
|
220
250
|
</div>) : (
|
|
221
|
-
<
|
|
222
|
-
|
|
223
|
-
|
|
251
|
+
<div className="empty-state-container">
|
|
252
|
+
<div className="empty-state-content">
|
|
253
|
+
<div className="empty-state-icon">
|
|
254
|
+
{/* Une icône SVG simple pour représenter des "blocs de construction" ou des "données" */}
|
|
255
|
+
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
256
|
+
<path d="M14 10L14 4L20 4L20 10L14 10Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
257
|
+
<path d="M4 20L4 14L10 14L10 20L4 20Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
258
|
+
<path d="M4 10L4 4L10 4L10 10L4 10Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
259
|
+
<path d="M14 20L14 14L20 14L20 20L14 20Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
260
|
+
</svg>
|
|
261
|
+
</div>
|
|
262
|
+
<h3>{t('models.empty.title', 'Commencez à structurer vos données')}</h3>
|
|
263
|
+
<p>
|
|
264
|
+
{searchTerm ?
|
|
265
|
+
t('models.noMatch', 'Aucun modèle ne correspond à votre recherche.') :
|
|
266
|
+
t('models.empty.description', 'Créez votre premier modèle de A à Z ou importez une structure existante pour démarrer rapidement.')
|
|
267
|
+
}
|
|
268
|
+
</p>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
224
271
|
)}
|
|
225
|
-
<div className="flex actions">
|
|
226
|
-
<Button onClick={onCreateModel} className="btn tourStep-create-model"><FaPlus/><Trans
|
|
227
|
-
i18nKey="btns.createModel">Créer un modèle</Trans></Button>
|
|
228
|
-
<Button onClick={onImportModel}
|
|
229
|
-
className="btn tourStep-import-model btn-primary"><FaFileImport/><Trans
|
|
230
|
-
i18nKey="btns.importModels">Importer un modèle</Trans></Button>
|
|
231
|
-
</div>
|
|
232
272
|
</div>
|
|
233
273
|
</div>
|
|
234
274
|
</>
|
|
235
275
|
}
|
|
236
|
-
|
|
@@ -1,137 +1,137 @@
|
|
|
1
|
-
import React, {useState, useEffect, useRef, useCallback, forwardRef} from 'react';
|
|
2
|
-
import { FaCheck, FaSpinner, FaExclamationTriangle, FaBell, FaTimes, FaTrash } from 'react-icons/fa'; // Example icons
|
|
3
|
-
import { useNotificationContext } from './NotificationProvider.jsx'; // Import the context
|
|
4
|
-
import './Notification.scss';
|
|
5
|
-
|
|
6
|
-
/*
|
|
7
|
-
const { addNotification } = useNotificationContext();
|
|
8
|
-
const notificationData = {
|
|
9
|
-
title: 'New Notification',
|
|
10
|
-
message: 'This is a new notification message.',
|
|
11
|
-
icon: <FaBell />,
|
|
12
|
-
status: ['ongoing','completed','error'][Math.floor(Math.random()*3)]
|
|
13
|
-
};
|
|
14
|
-
*/
|
|
15
|
-
// Helper function to generate a unique ID
|
|
16
|
-
const generateNotificationId = () => {
|
|
17
|
-
return Math.random().toString(36).substring(2, 9);
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
const Notification = ({ notification, onClick }) => {
|
|
21
|
-
const { markAsRead, removeNotification } = useNotificationContext();
|
|
22
|
-
const [dismissTimer, setDismissTimer] = useState(null);
|
|
23
|
-
const notificationRef = useRef(null);
|
|
24
|
-
const isMounted = useRef(false)
|
|
25
|
-
|
|
26
|
-
const handleMarkAsRead = useCallback(() => {
|
|
27
|
-
markAsRead(notification.id);
|
|
28
|
-
}, [markAsRead, notification.id]);
|
|
29
|
-
|
|
30
|
-
useEffect(() => {
|
|
31
|
-
const t = setTimeout(()=>{
|
|
32
|
-
markAsRead(notification.id);
|
|
33
|
-
}, notification.timeout || 12000);
|
|
34
|
-
return () => {
|
|
35
|
-
clearTimeout(t);
|
|
36
|
-
}
|
|
37
|
-
}, [notification]);
|
|
38
|
-
|
|
39
|
-
useEffect(() => {
|
|
40
|
-
if (notification.isRead) {
|
|
41
|
-
const timer = setTimeout(() => {
|
|
42
|
-
removeNotification(notification.id);
|
|
43
|
-
}, 30000);
|
|
44
|
-
|
|
45
|
-
return () => {
|
|
46
|
-
clearTimeout(timer);
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
}, [notification.isRead, notification.id, removeNotification]);
|
|
50
|
-
|
|
51
|
-
let statusIcon;
|
|
52
|
-
switch (notification.status) {
|
|
53
|
-
case 'ongoing':
|
|
54
|
-
statusIcon = <FaSpinner className="spinner" />;
|
|
55
|
-
break;
|
|
56
|
-
case 'completed':
|
|
57
|
-
statusIcon = <FaCheck />;
|
|
58
|
-
break;
|
|
59
|
-
case 'error':
|
|
60
|
-
statusIcon = <FaExclamationTriangle />;
|
|
61
|
-
break;
|
|
62
|
-
default:
|
|
63
|
-
statusIcon = null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const removeBtn = notification.isRead ? <button onClick={() => {
|
|
67
|
-
removeNotification(notification.id);
|
|
68
|
-
}}><FaTrash /></button> : <></>
|
|
69
|
-
return (
|
|
70
|
-
<div ref={notificationRef} onClick={onClick} className={`notification ${notification.isExpanded ? 'expanded' : 'collapsed'} ${notification.status}`}>
|
|
71
|
-
<div className="notification-header">
|
|
72
|
-
{notification.icon && <span className="notification-icon">{notification.icon}</span>}
|
|
73
|
-
<span className="notification-title">{notification.title}</span>
|
|
74
|
-
{statusIcon && <span className="notification-status">{statusIcon}</span>}
|
|
75
|
-
{removeBtn}
|
|
76
|
-
</div>
|
|
77
|
-
{<div className="notification-content">
|
|
78
|
-
<p className="notification-message">{notification.message}</p>
|
|
79
|
-
</div>}
|
|
80
|
-
</div>
|
|
81
|
-
);
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const NotificationList = forwardRef((props, ref) => {
|
|
85
|
-
const { notifications, markAsRead, removeNotification, addNotification, unreadCount } = useNotificationContext();
|
|
86
|
-
const [showBubble, setShowBubble] = useState(false);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
<
|
|
120
|
-
<
|
|
121
|
-
<FaBell/>
|
|
122
|
-
{unreadCount > 0 && <span className="notification-bubble-count">{unreadCount}</span>}
|
|
123
|
-
</
|
|
124
|
-
{showBubble && <div className='notifications-bubble-content'>
|
|
125
|
-
{readNotifications.map(notification => (
|
|
126
|
-
<Notification
|
|
127
|
-
key={notification.id}
|
|
128
|
-
notification={notification}
|
|
129
|
-
/>
|
|
130
|
-
))}
|
|
131
|
-
</div>}
|
|
132
|
-
</
|
|
133
|
-
|
|
134
|
-
);
|
|
135
|
-
});
|
|
136
|
-
NotificationList.displayName = "NotificationList";
|
|
1
|
+
import React, {useState, useEffect, useRef, useCallback, forwardRef} from 'react';
|
|
2
|
+
import { FaCheck, FaSpinner, FaExclamationTriangle, FaBell, FaTimes, FaTrash } from 'react-icons/fa'; // Example icons
|
|
3
|
+
import { useNotificationContext } from './NotificationProvider.jsx'; // Import the context
|
|
4
|
+
import './Notification.scss';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
const { addNotification } = useNotificationContext();
|
|
8
|
+
const notificationData = {
|
|
9
|
+
title: 'New Notification',
|
|
10
|
+
message: 'This is a new notification message.',
|
|
11
|
+
icon: <FaBell />,
|
|
12
|
+
status: ['ongoing','completed','error'][Math.floor(Math.random()*3)]
|
|
13
|
+
};
|
|
14
|
+
*/
|
|
15
|
+
// Helper function to generate a unique ID
|
|
16
|
+
const generateNotificationId = () => {
|
|
17
|
+
return Math.random().toString(36).substring(2, 9);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const Notification = ({ notification, onClick }) => {
|
|
21
|
+
const { markAsRead, removeNotification } = useNotificationContext();
|
|
22
|
+
const [dismissTimer, setDismissTimer] = useState(null);
|
|
23
|
+
const notificationRef = useRef(null);
|
|
24
|
+
const isMounted = useRef(false)
|
|
25
|
+
|
|
26
|
+
const handleMarkAsRead = useCallback(() => {
|
|
27
|
+
markAsRead(notification.id);
|
|
28
|
+
}, [markAsRead, notification.id]);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const t = setTimeout(()=>{
|
|
32
|
+
markAsRead(notification.id);
|
|
33
|
+
}, notification.timeout || 12000);
|
|
34
|
+
return () => {
|
|
35
|
+
clearTimeout(t);
|
|
36
|
+
}
|
|
37
|
+
}, [notification]);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (notification.isRead) {
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
removeNotification(notification.id);
|
|
43
|
+
}, 30000);
|
|
44
|
+
|
|
45
|
+
return () => {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}, [notification.isRead, notification.id, removeNotification]);
|
|
50
|
+
|
|
51
|
+
let statusIcon;
|
|
52
|
+
switch (notification.status) {
|
|
53
|
+
case 'ongoing':
|
|
54
|
+
statusIcon = <FaSpinner className="spinner" />;
|
|
55
|
+
break;
|
|
56
|
+
case 'completed':
|
|
57
|
+
statusIcon = <FaCheck />;
|
|
58
|
+
break;
|
|
59
|
+
case 'error':
|
|
60
|
+
statusIcon = <FaExclamationTriangle />;
|
|
61
|
+
break;
|
|
62
|
+
default:
|
|
63
|
+
statusIcon = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const removeBtn = notification.isRead ? <button onClick={() => {
|
|
67
|
+
removeNotification(notification.id);
|
|
68
|
+
}}><FaTrash /></button> : <></>
|
|
69
|
+
return (
|
|
70
|
+
<div ref={notificationRef} onClick={onClick} className={`notification ${notification.isExpanded ? 'expanded' : 'collapsed'} ${notification.status}`}>
|
|
71
|
+
<div className="notification-header">
|
|
72
|
+
{notification.icon && <span className="notification-icon">{notification.icon}</span>}
|
|
73
|
+
<span className="notification-title">{notification.title}</span>
|
|
74
|
+
{statusIcon && <span className="notification-status">{statusIcon}</span>}
|
|
75
|
+
{removeBtn}
|
|
76
|
+
</div>
|
|
77
|
+
{<div className="notification-content">
|
|
78
|
+
<p className="notification-message">{notification.message}</p>
|
|
79
|
+
</div>}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const NotificationList = forwardRef((props, ref) => {
|
|
85
|
+
const { notifications, markAsRead, removeNotification, addNotification, unreadCount } = useNotificationContext();
|
|
86
|
+
const [showBubble, setShowBubble] = useState(false);
|
|
87
|
+
|
|
88
|
+
const toggleBubble = useCallback(() => {
|
|
89
|
+
// Lorsque l'on ouvre la bulle, on marque toutes les notifications comme lues.
|
|
90
|
+
if (!showBubble) {
|
|
91
|
+
notifications.forEach(n => {
|
|
92
|
+
if (!n.isRead) {
|
|
93
|
+
markAsRead(n.id);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
// Ensuite, on bascule la visibilité du contenu de la bulle.
|
|
98
|
+
setShowBubble(prev => !prev);
|
|
99
|
+
}, [showBubble, notifications, markAsRead]);
|
|
100
|
+
|
|
101
|
+
const readNotifications = notifications.filter(n => n.isRead) || [];
|
|
102
|
+
const unreadNotifications = notifications.filter(n => !n.isRead) || [];
|
|
103
|
+
return (
|
|
104
|
+
<><div className="notification-container">
|
|
105
|
+
<div className="notifications-right">
|
|
106
|
+
{unreadNotifications.map(notification => (
|
|
107
|
+
<Notification
|
|
108
|
+
onClick={() => {
|
|
109
|
+
markAsRead(notification.id)
|
|
110
|
+
}}
|
|
111
|
+
key={notification.id}
|
|
112
|
+
notification={notification}
|
|
113
|
+
/>
|
|
114
|
+
))}
|
|
115
|
+
{unreadCount > 0 && <div className={"notification-bubble-count"}>{unreadCount}</div>}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{unreadCount > 0 && <button onClick={toggleBubble} className={`notification-bubble fab ${showBubble ? 'visible' : ''}`}>
|
|
120
|
+
<div>
|
|
121
|
+
<FaBell/>
|
|
122
|
+
{unreadCount > 0 && <span className="notification-bubble-count">{unreadCount}</span>}
|
|
123
|
+
</div>
|
|
124
|
+
{showBubble && <div className='notifications-bubble-content'>
|
|
125
|
+
{readNotifications.map(notification => (
|
|
126
|
+
<Notification
|
|
127
|
+
key={notification.id}
|
|
128
|
+
notification={notification}
|
|
129
|
+
/>
|
|
130
|
+
))}
|
|
131
|
+
</div>}
|
|
132
|
+
</button>}
|
|
133
|
+
</>
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
NotificationList.displayName = "NotificationList";
|
|
137
137
|
export {NotificationList, generateNotificationId}; // Export for use elsewherex
|
|
@@ -74,24 +74,6 @@
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
.notification-bubble {
|
|
78
|
-
position: fixed;
|
|
79
|
-
bottom: 20px;
|
|
80
|
-
right: 20px;
|
|
81
|
-
background-color: #007bff;
|
|
82
|
-
color: #fff;
|
|
83
|
-
border-radius: 50%;
|
|
84
|
-
width: 50px;
|
|
85
|
-
height: 50px;
|
|
86
|
-
justify-content: center;
|
|
87
|
-
align-items: center;
|
|
88
|
-
cursor: pointer;
|
|
89
|
-
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
|
90
|
-
z-index: 1000;
|
|
91
|
-
transition: all 0.3s ease;
|
|
92
|
-
pointer-events: all;
|
|
93
|
-
display: none;
|
|
94
|
-
}
|
|
95
77
|
|
|
96
78
|
.notification-bubble.visible {
|
|
97
79
|
display: flex;
|
|
@@ -14,9 +14,11 @@ export const Pagination = ({
|
|
|
14
14
|
page,
|
|
15
15
|
setPage,
|
|
16
16
|
useParam = false,
|
|
17
|
-
showElementsPerPage=false
|
|
17
|
+
showElementsPerPage=false,
|
|
18
|
+
elementsPerPage: propElementsPerPage
|
|
18
19
|
}) => {
|
|
19
|
-
const { elementsPerPage, setElementsPerPage } = useModelContext();
|
|
20
|
+
const { elementsPerPage: contextElementsPerPage, setElementsPerPage } = useModelContext();
|
|
21
|
+
const elementsPerPage = propElementsPerPage || contextElementsPerPage;
|
|
20
22
|
let pageCount = Math.ceil(totalCount / elementsPerPage);
|
|
21
23
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
22
24
|
|
|
@@ -109,7 +111,7 @@ export const Pagination = ({
|
|
|
109
111
|
(_, i) => page + i - parseInt(visibleItemsCount / 2, 10),
|
|
110
112
|
).map((p, i) => {
|
|
111
113
|
return p >= 1 && p <= pageCount ? (
|
|
112
|
-
<Button disabled={p === page} onClick={() => handleChange(p)}>
|
|
114
|
+
<Button key={"paginate-"+i} disabled={p === page} onClick={() => handleChange(p)}>
|
|
113
115
|
{p}
|
|
114
116
|
</Button>
|
|
115
117
|
) : (
|