@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.
- 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/de/translation.json +13 -3
- package/build/locales/en/translation.json +3 -0
- package/build/locales/es/translation.json +22 -3
- package/build/locales/fr/translation.json +10 -1
- package/build/locales/hi/translation.json +4 -1
- package/build/locales/nl/translation.json +118 -11
- package/build/locales/pt_BR/translation.json +12 -3
- package/build/locales/ru/translation.json +39 -3
- package/build/locales/uk/translation.json +10 -1
- package/build/main.js +169 -169
- package/build/main.js.map +1 -1
- package/package.json +4 -1
- 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 +128 -0
- package/src/components/Dashboard/ConfigurableDashboard.tsx +306 -55
- 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/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/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,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
|
|
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, 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
|
-
|
|
17
|
-
|
|
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[] =
|
|
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[] =
|
|
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,49 +259,107 @@ 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
|
-
} 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
|
|
115
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
<
|
|
412
|
+
<RuleIcon />
|
|
185
413
|
</IconButton>
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
+
};
|