@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.
Files changed (72) hide show
  1. package/build/assets/ajax-loader.gif +0 -0
  2. package/build/assets/index.css +1 -1
  3. package/build/assets/slick.svg +14 -0
  4. package/build/locales/ar/translation.json +347 -236
  5. package/build/locales/cs/translation.json +257 -253
  6. package/build/locales/de/translation.json +13 -3
  7. package/build/locales/en/translation.json +4 -1
  8. package/build/locales/es/translation.json +26 -4
  9. package/build/locales/fr/translation.json +10 -1
  10. package/build/locales/hi/translation.json +4 -1
  11. package/build/locales/hr/translation.json +15 -3
  12. package/build/locales/mk/translation.json +138 -0
  13. package/build/locales/nl/translation.json +118 -11
  14. package/build/locales/pt/translation.json +338 -338
  15. package/build/locales/pt_BR/translation.json +15 -3
  16. package/build/locales/ru/translation.json +43 -3
  17. package/build/locales/ta/translation.json +1 -1
  18. package/build/locales/uk/translation.json +13 -1
  19. package/build/locales/zh_Hans/translation.json +25 -1
  20. package/build/locales/zh_Hant/translation.json +255 -243
  21. package/build/main.js +170 -170
  22. package/build/main.js.map +1 -1
  23. package/package.json +15 -13
  24. package/src/components/BodyWeight/TableDashboard/TableDashboard.tsx +4 -6
  25. package/src/components/Calendar/Components/CalendarComponent.test.tsx +18 -22
  26. package/src/components/Calendar/Components/CalendarComponent.tsx +11 -8
  27. package/src/components/Calendar/Components/CalendarHeader.tsx +3 -3
  28. package/src/components/Calendar/Components/Entries.tsx +8 -3
  29. package/src/components/Dashboard/CalendarCard.tsx +16 -0
  30. package/src/components/Dashboard/ConfigurableDashboard.test.ts +129 -0
  31. package/src/components/Dashboard/ConfigurableDashboard.tsx +352 -89
  32. package/src/components/Dashboard/DashboardCard.tsx +3 -2
  33. package/src/components/Dashboard/MeasurementCard.test.tsx +75 -0
  34. package/src/components/Dashboard/MeasurementCard.tsx +101 -0
  35. package/src/components/Dashboard/RoutineCard.tsx +1 -1
  36. package/src/components/Dashboard/TrophiesCard.test.tsx +63 -0
  37. package/src/components/Dashboard/TrophiesCard.tsx +84 -0
  38. package/src/components/Dashboard/WeightCard.test.tsx +0 -10
  39. package/src/components/Measurements/Screens/MeasurementCategoryOverview.tsx +1 -1
  40. package/src/components/Measurements/models/Category.ts +13 -2
  41. package/src/components/Measurements/models/Entry.ts +13 -2
  42. package/src/components/Trophies/components/TrophiesDetail.test.tsx +34 -0
  43. package/src/components/Trophies/components/TrophiesDetail.tsx +88 -0
  44. package/src/components/Trophies/models/trophy.test.ts +33 -0
  45. package/src/components/Trophies/models/trophy.ts +75 -0
  46. package/src/components/Trophies/models/userTrophy.test.ts +38 -0
  47. package/src/components/Trophies/models/userTrophy.ts +67 -0
  48. package/src/components/Trophies/models/userTrophyProgression.test.ts +43 -0
  49. package/src/components/Trophies/models/userTrophyProgression.ts +68 -0
  50. package/src/components/Trophies/queries/trophies.ts +31 -0
  51. package/src/components/Trophies/services/trophies.ts +22 -0
  52. package/src/components/Trophies/services/userTrophies.ts +33 -0
  53. package/src/components/Trophies/services/userTrophyProgression.ts +16 -0
  54. package/src/components/WorkoutRoutines/Detail/RoutineDetail.tsx +1 -1
  55. package/src/components/WorkoutRoutines/Detail/TemplateDetail.tsx +1 -1
  56. package/src/components/WorkoutRoutines/models/Routine.test.ts +17 -0
  57. package/src/components/WorkoutRoutines/models/Routine.ts +20 -3
  58. package/src/components/WorkoutRoutines/widgets/RoutineDetailsCard.tsx +1 -1
  59. package/src/components/index.ts +0 -2
  60. package/src/pages/Calendar/index.tsx +2 -2
  61. package/src/pages/WeightOverview/index.tsx +1 -1
  62. package/src/routes.tsx +6 -1
  63. package/src/services/measurements.ts +10 -17
  64. package/src/tests/trophies/trophiesTestData.ts +80 -0
  65. package/src/utils/consts.ts +18 -3
  66. package/src/utils/url.test.ts +32 -1
  67. package/src/utils/url.ts +24 -3
  68. package/src/components/Carousel/carousel.module.css +0 -43
  69. package/src/components/Carousel/carousel.module.css.map +0 -1
  70. package/src/components/Carousel/carousel.module.scss +0 -46
  71. package/src/components/Carousel/index.tsx +0 -66
  72. 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 React, { useState, useCallback, useMemo } from "react";
