@wger-project/react-components 25.12.5 → 26.2.26
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/build/assets/ajax-loader.gif +0 -0
- package/build/assets/index.css +1 -1
- package/build/assets/slick.svg +14 -0
- package/build/locales/ar/translation.json +347 -236
- package/build/locales/cs/translation.json +257 -253
- package/build/locales/de/translation.json +13 -3
- package/build/locales/en/translation.json +4 -1
- package/build/locales/es/translation.json +26 -4
- package/build/locales/fr/translation.json +10 -1
- package/build/locales/hi/translation.json +4 -1
- package/build/locales/hr/translation.json +15 -3
- package/build/locales/mk/translation.json +138 -0
- package/build/locales/nl/translation.json +118 -11
- package/build/locales/pt/translation.json +338 -338
- package/build/locales/pt_BR/translation.json +15 -3
- package/build/locales/ru/translation.json +43 -3
- package/build/locales/ta/translation.json +1 -1
- package/build/locales/uk/translation.json +13 -1
- package/build/locales/zh_Hans/translation.json +25 -1
- package/build/locales/zh_Hant/translation.json +255 -243
- package/build/main.js +170 -170
- package/build/main.js.map +1 -1
- package/package.json +15 -13
- package/src/components/BodyWeight/TableDashboard/TableDashboard.tsx +4 -6
- package/src/components/Calendar/Components/CalendarComponent.test.tsx +18 -22
- package/src/components/Calendar/Components/CalendarComponent.tsx +11 -8
- package/src/components/Calendar/Components/CalendarHeader.tsx +3 -3
- package/src/components/Calendar/Components/Entries.tsx +8 -3
- package/src/components/Dashboard/CalendarCard.tsx +16 -0
- package/src/components/Dashboard/ConfigurableDashboard.test.ts +129 -0
- package/src/components/Dashboard/ConfigurableDashboard.tsx +352 -89
- package/src/components/Dashboard/DashboardCard.tsx +3 -2
- package/src/components/Dashboard/MeasurementCard.test.tsx +75 -0
- package/src/components/Dashboard/MeasurementCard.tsx +101 -0
- package/src/components/Dashboard/RoutineCard.tsx +1 -1
- package/src/components/Dashboard/TrophiesCard.test.tsx +63 -0
- package/src/components/Dashboard/TrophiesCard.tsx +84 -0
- package/src/components/Dashboard/WeightCard.test.tsx +0 -10
- package/src/components/Measurements/Screens/MeasurementCategoryOverview.tsx +1 -1
- package/src/components/Measurements/models/Category.ts +13 -2
- package/src/components/Measurements/models/Entry.ts +13 -2
- package/src/components/Trophies/components/TrophiesDetail.test.tsx +34 -0
- package/src/components/Trophies/components/TrophiesDetail.tsx +88 -0
- package/src/components/Trophies/models/trophy.test.ts +33 -0
- package/src/components/Trophies/models/trophy.ts +75 -0
- package/src/components/Trophies/models/userTrophy.test.ts +38 -0
- package/src/components/Trophies/models/userTrophy.ts +67 -0
- package/src/components/Trophies/models/userTrophyProgression.test.ts +43 -0
- package/src/components/Trophies/models/userTrophyProgression.ts +68 -0
- package/src/components/Trophies/queries/trophies.ts +31 -0
- package/src/components/Trophies/services/trophies.ts +22 -0
- package/src/components/Trophies/services/userTrophies.ts +33 -0
- package/src/components/Trophies/services/userTrophyProgression.ts +16 -0
- package/src/components/WorkoutRoutines/Detail/RoutineDetail.tsx +1 -1
- package/src/components/WorkoutRoutines/Detail/TemplateDetail.tsx +1 -1
- package/src/components/WorkoutRoutines/models/Routine.test.ts +17 -0
- package/src/components/WorkoutRoutines/models/Routine.ts +20 -3
- package/src/components/WorkoutRoutines/widgets/RoutineDetailsCard.tsx +1 -1
- package/src/components/index.ts +0 -2
- package/src/pages/Calendar/index.tsx +2 -2
- package/src/pages/WeightOverview/index.tsx +1 -1
- package/src/routes.tsx +6 -1
- package/src/services/measurements.ts +10 -17
- package/src/tests/trophies/trophiesTestData.ts +80 -0
- package/src/utils/consts.ts +18 -3
- package/src/utils/url.test.ts +32 -1
- package/src/utils/url.ts +24 -3
- package/src/components/Carousel/carousel.module.css +0 -43
- package/src/components/Carousel/carousel.module.css.map +0 -1
- package/src/components/Carousel/carousel.module.scss +0 -46
- package/src/components/Carousel/index.tsx +0 -66
- package/src/components/Dashboard/GoalCard.tsx +0 -71
|
@@ -1,28 +1,41 @@
|
|
|
1
|
-
import { Box, Button, IconButton, Tooltip } from "@mui/material";
|
|
2
1
|
import DashboardCustomizeIcon from "@mui/icons-material/DashboardCustomize";
|
|
3
|
-
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
|
4
2
|
import DoneIcon from '@mui/icons-material/Done';
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import "
|
|
8
|
-
import "
|
|
3
|
+
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
|
4
|
+
import RuleIcon from '@mui/icons-material/Rule';
|
|
5
|
+
import { Box, Button, IconButton, ListItemText, Menu, MenuItem, Switch, Tooltip } from "@mui/material";
|
|
6
|
+
import { CalendarCard } from "components/Dashboard/CalendarCard";
|
|
7
|
+
import { MeasurementCard } from "components/Dashboard/MeasurementCard";
|
|
9
8
|
import { NutritionCard } from "components/Dashboard/NutritionCard";
|
|
10
9
|
import { RoutineCard } from "components/Dashboard/RoutineCard";
|
|
10
|
+
import { TrophiesCard } from "components/Dashboard/TrophiesCard";
|
|
11
11
|
import { WeightCard } from "components/Dashboard/WeightCard";
|
|
12
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
13
|
+
import { Layout, LayoutItem, Responsive, ResponsiveLayouts, useContainerWidth, } from "react-grid-layout";
|
|
14
|
+
import "react-grid-layout/css/styles.css";
|
|
15
|
+
import "react-resizable/css/styles.css";
|
|
12
16
|
import { useTranslation } from "react-i18next";
|
|
13
17
|
|
|
14
|
-
const
|
|
18
|
+
const DASHBOARD_STORAGE_KEY = "dashboard-state";
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
const VERSION = 1;
|
|
21
|
+
|
|
22
|
+
type DashboardState = {
|
|
23
|
+
version: number;
|
|
24
|
+
selectedWidgetIds: string[];
|
|
25
|
+
hiddenWidgetIds: string[];
|
|
26
|
+
layouts: ResponsiveLayouts | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const BREAKPOINTS = ['lg', 'md', 'sm', 'xs'] as const;
|
|
18
30
|
|
|
19
31
|
// Define widget types for extensibility
|
|
20
|
-
export type WidgetType = "routine" | "nutrition" | "weight";
|
|
32
|
+
export type WidgetType = "routine" | "nutrition" | "weight" | "calendar" | "measurement" | "trophies";
|
|
21
33
|
|
|
22
34
|
export interface WidgetConfig {
|
|
23
35
|
id: string;
|
|
24
36
|
type: WidgetType;
|
|
25
37
|
component: React.ComponentType;
|
|
38
|
+
translationKey: string; // key for i18n translations
|
|
26
39
|
defaultLayout: {
|
|
27
40
|
w: number;
|
|
28
41
|
h: number;
|
|
@@ -39,31 +52,190 @@ export const AVAILABLE_WIDGETS: WidgetConfig[] = [
|
|
|
39
52
|
id: "routine",
|
|
40
53
|
type: "routine",
|
|
41
54
|
component: RoutineCard,
|
|
55
|
+
translationKey: 'routines.routine',
|
|
42
56
|
defaultLayout: { w: 4, h: 5, x: 0, y: 0, minW: 3, minH: 2 },
|
|
43
57
|
},
|
|
44
58
|
{
|
|
45
59
|
id: "nutrition",
|
|
46
60
|
type: "nutrition",
|
|
47
61
|
component: NutritionCard,
|
|
62
|
+
translationKey: 'nutritionalPlan',
|
|
48
63
|
defaultLayout: { w: 4, h: 5, x: 4, y: 0, minW: 3, minH: 2 },
|
|
49
64
|
},
|
|
50
65
|
{
|
|
51
66
|
id: "weight",
|
|
52
67
|
type: "weight",
|
|
53
68
|
component: WeightCard,
|
|
69
|
+
translationKey: 'weight',
|
|
54
70
|
defaultLayout: { w: 4, h: 5, x: 8, y: 0, minW: 3, minH: 2 },
|
|
55
71
|
},
|
|
72
|
+
{
|
|
73
|
+
id: "calendar",
|
|
74
|
+
type: "calendar",
|
|
75
|
+
component: CalendarCard,
|
|
76
|
+
translationKey: 'calendar',
|
|
77
|
+
defaultLayout: { w: 8, h: 4, x: 0, y: 1, minW: 3, minH: 2 },
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "measurement",
|
|
81
|
+
type: "measurement",
|
|
82
|
+
component: MeasurementCard,
|
|
83
|
+
translationKey: 'measurements.measurements',
|
|
84
|
+
defaultLayout: { w: 4, h: 4, x: 8, y: 1, minW: 3, minH: 2 },
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "trophies",
|
|
88
|
+
type: "trophies",
|
|
89
|
+
component: TrophiesCard,
|
|
90
|
+
translationKey: 'trophies.trophies',
|
|
91
|
+
defaultLayout: { w: 12, h: 2, x: 0, y: 2, minW: 3, minH: 2 },
|
|
92
|
+
},
|
|
56
93
|
];
|
|
57
94
|
|
|
95
|
+
/*
|
|
96
|
+
* Load dashboard state from local storage, with migration support for older formats.
|
|
97
|
+
* Returns null if no saved state exists.
|
|
98
|
+
*/
|
|
99
|
+
export const loadDashboardState = (): DashboardState | null => {
|
|
100
|
+
try {
|
|
101
|
+
const raw = localStorage.getItem(DASHBOARD_STORAGE_KEY);
|
|
102
|
+
if (raw === null) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
107
|
+
const parsedAny = JSON.parse(raw) as any;
|
|
108
|
+
|
|
109
|
+
let out: DashboardState | null = null;
|
|
110
|
+
|
|
111
|
+
// If version is not present -> treat as old format and migrate
|
|
112
|
+
//TODO: Once some time has passed, this can be removed after some time, e.g. after 2026-01-31.
|
|
113
|
+
if (typeof parsedAny.version === 'undefined') {
|
|
114
|
+
out = migrateOldDashboardState(parsedAny);
|
|
115
|
+
saveDashboardState(out);
|
|
116
|
+
} else {
|
|
117
|
+
out = parsedAny as DashboardState;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!out) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
// Sanity checks
|
|
126
|
+
// -> remove unknown widget ids
|
|
127
|
+
const allowedWidgets = new Set(AVAILABLE_WIDGETS.map((w) => w.id));
|
|
128
|
+
|
|
129
|
+
out.selectedWidgetIds = Array.isArray(out.selectedWidgetIds)
|
|
130
|
+
? out.selectedWidgetIds.filter((id) => allowedWidgets.has(id))
|
|
131
|
+
: [];
|
|
132
|
+
|
|
133
|
+
out.hiddenWidgetIds = Array.isArray(out.hiddenWidgetIds)
|
|
134
|
+
? out.hiddenWidgetIds.filter((id) => allowedWidgets.has(id))
|
|
135
|
+
: [];
|
|
136
|
+
|
|
137
|
+
// -> If selected list is empty, default to all available, except those explicitly hidden
|
|
138
|
+
if (out.selectedWidgetIds.length === 0) {
|
|
139
|
+
out.selectedWidgetIds = AVAILABLE_WIDGETS.map((w) => w.id).filter((id) => !out.hiddenWidgetIds.includes(id));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// -> remove unknown ids from the layout
|
|
143
|
+
for (const bp of BREAKPOINTS) {
|
|
144
|
+
const arr = (out.layouts as ResponsiveLayouts)[bp] as Layout | undefined;
|
|
145
|
+
if (Array.isArray(arr)) {
|
|
146
|
+
(out.layouts as ResponsiveLayouts)[bp] = arr.filter(
|
|
147
|
+
(item: LayoutItem) => item && allowedWidgets.has(String(item.i))
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// -> if there are widgets in AVAILABLE_WIDGETS that are not in selected or hidden
|
|
153
|
+
// (e.g. because they have been added in the meantime), add them to selected
|
|
154
|
+
const known = new Set([...out.selectedWidgetIds, ...out.hiddenWidgetIds]);
|
|
155
|
+
const missing: string[] = AVAILABLE_WIDGETS.map((w) => w.id).filter((id) => !known.has(id));
|
|
156
|
+
if (missing.length > 0) {
|
|
157
|
+
out.selectedWidgetIds = [...out.selectedWidgetIds, ...missing];
|
|
158
|
+
saveDashboardState(out);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return out;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error('Error loading dashboard state', error);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const saveDashboardState = (state: DashboardState) => {
|
|
169
|
+
try {
|
|
170
|
+
const toSave: DashboardState = {
|
|
171
|
+
...state,
|
|
172
|
+
version: state.version ?? 1,
|
|
173
|
+
selectedWidgetIds: Array.isArray(state.selectedWidgetIds) ? [...state.selectedWidgetIds].slice().sort() : [],
|
|
174
|
+
hiddenWidgetIds: Array.isArray(state.hiddenWidgetIds) ? [...state.hiddenWidgetIds].slice().sort() : [],
|
|
175
|
+
};
|
|
176
|
+
localStorage.setItem(DASHBOARD_STORAGE_KEY, JSON.stringify(toSave));
|
|
177
|
+
} catch (error) {
|
|
178
|
+
console.error('Error saving dashboard state', error);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Migrate the old dashboard-state shape into the new DashboardState
|
|
183
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
184
|
+
const migrateOldDashboardState = (parsedAny: any): DashboardState => {
|
|
185
|
+
const parsed = parsedAny as DashboardState;
|
|
186
|
+
parsed.version = 1;
|
|
187
|
+
|
|
188
|
+
// Ensure hiddenWidgetIds exists in migrated state
|
|
189
|
+
if (!Array.isArray(parsed.hiddenWidgetIds)) parsed.hiddenWidgetIds = [];
|
|
190
|
+
|
|
191
|
+
// If selectedWidgetIds missing, try to extract from layouts (or top-level breakpoints)
|
|
192
|
+
if (!Array.isArray(parsed.selectedWidgetIds)) {
|
|
193
|
+
const ids = new Set<string>();
|
|
194
|
+
let layoutsSource: ResponsiveLayouts | undefined;
|
|
195
|
+
if (parsed && parsed.layouts) {
|
|
196
|
+
layoutsSource = parsed.layouts as ResponsiveLayouts;
|
|
197
|
+
} else if (parsedAny && (parsedAny.lg || parsedAny.md || parsedAny.sm || parsedAny.xs)) {
|
|
198
|
+
layoutsSource = {
|
|
199
|
+
lg: parsedAny.lg ?? [],
|
|
200
|
+
md: parsedAny.md ?? [],
|
|
201
|
+
sm: parsedAny.sm ?? [],
|
|
202
|
+
xs: parsedAny.xs ?? [],
|
|
203
|
+
} as ResponsiveLayouts;
|
|
204
|
+
parsed.layouts = layoutsSource;
|
|
205
|
+
|
|
206
|
+
delete parsedAny.lg;
|
|
207
|
+
delete parsedAny.md;
|
|
208
|
+
delete parsedAny.sm;
|
|
209
|
+
delete parsedAny.xs;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (layoutsSource) {
|
|
213
|
+
for (const bp of BREAKPOINTS) {
|
|
214
|
+
const arr = layoutsSource[bp] as Layout | undefined;
|
|
215
|
+
if (Array.isArray(arr)) arr.forEach((item: LayoutItem) => {
|
|
216
|
+
if (item && item.i) ids.add(String(item.i));
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (ids.size > 0) parsed.selectedWidgetIds = Array.from(ids);
|
|
222
|
+
else parsed.selectedWidgetIds = AVAILABLE_WIDGETS.map((w) => w.id);
|
|
223
|
+
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return parsed;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
|
|
58
230
|
// Generate default layouts for all breakpoints
|
|
59
|
-
const generateDefaultLayouts = ():
|
|
60
|
-
const lg: Layout
|
|
231
|
+
const generateDefaultLayouts = (widgets: WidgetConfig[] = AVAILABLE_WIDGETS): ResponsiveLayouts => {
|
|
232
|
+
const lg: Layout = widgets.map((widget) => ({
|
|
61
233
|
i: widget.id,
|
|
62
234
|
...widget.defaultLayout,
|
|
63
235
|
}));
|
|
64
236
|
|
|
65
237
|
// For medium screens, make widgets full width in pairs
|
|
66
|
-
const md: Layout
|
|
238
|
+
const md: Layout = widgets.map((widget, index) => ({
|
|
67
239
|
i: widget.id,
|
|
68
240
|
w: 6,
|
|
69
241
|
h: widget.defaultLayout.h,
|
|
@@ -74,7 +246,7 @@ const generateDefaultLayouts = (): Layouts => {
|
|
|
74
246
|
}));
|
|
75
247
|
|
|
76
248
|
// For small screens, stack vertically
|
|
77
|
-
const sm: Layout
|
|
249
|
+
const sm: Layout = widgets.map((widget, index) => ({
|
|
78
250
|
i: widget.id,
|
|
79
251
|
w: 12,
|
|
80
252
|
h: widget.defaultLayout.h,
|
|
@@ -87,63 +259,129 @@ const generateDefaultLayouts = (): Layouts => {
|
|
|
87
259
|
return { lg, md, sm, xs: sm };
|
|
88
260
|
};
|
|
89
261
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return saved ? JSON.parse(saved) : null;
|
|
95
|
-
} catch (error) {
|
|
96
|
-
console.error("Error loading dashboard layout:", error);
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
};
|
|
262
|
+
interface ConfigurableDashboardProps {
|
|
263
|
+
/** Optional list of widget IDs to show (defaults to all AVAILABLE_WIDGETS) */
|
|
264
|
+
enabledWidgetIds?: string[];
|
|
265
|
+
}
|
|
100
266
|
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
};
|
|
267
|
+
export const ConfigurableDashboard: React.FC<ConfigurableDashboardProps> = ({ enabledWidgetIds }) => {
|
|
268
|
+
const [tRaw] = useTranslation();
|
|
269
|
+
// Cast t to a looser signature so we can call dynamic keys like `dashboard.widgets` without TS errors
|
|
270
|
+
const t = tRaw as unknown as (key: string) => string;
|
|
271
|
+
|
|
272
|
+
const { width, containerRef, mounted } = useContainerWidth();
|
|
109
273
|
|
|
110
|
-
export const ConfigurableDashboard: React.FC = () => {
|
|
111
|
-
const [t] = useTranslation();
|
|
112
274
|
const [isEditMode, setIsEditMode] = useState(false);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
275
|
+
|
|
276
|
+
// Selected widgets (internal state) - initialize from prop or persisted single-state
|
|
277
|
+
const [selectedWidgetIds, setSelectedWidgetIds] = useState<string[]>(() => {
|
|
278
|
+
if (enabledWidgetIds && enabledWidgetIds.length > 0) return enabledWidgetIds;
|
|
279
|
+
const saved = loadDashboardState();
|
|
280
|
+
if (saved && Array.isArray(saved.selectedWidgetIds)) {
|
|
281
|
+
return saved.selectedWidgetIds;
|
|
282
|
+
}
|
|
283
|
+
return AVAILABLE_WIDGETS.map((w) => w.id);
|
|
116
284
|
});
|
|
117
285
|
|
|
118
|
-
|
|
286
|
+
// Hidden widgets (persisted)
|
|
287
|
+
const [hiddenWidgetIds, setHiddenWidgetIds] = useState<string[]>(() => {
|
|
288
|
+
const saved = loadDashboardState();
|
|
289
|
+
if (saved && Array.isArray(saved.hiddenWidgetIds)) return saved.hiddenWidgetIds;
|
|
290
|
+
return [];
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Menu anchor for widget selection
|
|
294
|
+
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
|
295
|
+
|
|
296
|
+
const visibleWidgets = useMemo(() => {
|
|
297
|
+
return AVAILABLE_WIDGETS.filter((w) => selectedWidgetIds.includes(w.id));
|
|
298
|
+
}, [selectedWidgetIds]);
|
|
299
|
+
|
|
300
|
+
const [layouts, setLayouts] = useState<ResponsiveLayouts>(() => {
|
|
301
|
+
const saved = loadDashboardState();
|
|
302
|
+
return (saved && saved.layouts) || generateDefaultLayouts(visibleWidgets);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// when selected widgets change, either re-use the saved layouts (if the saved selection matches)
|
|
306
|
+
// or generate defaults for the new visible set
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
const saved = loadDashboardState();
|
|
309
|
+
if (saved && Array.isArray(saved.selectedWidgetIds) && saved.layouts) {
|
|
310
|
+
// compare as sets (order-insensitive)
|
|
311
|
+
const sameLength = saved.selectedWidgetIds.length === selectedWidgetIds.length;
|
|
312
|
+
const sameMembers = saved.selectedWidgetIds.every((id) => selectedWidgetIds.includes(id));
|
|
313
|
+
if (sameLength && sameMembers) {
|
|
314
|
+
setLayouts(saved.layouts);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
setLayouts(generateDefaultLayouts(visibleWidgets));
|
|
319
|
+
}, [selectedWidgetIds, visibleWidgets]);
|
|
320
|
+
|
|
321
|
+
const handleLayoutChange = useCallback((_: Layout, allLayouts: ResponsiveLayouts) => {
|
|
119
322
|
setLayouts(allLayouts);
|
|
120
|
-
saveLayouts(allLayouts);
|
|
121
323
|
}, []);
|
|
122
324
|
|
|
325
|
+
// Reset to defaults for all available widgets and make all widgets visible
|
|
123
326
|
const handleResetLayout = useCallback(() => {
|
|
124
|
-
const
|
|
327
|
+
const allWidgetIds = AVAILABLE_WIDGETS.map((w) => w.id);
|
|
328
|
+
setSelectedWidgetIds(allWidgetIds);
|
|
329
|
+
setHiddenWidgetIds([]);
|
|
330
|
+
|
|
331
|
+
const defaultLayouts = generateDefaultLayouts(AVAILABLE_WIDGETS);
|
|
125
332
|
setLayouts(defaultLayouts);
|
|
126
|
-
|
|
333
|
+
saveDashboardState({
|
|
334
|
+
selectedWidgetIds: allWidgetIds,
|
|
335
|
+
hiddenWidgetIds: [],
|
|
336
|
+
layouts: defaultLayouts,
|
|
337
|
+
version: VERSION
|
|
338
|
+
});
|
|
127
339
|
}, []);
|
|
128
340
|
|
|
129
341
|
const toggleEditMode = useCallback(() => {
|
|
130
342
|
setIsEditMode((prev) => !prev);
|
|
131
343
|
}, []);
|
|
132
344
|
|
|
345
|
+
// Widget menu handlers
|
|
346
|
+
const openWidgetMenu = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget);
|
|
347
|
+
const closeWidgetMenu = () => setAnchorEl(null);
|
|
348
|
+
|
|
349
|
+
const toggleWidget = (id: string) => {
|
|
350
|
+
// If currently selected -> remove from selected and add to hidden
|
|
351
|
+
if (selectedWidgetIds.includes(id)) {
|
|
352
|
+
setSelectedWidgetIds((prev) => prev.filter((p) => p !== id));
|
|
353
|
+
setHiddenWidgetIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
|
354
|
+
} else {
|
|
355
|
+
// If currently hidden or not present -> add to selected and remove from hidden
|
|
356
|
+
setSelectedWidgetIds((prev) => [...prev, id]);
|
|
357
|
+
setHiddenWidgetIds((prev) => prev.filter((h) => h !== id));
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
useEffect(() => {
|
|
362
|
+
saveDashboardState({ selectedWidgetIds, hiddenWidgetIds, layouts, version: VERSION });
|
|
363
|
+
}, [selectedWidgetIds, hiddenWidgetIds, layouts]);
|
|
364
|
+
|
|
133
365
|
// Grid configuration
|
|
134
|
-
const
|
|
366
|
+
const gridProps = useMemo(
|
|
135
367
|
() => ({
|
|
136
368
|
className: "layout",
|
|
137
369
|
layouts: layouts,
|
|
138
370
|
breakpoints: { lg: 1200, md: 996, sm: 768, xs: 480 },
|
|
139
371
|
cols: { lg: 12, md: 12, sm: 12, xs: 12 },
|
|
140
|
-
rowHeight: 100,
|
|
141
|
-
isDraggable: isEditMode,
|
|
142
|
-
isResizable: isEditMode,
|
|
143
372
|
onLayoutChange: handleLayoutChange,
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
373
|
+
dragConfig: {
|
|
374
|
+
enabled: isEditMode,
|
|
375
|
+
handle: isEditMode ? undefined : ".no-drag",
|
|
376
|
+
},
|
|
377
|
+
resizeConfig: {
|
|
378
|
+
enabled: isEditMode,
|
|
379
|
+
},
|
|
380
|
+
gridConfig: {
|
|
381
|
+
rowHeight: 100,
|
|
382
|
+
margin: [16, 16],
|
|
383
|
+
containerPadding: [0, 0],
|
|
384
|
+
},
|
|
147
385
|
}),
|
|
148
386
|
[layouts, isEditMode, handleLayoutChange]
|
|
149
387
|
);
|
|
@@ -160,32 +398,53 @@ export const ConfigurableDashboard: React.FC = () => {
|
|
|
160
398
|
}}
|
|
161
399
|
>
|
|
162
400
|
{isEditMode && (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
{isEditMode && (
|
|
174
|
-
<Tooltip title={t('dashboard.resetLayout')}>
|
|
401
|
+
<>
|
|
402
|
+
<Box
|
|
403
|
+
sx={{
|
|
404
|
+
p: 1,
|
|
405
|
+
borderRadius: 1,
|
|
406
|
+
fontSize: "0.875rem",
|
|
407
|
+
}}
|
|
408
|
+
>
|
|
409
|
+
{t('dashboard.dragWidgetsHelp')}
|
|
410
|
+
</Box>
|
|
175
411
|
<IconButton
|
|
176
|
-
onClick={handleResetLayout}
|
|
177
412
|
size="small"
|
|
413
|
+
onClick={openWidgetMenu}
|
|
178
414
|
sx={{
|
|
179
415
|
color: "primary.main",
|
|
180
416
|
borderRadius: 1,
|
|
181
417
|
fontSize: "0.875rem",
|
|
182
418
|
}}
|
|
183
419
|
>
|
|
184
|
-
<
|
|
420
|
+
<RuleIcon />
|
|
185
421
|
</IconButton>
|
|
186
|
-
|
|
422
|
+
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={closeWidgetMenu}>
|
|
423
|
+
{AVAILABLE_WIDGETS.map((widget) => (
|
|
424
|
+
<MenuItem key={widget.id} onClick={() => toggleWidget(widget.id)}>
|
|
425
|
+
<Switch checked={selectedWidgetIds.includes(widget.id)} />
|
|
426
|
+
<ListItemText primary={t(widget.translationKey)} />
|
|
427
|
+
</MenuItem>
|
|
428
|
+
))}
|
|
429
|
+
</Menu>
|
|
430
|
+
<Tooltip title={t('dashboard.resetLayout')}>
|
|
431
|
+
<IconButton
|
|
432
|
+
onClick={handleResetLayout}
|
|
433
|
+
size="small"
|
|
434
|
+
sx={{
|
|
435
|
+
color: "primary.main",
|
|
436
|
+
borderRadius: 1,
|
|
437
|
+
fontSize: "0.875rem",
|
|
438
|
+
}}
|
|
439
|
+
>
|
|
440
|
+
<RestartAltIcon />
|
|
441
|
+
</IconButton>
|
|
442
|
+
</Tooltip>
|
|
443
|
+
</>
|
|
187
444
|
)}
|
|
188
|
-
|
|
445
|
+
|
|
446
|
+
<Tooltip
|
|
447
|
+
title={isEditMode ? t('core.exitEditMode') : t('dashboard.customizeDashboard')}>
|
|
189
448
|
<Button
|
|
190
449
|
variant={isEditMode ? "contained" : "outlined"}
|
|
191
450
|
startIcon={isEditMode ? <DoneIcon /> : <DashboardCustomizeIcon />}
|
|
@@ -198,31 +457,35 @@ export const ConfigurableDashboard: React.FC = () => {
|
|
|
198
457
|
</Tooltip>
|
|
199
458
|
</Box>
|
|
200
459
|
|
|
201
|
-
<
|
|
202
|
-
{
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
460
|
+
<Box ref={containerRef}>
|
|
461
|
+
{mounted && (
|
|
462
|
+
<Responsive {...gridProps} width={width}>
|
|
463
|
+
{visibleWidgets.map((widget) => {
|
|
464
|
+
const WidgetComponent = widget.component;
|
|
465
|
+
return (
|
|
466
|
+
<Box
|
|
467
|
+
key={widget.id}
|
|
468
|
+
sx={{
|
|
469
|
+
// Add visual feedback in edit mode
|
|
470
|
+
border: isEditMode ? "1px dashed" : "none",
|
|
471
|
+
borderColor: "primary.main",
|
|
472
|
+
borderRadius: 1,
|
|
473
|
+
transition: "border 0.2s",
|
|
474
|
+
cursor: isEditMode ? "move" : "default",
|
|
475
|
+
"&:hover": isEditMode
|
|
476
|
+
? {
|
|
477
|
+
borderColor: "primary.dark",
|
|
478
|
+
}
|
|
479
|
+
: {},
|
|
480
|
+
}}
|
|
481
|
+
>
|
|
482
|
+
<WidgetComponent />
|
|
483
|
+
</Box>
|
|
484
|
+
);
|
|
485
|
+
})}
|
|
486
|
+
</Responsive>
|
|
487
|
+
)}
|
|
488
|
+
</Box>
|
|
226
489
|
</Box>
|
|
227
490
|
);
|
|
228
491
|
};
|
|
@@ -72,7 +72,8 @@ export interface DashboardCardProps {
|
|
|
72
72
|
* </DashboardCard>
|
|
73
73
|
* ```
|
|
74
74
|
*/
|
|
75
|
-
export const DashboardCard: React.FC<DashboardCardProps> = (
|
|
75
|
+
export const DashboardCard: React.FC<DashboardCardProps> = (
|
|
76
|
+
{
|
|
76
77
|
title,
|
|
77
78
|
subheader,
|
|
78
79
|
children,
|
|
@@ -92,7 +93,7 @@ export const DashboardCard: React.FC<DashboardCardProps> = ({
|
|
|
92
93
|
...cardSx,
|
|
93
94
|
}}
|
|
94
95
|
>
|
|
95
|
-
<CardHeader title={title} subheader={subheader} action={headerAction} />
|
|
96
|
+
{title !== '' && <CardHeader title={title} subheader={subheader} action={headerAction} />}
|
|
96
97
|
|
|
97
98
|
<CardContent
|
|
98
99
|
sx={{
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { MeasurementCard } from "components/Dashboard/MeasurementCard";
|
|
4
|
+
import { useMeasurementsCategoryQuery } from "components/Measurements/queries";
|
|
5
|
+
import { TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2 } from "tests/measurementsTestData";
|
|
6
|
+
|
|
7
|
+
jest.mock("components/Measurements/queries");
|
|
8
|
+
jest.useFakeTimers();
|
|
9
|
+
|
|
10
|
+
const queryClient = new QueryClient();
|
|
11
|
+
|
|
12
|
+
describe("smoke test the MeasurementCard component", () => {
|
|
13
|
+
|
|
14
|
+
describe("Measurements available", () => {
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
(useMeasurementsCategoryQuery as jest.Mock).mockImplementation(() => ({
|
|
18
|
+
isSuccess: true,
|
|
19
|
+
isLoading: false,
|
|
20
|
+
data: [
|
|
21
|
+
TEST_MEASUREMENT_CATEGORY_1,
|
|
22
|
+
TEST_MEASUREMENT_CATEGORY_2
|
|
23
|
+
]
|
|
24
|
+
}));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('renders the current categories correctly', async () => {
|
|
28
|
+
|
|
29
|
+
// Act
|
|
30
|
+
render(
|
|
31
|
+
<QueryClientProvider client={queryClient}>
|
|
32
|
+
<MeasurementCard />
|
|
33
|
+
</QueryClientProvider>
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
// Assert
|
|
37
|
+
expect(useMeasurementsCategoryQuery).toHaveBeenCalled();
|
|
38
|
+
expect(screen.getAllByText('Biceps').length).toBeGreaterThan(0);
|
|
39
|
+
expect(screen.getAllByText('11 %').length).toBeGreaterThan(0);
|
|
40
|
+
expect(screen.getAllByText('22 %').length).toBeGreaterThan(0);
|
|
41
|
+
expect(screen.getAllByText('33 %').length).toBeGreaterThan(0);
|
|
42
|
+
expect(screen.getAllByText('44 %').length).toBeGreaterThan(0);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
describe("No data available", () => {
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
(useMeasurementsCategoryQuery as jest.Mock).mockImplementation(() => ({
|
|
51
|
+
isSuccess: true,
|
|
52
|
+
isLoading: false,
|
|
53
|
+
data: null
|
|
54
|
+
}));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('renders the overview correctly', async () => {
|
|
58
|
+
|
|
59
|
+
// Act
|
|
60
|
+
render(
|
|
61
|
+
<QueryClientProvider client={queryClient}>
|
|
62
|
+
<MeasurementCard />
|
|
63
|
+
</QueryClientProvider>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Assert
|
|
67
|
+
expect(useMeasurementsCategoryQuery).toHaveBeenCalled();
|
|
68
|
+
expect(screen.getByText('nothingHereYet')).toBeInTheDocument();
|
|
69
|
+
expect(screen.getByText('nothingHereYetAction')).toBeInTheDocument();
|
|
70
|
+
expect(screen.getByText('add')).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
|