@wger-project/react-components 25.12.5 → 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 (58) 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 +13 -3
  5. package/build/locales/en/translation.json +3 -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 +4 -1
  9. package/build/locales/nl/translation.json +118 -11
  10. package/build/locales/pt_BR/translation.json +12 -3
  11. package/build/locales/ru/translation.json +39 -3
  12. package/build/locales/uk/translation.json +10 -1
  13. package/build/main.js +169 -169
  14. package/build/main.js.map +1 -1
  15. package/package.json +4 -1
  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 +306 -55
  24. package/src/components/Dashboard/DashboardCard.tsx +3 -2
  25. package/src/components/Dashboard/MeasurementCard.test.tsx +75 -0
  26. package/src/components/Dashboard/MeasurementCard.tsx +101 -0
  27. package/src/components/Dashboard/TrophiesCard.test.tsx +63 -0
  28. package/src/components/Dashboard/TrophiesCard.tsx +84 -0
  29. package/src/components/Dashboard/WeightCard.test.tsx +0 -10
  30. package/src/components/Measurements/Screens/MeasurementCategoryOverview.tsx +1 -1
  31. package/src/components/Measurements/models/Category.ts +13 -2
  32. package/src/components/Measurements/models/Entry.ts +13 -2
  33. package/src/components/Trophies/components/TrophiesDetail.test.tsx +34 -0
  34. package/src/components/Trophies/components/TrophiesDetail.tsx +88 -0
  35. package/src/components/Trophies/models/trophy.test.ts +33 -0
  36. package/src/components/Trophies/models/trophy.ts +75 -0
  37. package/src/components/Trophies/models/userTrophy.test.ts +38 -0
  38. package/src/components/Trophies/models/userTrophy.ts +67 -0
  39. package/src/components/Trophies/models/userTrophyProgression.test.ts +43 -0
  40. package/src/components/Trophies/models/userTrophyProgression.ts +68 -0
  41. package/src/components/Trophies/queries/trophies.ts +31 -0
  42. package/src/components/Trophies/services/trophies.ts +22 -0
  43. package/src/components/Trophies/services/userTrophies.ts +33 -0
  44. package/src/components/Trophies/services/userTrophyProgression.ts +16 -0
  45. package/src/components/index.ts +0 -2
  46. package/src/pages/Calendar/index.tsx +2 -2
  47. package/src/pages/WeightOverview/index.tsx +1 -1
  48. package/src/routes.tsx +6 -1
  49. package/src/services/measurements.ts +10 -17
  50. package/src/tests/trophies/trophiesTestData.ts +80 -0
  51. package/src/utils/consts.ts +18 -3
  52. package/src/utils/url.test.ts +32 -1
  53. package/src/utils/url.ts +24 -3
  54. package/src/components/Carousel/carousel.module.css +0 -43
  55. package/src/components/Carousel/carousel.module.css.map +0 -1
  56. package/src/components/Carousel/carousel.module.scss +0 -46
  57. package/src/components/Carousel/index.tsx +0 -66
  58. package/src/components/Dashboard/GoalCard.tsx +0 -71
@@ -1,28 +1,43 @@
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, Layouts, Responsive, WidthProvider } 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
18
  const ResponsiveGridLayout = WidthProvider(Responsive);
15
19
 
16
- // Local storage key for persisting layouts
17
- const LAYOUT_STORAGE_KEY = "dashboard-layout";
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;
18
32
 
19
33
  // Define widget types for extensibility
20
- export type WidgetType = "routine" | "nutrition" | "weight";
34
+ export type WidgetType = "routine" | "nutrition" | "weight" | "calendar" | "measurement" | "trophies";
21
35
 