6
- import { Responsive, WidthProvider, Layout, Layouts } from "react-grid-layout";
7
- import "react-grid-layout/css/styles.css";
8
- import "react-resizable/css/styles.css";
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 ResponsiveGridLayout = WidthProvider(Responsive);
18
+ const DASHBOARD_STORAGE_KEY = "dashboard-state";
15
19
 
16
- // Local storage key for persisting layouts
17
- const LAYOUT_STORAGE_KEY = "dashboard-layout";
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 = (): Layouts => {
60
- const lg: Layout[] = AVAILABLE_WIDGETS.map((widget) => ({
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[] = AVAILABLE_WIDGETS.map((widget, index) => ({
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[] = AVAILABLE_WIDGETS.map((widget, index) => ({
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
- // Load layouts from localStorage
91
- const loadLayouts = (): Layouts | null => {
92
- try {
93
- const saved = localStorage.getItem(LAYOUT_STORAGE_KEY);
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
- // Save layouts to localStorage
102
- const saveLayouts = (layouts: Layouts) => {
103
- try {
104
- localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(layouts));
105
- } catch (error) {
106
- console.error("Error saving dashboard layout:", error);
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
- const [layouts, setLayouts] = useState<Layouts>(() => {
114
- const savedLayouts = loadLayouts();
115
- return savedLayouts || generateDefaultLayouts();
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
- const handleLayoutChange = useCallback((_: Layout[], allLayouts: Layouts) => {
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 defaultLayouts = generateDefaultLayouts();
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
- saveLayouts(defaultLayouts);
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 gridConfig = useMemo(
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
- draggableHandle: isEditMode ? undefined : ".no-drag",
145
- margin: [16, 16] as [number, number],
146
- containerPadding: [0, 0] as [number, number],
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
- <Box
164
- sx={{
165
- p: 1,
166
- borderRadius: 1,
167
- fontSize: "0.875rem",
168
- }}
169
- >
170
- {t('dashboard.dragWidgetsHelp')}
171
- </Box>
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
- <RestartAltIcon />
420
+ <RuleIcon />
185
421
  </IconButton>
186
- </Tooltip>
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
- <Tooltip title={isEditMode ? t('core.exitEditMode') : t('dashboard.customizeDashboard')}>
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
- <ResponsiveGridLayout {...gridConfig}>
202
- {AVAILABLE_WIDGETS.map((widget) => {
203
- const WidgetComponent = widget.component;
204
- return (
205
- <Box
206
- key={widget.id}
207
- sx={{
208
- // Add visual feedback in edit mode
209
- border: isEditMode ? "1px dashed" : "none",
210
- borderColor: "primary.main",
211
- borderRadius: 1,
212
- transition: "border 0.2s",
213
- cursor: isEditMode ? "move" : "default",
214
- "&:hover": isEditMode
215
- ? {
216
- borderColor: "primary.dark",
217
- }
218
- : {},
219
- }}
220
- >
221
- <WidgetComponent />
222
- </Box>
223
- );
224
- })}
225
- </ResponsiveGridLayout>
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
+