@wger-project/react-components 25.11.22 → 26.1.18

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 (69) 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/de/translation.json +25 -6
  5. package/build/locales/en/translation.json +12 -0
  6. package/build/locales/es/translation.json +22 -3
  7. package/build/locales/fr/translation.json +10 -1
  8. package/build/locales/hi/translation.json +8 -0
  9. package/build/locales/nl/translation.json +350 -239
  10. package/build/locales/pt_BR/translation.json +349 -255
  11. package/build/locales/ru/translation.json +39 -3
  12. package/build/locales/uk/translation.json +10 -1
  13. package/build/main.js +170 -166
  14. package/build/main.js.map +1 -1
  15. package/package.json +8 -2
  16. package/src/components/BodyWeight/TableDashboard/TableDashboard.tsx +4 -6
  17. package/src/components/Calendar/Components/CalendarComponent.test.tsx +18 -22
  18. package/src/components/Calendar/Components/CalendarComponent.tsx +11 -8
  19. package/src/components/Calendar/Components/CalendarHeader.tsx +3 -3
  20. package/src/components/Calendar/Components/Entries.tsx +8 -3
  21. package/src/components/Dashboard/CalendarCard.tsx +16 -0
  22. package/src/components/Dashboard/ConfigurableDashboard.test.ts +128 -0
  23. package/src/components/Dashboard/ConfigurableDashboard.tsx +479 -0
  24. package/src/components/Dashboard/DashboardCard.tsx +122 -0
  25. package/src/components/Dashboard/EmptyCard.tsx +3 -3
  26. package/src/components/Dashboard/MeasurementCard.test.tsx +75 -0
  27. package/src/components/Dashboard/MeasurementCard.tsx +101 -0
  28. package/src/components/Dashboard/NutritionCard.tsx +88 -96
  29. package/src/components/Dashboard/RoutineCard.tsx +54 -69
  30. package/src/components/Dashboard/TrophiesCard.test.tsx +63 -0
  31. package/src/components/Dashboard/TrophiesCard.tsx +84 -0
  32. package/src/components/Dashboard/WeightCard.test.tsx +0 -10
  33. package/src/components/Dashboard/WeightCard.tsx +36 -42
  34. package/src/components/Exercises/Detail/Head/ExerciseDeleteDialog.tsx +1 -1
  35. package/src/components/Measurements/Screens/MeasurementCategoryOverview.tsx +1 -1
  36. package/src/components/Measurements/models/Category.ts +13 -2
  37. package/src/components/Measurements/models/Entry.ts +13 -2
  38. package/src/components/Trophies/components/TrophiesDetail.test.tsx +34 -0
  39. package/src/components/Trophies/components/TrophiesDetail.tsx +88 -0
  40. package/src/components/Trophies/models/trophy.test.ts +33 -0
  41. package/src/components/Trophies/models/trophy.ts +75 -0
  42. package/src/components/Trophies/models/userTrophy.test.ts +38 -0
  43. package/src/components/Trophies/models/userTrophy.ts +67 -0
  44. package/src/components/Trophies/models/userTrophyProgression.test.ts +43 -0
  45. package/src/components/Trophies/models/userTrophyProgression.ts +68 -0
  46. package/src/components/Trophies/queries/trophies.ts +31 -0
  47. package/src/components/Trophies/services/trophies.ts +22 -0
  48. package/src/components/Trophies/services/userTrophies.ts +33 -0
  49. package/src/components/Trophies/services/userTrophyProgression.ts +16 -0
  50. package/src/components/WorkoutRoutines/Detail/WorkoutStats.tsx +1 -1
  51. package/src/components/WorkoutRoutines/widgets/forms/DayTypeSelect.tsx +1 -2
  52. package/src/components/WorkoutRoutines/widgets/forms/SlotForm.tsx +0 -4
  53. package/src/components/index.ts +0 -2
  54. package/src/index.tsx +0 -46
  55. package/src/pages/Calendar/index.tsx +2 -2
  56. package/src/pages/WeightOverview/index.tsx +1 -1
  57. package/src/routes.tsx +87 -79
  58. package/src/services/exerciseTranslation.ts +5 -6
  59. package/src/services/measurements.ts +10 -17
  60. package/src/services/video.test.ts +4 -4
  61. package/src/tests/trophies/trophiesTestData.ts +80 -0
  62. package/src/utils/consts.ts +18 -3
  63. package/src/utils/url.test.ts +32 -1
  64. package/src/utils/url.ts +24 -3
  65. package/src/components/Carousel/carousel.module.css +0 -43
  66. package/src/components/Carousel/carousel.module.css.map +0 -1
  67. package/src/components/Carousel/carousel.module.scss +0 -46
  68. package/src/components/Carousel/index.tsx +0 -66
  69. package/src/components/Dashboard/Dashboard.tsx +0 -22