22
36
  export interface WidgetConfig {
23
37
  id: string;
24
38
  type: WidgetType;
25
39
  component: React.ComponentType;
40
+ translationKey: string; // key for i18n translations
26
41
  defaultLayout: {
27
42
  w: number;
28
43
  h: number;
@@ -39,31 +54,188 @@ export const AVAILABLE_WIDGETS: WidgetConfig[] = [
39
54
  id: "routine",
40
55
  type: "routine",
41
56
  component: RoutineCard,
57
+ translationKey: 'routines.routine',
42
58
  defaultLayout: { w: 4, h: 5, x: 0, y: 0, minW: 3, minH: 2 },
43
59
  },
44
60
  {
45
61
  id: "nutrition",
46
62
  type: "nutrition",
47
63
  component: NutritionCard,
64
+ translationKey: 'nutritionalPlan',
48
65
  defaultLayout: { w: 4, h: 5, x: 4, y: 0, minW: 3, minH: 2 },
49
66
  },
50
67
  {
51
68
  id: "weight",
52
69
  type: "weight",
53
70
  component: WeightCard,
71
+ translationKey: 'weight',
54
72
  defaultLayout: { w: 4, h: 5, x: 8, y: 0, minW: 3, minH: 2 },
55
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
+ },
56
95
  ];
57
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
+
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): Layouts => {
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,49 +259,107 @@ 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;
109
271
 
110
- export const ConfigurableDashboard: React.FC = () => {
111
- const [t] = useTranslation();
112
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
+
113
298
  const [layouts, setLayouts] = useState<Layouts>(() => {
114
- const savedLayouts = loadLayouts();
115
- return savedLayouts || generateDefaultLayouts();
299
+ const saved = loadDashboardState();
300
+ return (saved && saved.layouts) || generateDefaultLayouts(visibleWidgets);
116
301
  });
117
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
+
118
319
  const handleLayoutChange = useCallback((_: Layout[], allLayouts: Layouts) => {
119
320
  setLayouts(allLayouts);
120
- saveLayouts(allLayouts);
121
321
  }, []);
122
322
 
323
+ // Reset to defaults for all available widgets and make all widgets visible
123
324
  const handleResetLayout = useCallback(() => {
124
- const defaultLayouts = generateDefaultLayouts();
325
+ const allWidgetIds = AVAILABLE_WIDGETS.map((w) => w.id);
326
+ setSelectedWidgetIds(allWidgetIds);
327
+ setHiddenWidgetIds([]);
328
+
329
+ const defaultLayouts = generateDefaultLayouts(AVAILABLE_WIDGETS);
125
330
  setLayouts(defaultLayouts);
126
- saveLayouts(defaultLayouts);
331
+ saveDashboardState({
332
+ selectedWidgetIds: allWidgetIds,
333
+ hiddenWidgetIds: [],
334
+ layouts: defaultLayouts,
335
+ version: VERSION
336
+ });
127
337
  }, []);
128
338
 
129
339
  const toggleEditMode = useCallback(() => {
130
340
  setIsEditMode((prev) => !prev);
131
341
  }, []);
132
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
+
133
363
  // Grid configuration
134
364
  const gridConfig = useMemo(
135
365
  () => ({
@@ -160,32 +390,53 @@ export const ConfigurableDashboard: React.FC = () => {
160
390
  }}
161
391
  >
162
392
  {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')}>
393
+ <>
394
+ <Box
395
+ sx={{
396
+ p: 1,
397
+ borderRadius: 1,
398
+ fontSize: "0.875rem",
399
+ }}
400
+ >
401
+ {t('dashboard.dragWidgetsHelp')}
402
+ </Box>
175
403
  <IconButton
176
- onClick={handleResetLayout}
177
404
  size="small"
405
+ onClick={openWidgetMenu}
178
406
  sx={{
179
407
  color: "primary.main",
180
408
  borderRadius: 1,
181
409
  fontSize: "0.875rem",
182
410
  }}
183
411
  >
184
- <RestartAltIcon />
412
+ <RuleIcon />
185
413
  </IconButton>
186
- </Tooltip>
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
+ </>
187
436
  )}
188
- <Tooltip title={isEditMode ? t('core.exitEditMode') : t('dashboard.customizeDashboard')}>
437
+
438
+ <Tooltip
439
+ title={isEditMode ? t('core.exitEditMode') : t('dashboard.customizeDashboard')}>
189
440
  <Button
190
441
  variant={isEditMode ? "contained" : "outlined"}
191
442
  startIcon={isEditMode ? <DoneIcon /> : <DashboardCustomizeIcon />}
@@ -199,7 +450,7 @@ export const ConfigurableDashboard: React.FC = () => {
199
450
  </Box>
200
451
 
201
452
  <ResponsiveGridLayout {...gridConfig}>
202
- {AVAILABLE_WIDGETS.map((widget) => {
453
+ {visibleWidgets.map((widget) => {
203
454
  const WidgetComponent = widget.component;
204
455
  return (
205
456
  <Box
@@ -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
+
@@ -0,0 +1,101 @@
1
+ import Button from "@mui/material/Button";
2
+ import Table from "@mui/material/Table";
3
+ import TableBody from "@mui/material/TableBody";
4
+ import TableCell from "@mui/material/TableCell";
5
+ import TableHead from "@mui/material/TableHead";
6
+ import TableRow from "@mui/material/TableRow";
7
+ import Typography from "@mui/material/Typography";
8
+ import { LoadingPlaceholder } from "components/Core/LoadingWidget/LoadingWidget";
9
+ import { DashboardCard } from "components/Dashboard/DashboardCard";
10
+ import { EmptyCard } from "components/Dashboard/EmptyCard";
11
+ import { MeasurementCategory } from "components/Measurements/models/Category";
12
+ import { useMeasurementsCategoryQuery } from "components/Measurements/queries";
13
+ import { CategoryForm } from "components/Measurements/widgets/CategoryForm";
14
+ import { MeasurementChart } from "components/Measurements/widgets/MeasurementChart";
15
+ import i18n from "i18n";
16
+ import React from "react";
17
+ import { useTranslation } from "react-i18next";
18
+ import Slider, { Settings } from "react-slick";
19
+ import { makeLink, WgerLink } from "utils/url";
20
+ import "slick-carousel/slick/slick.css";
21
+ import "slick-carousel/slick/slick-theme.css";
22
+
23
+
24
+ export const MeasurementCard = () => {
25
+ const { t } = useTranslation();
26
+ const categoryQuery = useMeasurementsCategoryQuery();
27
+
28
+ if (categoryQuery.isLoading) {
29
+ return <LoadingPlaceholder />;
30
+ }
31
+
32
+ return categoryQuery.data === null
33
+ ? <EmptyCard
34
+ title={t("measurements.measurements")}
35
+ modalContent={<CategoryForm />}
36
+ modalTitle={t("add")} />
37
+ : <MeasurementCardContent categories={categoryQuery.data!} />;
38
+ };
39
+
40
+ const MeasurementCardContent = (props: { categories: MeasurementCategory[] }) => {
41
+ const { t } = useTranslation();
42
+
43
+ const settings: Settings = {
44
+ dots: true,
45
+ infinite: true,
46
+ speed: 500,
47
+ slidesToShow: 1,
48
+ slidesToScroll: 1,
49
+ arrows: false,
50
+ };
51
+
52
+ return (<>
53
+ <DashboardCard
54
+ title={t("measurements.measurements")}
55
+ actions={
56
+ <>
57
+ <Button
58
+ size="small"
59
+ href={makeLink(WgerLink.MEASUREMENT_OVERVIEW, i18n.language)}
60
+ >
61
+ {t("seeDetails")}
62
+ </Button>
63
+ </>
64
+ }
65
+ >
66
+ <div className="slider-container">
67
+ <Slider {...settings}>
68
+ {props.categories.map(c => <MeasurementCardTableContent category={c} />)}
69
+ </Slider>
70
+ </div>
71
+ </DashboardCard>
72
+ </>);
73
+ };
74
+
75
+
76
+ const MeasurementCardTableContent = (props: { category: MeasurementCategory }) => {
77
+ const { t } = useTranslation();
78
+
79
+ return (<>
80
+ <Typography variant="h6" gutterBottom>
81
+ {props.category.name}
82
+ </Typography>
83
+ <MeasurementChart category={props.category} />
84
+ <Table size="small">
85
+ <TableHead>
86
+ <TableRow>
87
+ <TableCell>{t('date')}</TableCell>
88
+ <TableCell>{t('value')}</TableCell>
89
+ </TableRow>
90
+ </TableHead>
91
+ <TableBody>
92
+ {[...props.category.entries].slice(0, 5).map(entry => (
93
+ <TableRow key={`measurement-entry-${entry.id}`}>
94
+ <TableCell>{entry.date.toLocaleDateString()}</TableCell>
95
+ <TableCell>{entry.value} {props.category.unit}</TableCell>
96
+ </TableRow>
97
+ ))}
98
+ </TableBody>
99
+ </Table>
100
+ </>);
101
+ };