@@ -0,0 +1,479 @@
1
+ import DashboardCustomizeIcon from "@mui/icons-material/DashboardCustomize";
2
+ import DoneIcon from '@mui/icons-material/Done';
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";
8
+ import { NutritionCard } from "components/Dashboard/NutritionCard";
9
+ import { RoutineCard } from "components/Dashboard/RoutineCard";
10
+ import { TrophiesCard } from "components/Dashboard/TrophiesCard";
11
+ import { WeightCard } from "components/Dashboard/WeightCard";
12
+ import React, { useCallback, useEffect, useMemo, useState } from "react";
13
+ import { Layout, Layouts, Responsive, WidthProvider } from "react-grid-layout";
14
+ import "react-grid-layout/css/styles.css";
15
+ import "react-resizable/css/styles.css";
16
+ import { useTranslation } from "react-i18next";
17
+
18
+ const ResponsiveGridLayout = WidthProvider(Responsive);
19
+
20
+ const DASHBOARD_STORAGE_KEY = "dashboard-state";
21
+
22
+ const VERSION = 1;
23
+
24
+ type DashboardState = {
25
+ version: number;
26
+ selectedWidgetIds: string[];
27
+ hiddenWidgetIds: string[];
28
+ layouts: Layouts | null;
29
+ };
30
+
31
+ const BREAKPOINTS = ['lg', 'md', 'sm', 'xs'] as const;
32
+
33
+ // Define widget types for extensibility
34
+ export type WidgetType = "routine" | "nutrition" | "weight" | "calendar" | "measurement" | "trophies";
35
+
36
+ export interface WidgetConfig {
37
+ id: string;
38
+ type: WidgetType;
39
+ component: React.ComponentType;
40
+ translationKey: string; // key for i18n translations
41
+ defaultLayout: {
42
+ w: number;
43
+ h: number;
44
+ x: number;
45
+ y: number;
46
+ minW?: number;
47
+ minH?: number;
48
+ };
49
+ }
50
+
51
+ // Widget registry - easy to add new widgets in the future
52
+ export const AVAILABLE_WIDGETS: WidgetConfig[] = [
53
+ {
54
+ id: "routine",
55
+ type: "routine",
56
+ component: RoutineCard,
57
+ translationKey: 'routines.routine',
58
+ defaultLayout: { w: 4, h: 5, x: 0, y: 0, minW: 3, minH: 2 },
59
+ },
60
+ {
61
+ id: "nutrition",
62
+ type: "nutrition",
63
+ component: NutritionCard,
64
+ translationKey: 'nutritionalPlan',
65
+ defaultLayout: { w: 4, h: 5, x: 4, y: 0, minW: 3, minH: 2 },
66
+ },
67
+ {
68
+ id: "weight",
69
+ type: "weight",
70
+ component: WeightCard,
71
+ translationKey: 'weight',
72
+ defaultLayout: { w: 4, h: 5, x: 8, y: 0, minW: 3, minH: 2 },
73
+ },
74
+ {
75
+ id: "calendar",
76
+ type: "calendar",
77
+ component: CalendarCard,
78
+ translationKey: 'calendar',
79
+ defaultLayout: { w: 8, h: 4, x: 0, y: 1, minW: 3, minH: 2 },
80
+ },
81
+ {
82
+ id: "measurement",
83
+ type: "measurement",
84
+ component: MeasurementCard,
85
+ translationKey: 'measurements.measurements',
86
+ defaultLayout: { w: 4, h: 4, x: 8, y: 1, minW: 3, minH: 2 },
87
+ },
88
+ {
89
+ id: "trophies",
90
+ type: "trophies",
91
+ component: TrophiesCard,
92
+ translationKey: 'trophies.trophies',
93
+ defaultLayout: { w: 12, h: 2, x: 0, y: 2, minW: 3, minH: 2 },
94
+ },
95
+ ];
96
+
97
+ /*
98
+ * Load dashboard state from local storage, with migration support for older formats.
99
+ * Returns null if no saved state exists.
100
+ */
101
+ export const loadDashboardState = (): DashboardState | null => {
102
+ try {
103
+ const raw = localStorage.getItem(DASHBOARD_STORAGE_KEY);
104
+ if (raw === null) {
105
+ return null;
106
+ }
107
+
108
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
109
+ const parsedAny = JSON.parse(raw) as any;
110
+
111
+ let out: DashboardState | null = null;
112
+
113
+ // If version is not present -> treat as old format and migrate
114
+ //TODO: Once some time has passed, this can be removed after some time, e.g. after 2026-01-31.
115
+ if (typeof parsedAny.version === 'undefined') {
116
+ out = migrateOldDashboardState(parsedAny);
117
+ saveDashboardState(out);
118
+ } else {
119
+ out = parsedAny as DashboardState;
120
+ }
121
+
122
+ if (!out) {
123
+ return null;
124
+ }
125
+
126
+
127
+ // Sanity checks
128
+ // -> remove unknown widget ids
129
+ const allowedWidgets = new Set(AVAILABLE_WIDGETS.map((w) => w.id));
130
+
131
+ out.selectedWidgetIds = Array.isArray(out.selectedWidgetIds)
132
+ ? out.selectedWidgetIds.filter((id) => allowedWidgets.has(id))
133
+ : [];
134
+
135
+ out.hiddenWidgetIds = Array.isArray(out.hiddenWidgetIds)
136
+ ? out.hiddenWidgetIds.filter((id) => allowedWidgets.has(id))
137
+ : [];
138
+
139
+ // -> If selected list is empty, default to all available, except those explicitly hidden
140
+ if (out.selectedWidgetIds.length === 0) {
141
+ out.selectedWidgetIds = AVAILABLE_WIDGETS.map((w) => w.id).filter((id) => !out.hiddenWidgetIds.includes(id));
142
+ }
143
+
144
+ // -> remove unknown ids from the layout
145
+ for (const bp of BREAKPOINTS) {
146
+ const arr = (out.layouts as Layouts)[bp] as Layout[] | undefined;
147
+ if (Array.isArray(arr)) {
148
+ (out.layouts as Layouts)[bp] = arr.filter((item: Layout) => item && allowedWidgets.has(String(item.i)));
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: Layouts | undefined;
195
+ if (parsed && parsed.layouts) {
196
+ layoutsSource = parsed.layouts as Layouts;
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 Layouts;
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: Layout) => {
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
+
230
+ // Generate default layouts for all breakpoints
231
+ const generateDefaultLayouts = (widgets: WidgetConfig[] = AVAILABLE_WIDGETS): Layouts => {
232
+ const lg: Layout[] = widgets.map((widget) => ({
233
+ i: widget.id,
234
+ ...widget.defaultLayout,
235
+ }));
236
+
237
+ // For medium screens, make widgets full width in pairs
238
+ const md: Layout[] = widgets.map((widget, index) => ({
239
+ i: widget.id,
240
+ w: 6,
241
+ h: widget.defaultLayout.h,
242
+ x: (index % 2) * 6,
243
+ y: Math.floor(index / 2) * widget.defaultLayout.h,
244
+ minW: widget.defaultLayout.minW,
245
+ minH: widget.defaultLayout.minH,
246
+ }));
247
+
248
+ // For small screens, stack vertically
249
+ const sm: Layout[] = widgets.map((widget, index) => ({
250
+ i: widget.id,
251
+ w: 12,
252
+ h: widget.defaultLayout.h,
253
+ x: 0,
254
+ y: index * widget.defaultLayout.h,
255
+ minW: widget.defaultLayout.minW,
256
+ minH: widget.defaultLayout.minH,
257
+ }));
258
+
259
+ return { lg, md, sm, xs: sm };
260
+ };
261
+
262
+ interface ConfigurableDashboardProps {
263
+ /** Optional list of widget IDs to show (defaults to all AVAILABLE_WIDGETS) */
264
+ enabledWidgetIds?: string[];
265
+ }
266
+
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 [isEditMode, setIsEditMode] = useState(false);
273
+
274
+ // Selected widgets (internal state) - initialize from prop or persisted single-state
275
+ const [selectedWidgetIds, setSelectedWidgetIds] = useState<string[]>(() => {
276
+ if (enabledWidgetIds && enabledWidgetIds.length > 0) return enabledWidgetIds;
277
+ const saved = loadDashboardState();
278
+ if (saved && Array.isArray(saved.selectedWidgetIds)) {
279
+ return saved.selectedWidgetIds;
280
+ }
281
+ return AVAILABLE_WIDGETS.map((w) => w.id);
282
+ });
283
+
284
+ // Hidden widgets (persisted)
285
+ const [hiddenWidgetIds, setHiddenWidgetIds] = useState<string[]>(() => {
286
+ const saved = loadDashboardState();
287
+ if (saved && Array.isArray(saved.hiddenWidgetIds)) return saved.hiddenWidgetIds;
288
+ return [];
289
+ });
290
+
291
+ // Menu anchor for widget selection
292
+ const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
293
+
294
+ const visibleWidgets = useMemo(() => {
295
+ return AVAILABLE_WIDGETS.filter((w) => selectedWidgetIds.includes(w.id));
296
+ }, [selectedWidgetIds]);
297
+
298
+ const [layouts, setLayouts] = useState<Layouts>(() => {
299
+ const saved = loadDashboardState();
300
+ return (saved && saved.layouts) || generateDefaultLayouts(visibleWidgets);
301
+ });
302
+
303
+ // when selected widgets change, either re-use the saved layouts (if the saved selection matches)
304
+ // or generate defaults for the new visible set
305
+ useEffect(() => {
306
+ const saved = loadDashboardState();
307
+ if (saved && Array.isArray(saved.selectedWidgetIds) && saved.layouts) {
308
+ // compare as sets (order-insensitive)
309
+ const sameLength = saved.selectedWidgetIds.length === selectedWidgetIds.length;
310
+ const sameMembers = saved.selectedWidgetIds.every((id) => selectedWidgetIds.includes(id));
311
+ if (sameLength && sameMembers) {
312
+ setLayouts(saved.layouts);
313
+ return;
314
+ }
315
+ }
316
+ setLayouts(generateDefaultLayouts(visibleWidgets));
317
+ }, [selectedWidgetIds, visibleWidgets]);
318
+
319
+ const handleLayoutChange = useCallback((_: Layout[], allLayouts: Layouts) => {
320
+ setLayouts(allLayouts);
321
+ }, []);
322
+
323
+ // Reset to defaults for all available widgets and make all widgets visible
324
+ const handleResetLayout = useCallback(() => {
325
+ const allWidgetIds = AVAILABLE_WIDGETS.map((w) => w.id);
326
+ setSelectedWidgetIds(allWidgetIds);
327
+ setHiddenWidgetIds([]);
328
+
329
+ const defaultLayouts = generateDefaultLayouts(AVAILABLE_WIDGETS);
330
+ setLayouts(defaultLayouts);
331
+ saveDashboardState({
332
+ selectedWidgetIds: allWidgetIds,
333
+ hiddenWidgetIds: [],
334
+ layouts: defaultLayouts,
335
+ version: VERSION
336
+ });
337
+ }, []);
338
+
339
+ const toggleEditMode = useCallback(() => {
340
+ setIsEditMode((prev) => !prev);
341
+ }, []);
342
+
343
+ // Widget menu handlers
344
+ const openWidgetMenu = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget);
345
+ const closeWidgetMenu = () => setAnchorEl(null);
346
+
347
+ const toggleWidget = (id: string) => {
348
+ // If currently selected -> remove from selected and add to hidden
349
+ if (selectedWidgetIds.includes(id)) {
350
+ setSelectedWidgetIds((prev) => prev.filter((p) => p !== id));
351
+ setHiddenWidgetIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
352
+ } else {
353
+ // If currently hidden or not present -> add to selected and remove from hidden
354
+ setSelectedWidgetIds((prev) => [...prev, id]);
355
+ setHiddenWidgetIds((prev) => prev.filter((h) => h !== id));
356
+ }
357
+ };
358
+
359
+ useEffect(() => {
360
+ saveDashboardState({ selectedWidgetIds, hiddenWidgetIds, layouts, version: VERSION });
361
+ }, [selectedWidgetIds, hiddenWidgetIds, layouts]);
362
+
363
+ // Grid configuration
364
+ const gridConfig = useMemo(
365
+ () => ({
366
+ className: "layout",
367
+ layouts: layouts,
368
+ breakpoints: { lg: 1200, md: 996, sm: 768, xs: 480 },
369
+ cols: { lg: 12, md: 12, sm: 12, xs: 12 },
370
+ rowHeight: 100,
371
+ isDraggable: isEditMode,
372
+ isResizable: isEditMode,
373
+ onLayoutChange: handleLayoutChange,
374
+ draggableHandle: isEditMode ? undefined : ".no-drag",
375
+ margin: [16, 16] as [number, number],
376
+ containerPadding: [0, 0] as [number, number],
377
+ }),
378
+ [layouts, isEditMode, handleLayoutChange]
379
+ );
380
+
381
+ return (
382
+ <Box>
383
+ <Box
384
+ sx={{
385
+ display: "flex",
386
+ justifyContent: "flex-end",
387
+ my: 1,
388
+ mx: 2,
389
+ gap: 1,
390
+ }}
391
+ >
392
+ {isEditMode && (
393
+ <>
394
+ <Box
395
+ sx={{
396
+ p: 1,
397
+ borderRadius: 1,
398
+ fontSize: "0.875rem",
399
+ }}
400
+ >
401
+ {t('dashboard.dragWidgetsHelp')}
402
+ </Box>
403
+ <IconButton
404
+ size="small"
405
+ onClick={openWidgetMenu}
406
+ sx={{
407
+ color: "primary.main",
408
+ borderRadius: 1,
409
+ fontSize: "0.875rem",
410
+ }}
411
+ >
412
+ <RuleIcon />
413
+ </IconButton>
414
+ <Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={closeWidgetMenu}>
415
+ {AVAILABLE_WIDGETS.map((widget) => (
416
+ <MenuItem key={widget.id} onClick={() => toggleWidget(widget.id)}>
417
+ <Switch checked={selectedWidgetIds.includes(widget.id)} />
418
+ <ListItemText primary={t(widget.translationKey)} />
419
+ </MenuItem>
420
+ ))}
421
+ </Menu>
422
+ <Tooltip title={t('dashboard.resetLayout')}>
423
+ <IconButton
424
+ onClick={handleResetLayout}
425
+ size="small"
426
+ sx={{
427
+ color: "primary.main",
428
+ borderRadius: 1,
429
+ fontSize: "0.875rem",
430
+ }}
431
+ >
432
+ <RestartAltIcon />
433
+ </IconButton>
434
+ </Tooltip>
435
+ </>
436
+ )}
437
+
438
+ <Tooltip
439
+ title={isEditMode ? t('core.exitEditMode') : t('dashboard.customizeDashboard')}>
440
+ <Button
441
+ variant={isEditMode ? "contained" : "outlined"}
442
+ startIcon={isEditMode ? <DoneIcon /> : <DashboardCustomizeIcon />}
443
+ onClick={toggleEditMode}
444
+ size="small"
445
+ sx={{ p: 1 }}
446
+ >
447
+ {isEditMode ? t('save') : t('core.customize')}
448
+ </Button>
449
+ </Tooltip>
450
+ </Box>
451
+
452
+ <ResponsiveGridLayout {...gridConfig}>
453
+ {visibleWidgets.map((widget) => {
454
+ const WidgetComponent = widget.component;
455
+ return (
456
+ <Box
457
+ key={widget.id}
458
+ sx={{
459
+ // Add visual feedback in edit mode
460
+ border: isEditMode ? "1px dashed" : "none",
461
+ borderColor: "primary.main",
462
+ borderRadius: 1,
463
+ transition: "border 0.2s",
464
+ cursor: isEditMode ? "move" : "default",
465
+ "&:hover": isEditMode
466
+ ? {
467
+ borderColor: "primary.dark",
468
+ }
469
+ : {},
470
+ }}
471
+ >
472
+ <WidgetComponent />
473
+ </Box>
474
+ );
475
+ })}
476
+ </ResponsiveGridLayout>
477
+ </Box>
478
+ );
479
+ };
@@ -0,0 +1,122 @@
1
+ import { Card, CardActions, CardContent, CardHeader } from "@mui/material";
2
+ import React from "react";
3
+
4
+ export interface DashboardCardProps {
5
+ /**
6
+ * Card title displayed in the header
7
+ */
8
+ title: string;
9
+
10
+ /**
11
+ * Optional subtitle displayed below the title
12
+ */
13
+ subheader?: string;
14
+
15
+ /**
16
+ * Main content of the card
17
+ */
18
+ children: React.ReactNode;
19
+
20
+ /**
21
+ * Optional actions to display at the bottom of the card (buttons, icons, etc.)
22
+ */
23
+ actions?: React.ReactNode;
24
+
25
+ /**
26
+ * Optional header action (typically an icon button in the top-right)
27
+ */
28
+ headerAction?: React.ReactNode;
29
+
30
+ /**
31
+ * Custom height for the content area (default: auto-fills available space)
32
+ * Use this if you need to override the default flex behavior
33
+ */
34
+ contentHeight?: string | number;
35
+
36
+ /**
37
+ * Whether the content should scroll when it overflows (default: true)
38
+ */
39
+ scrollable?: boolean;
40
+
41
+ /**
42
+ * Additional sx props for the Card component
43
+ */
44
+ cardSx?: React.ComponentProps<typeof Card>["sx"];
45
+
46
+ /**
47
+ * Additional sx props for the CardContent component
48
+ */
49
+ contentSx?: React.ComponentProps<typeof CardContent>["sx"];
50
+ }
51
+
52
+ /**
53
+ * DashboardCard - A reusable card component for the configurable dashboard
54
+ *
55
+ * This component handles all the responsive layout logic so individual widgets
56
+ * don't need to worry about flexbox, height calculations, or scrolling.
57
+ *
58
+ * Features:
59
+ * - Automatically fills the grid cell height
60
+ * - Proper scrolling behavior
61
+ * - Consistent styling across all dashboard widgets
62
+ * - Easy to use for future widgets
63
+ *
64
+ * @example
65
+ * ```tsx
66
+ * <DashboardCard
67
+ * title="My Widget"
68
+ * subheader="Widget description"
69
+ * actions={<Button>See Details</Button>}
70
+ * >
71
+ * <YourContent />
72
+ * </DashboardCard>
73
+ * ```
74
+ */
75
+ export const DashboardCard: React.FC<DashboardCardProps> = (
76
+ {
77
+ title,
78
+ subheader,
79
+ children,
80
+ actions,
81
+ headerAction,
82
+ contentHeight,
83
+ scrollable = true,
84
+ cardSx = {},
85
+ contentSx = {},
86
+ }) => {
87
+ return (
88
+ <Card
89
+ sx={{
90
+ height: "100%",
91
+ display: "flex",
92
+ flexDirection: "column",
93
+ ...cardSx,
94
+ }}
95
+ >
96
+ {title !== '' && <CardHeader title={title} subheader={subheader} action={headerAction} />}
97
+
98
+ <CardContent
99
+ sx={{
100
+ flexGrow: 1,
101
+ overflow: scrollable ? "auto" : "visible",
102
+ minHeight: 0, // Critical for flexbox scrolling
103
+ height: contentHeight,
104
+ ...contentSx,
105
+ }}
106
+ >
107
+ {children}
108
+ </CardContent>
109
+
110
+ {actions && (
111
+ <CardActions
112
+ sx={{
113
+ justifyContent: "space-between",
114
+ alignItems: "flex-start",
115
+ }}
116
+ >
117
+ {actions}
118
+ </CardActions>
119
+ )}
120
+ </Card>
121
+ );
122
+ };
@@ -32,14 +32,14 @@ export const EmptyCard = (props: {
32
32
  </Button>;
33
33
 
34
34
  return (<>
35
- <Card>
35
+ <Card sx={{ paddingTop: 0, height: "100%", }}>
36
36
  <CardHeader
37
37
  title={props.title}
38
38
  subheader={'.'}
39
39
  sx={{ paddingBottom: 0 }} />
40
40
 
41
- <CardContent sx={{ paddingTop: 0, height: "500px", }}>
42
- <OverviewEmpty />
41
+ <CardContent>
42
+ <OverviewEmpty height={'50%'} />
43
43
  </CardContent>
44
44
 
45
45
  <CardActions>
@@ -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
+