@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.
- 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 +25 -6
- package/build/locales/en/translation.json +12 -0
- package/build/locales/es/translation.json +22 -3
- package/build/locales/fr/translation.json +10 -1
- package/build/locales/hi/translation.json +8 -0
- package/build/locales/nl/translation.json +350 -239
- package/build/locales/pt_BR/translation.json +349 -255
- package/build/locales/ru/translation.json +39 -3
- package/build/locales/uk/translation.json +10 -1
- package/build/main.js +170 -166
- package/build/main.js.map +1 -1
- package/package.json +8 -2
- 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 +479 -0
- package/src/components/Dashboard/DashboardCard.tsx +122 -0
- package/src/components/Dashboard/EmptyCard.tsx +3 -3
- package/src/components/Dashboard/MeasurementCard.test.tsx +75 -0
- package/src/components/Dashboard/MeasurementCard.tsx +101 -0
- package/src/components/Dashboard/NutritionCard.tsx +88 -96
- package/src/components/Dashboard/RoutineCard.tsx +54 -69
- 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/Dashboard/WeightCard.tsx +36 -42
- package/src/components/Exercises/Detail/Head/ExerciseDeleteDialog.tsx +1 -1
- package/src/components/Measurements/Screens/MeasurementCategoryOverview.tsx +1 -1
- package/src/components/Measurements/models/Category.ts +13 -2
- package/src/components/Measurements/models/Entry.ts +13 -2
- package/src/components/Trophies/components/TrophiesDetail.test.tsx +34 -0
- package/src/components/Trophies/components/TrophiesDetail.tsx +88 -0
- package/src/components/Trophies/models/trophy.test.ts +33 -0
- package/src/components/Trophies/models/trophy.ts +75 -0
- package/src/components/Trophies/models/userTrophy.test.ts +38 -0
- package/src/components/Trophies/models/userTrophy.ts +67 -0
- package/src/components/Trophies/models/userTrophyProgression.test.ts +43 -0
- package/src/components/Trophies/models/userTrophyProgression.ts +68 -0
- package/src/components/Trophies/queries/trophies.ts +31 -0
- package/src/components/Trophies/services/trophies.ts +22 -0
- package/src/components/Trophies/services/userTrophies.ts +33 -0
- package/src/components/Trophies/services/userTrophyProgression.ts +16 -0
- package/src/components/WorkoutRoutines/Detail/WorkoutStats.tsx +1 -1
- package/src/components/WorkoutRoutines/widgets/forms/DayTypeSelect.tsx +1 -2
- package/src/components/WorkoutRoutines/widgets/forms/SlotForm.tsx +0 -4
- package/src/components/index.ts +0 -2
- package/src/index.tsx +0 -46
- package/src/pages/Calendar/index.tsx +2 -2
- package/src/pages/WeightOverview/index.tsx +1 -1
- package/src/routes.tsx +87 -79
- package/src/services/exerciseTranslation.ts +5 -6
- package/src/services/measurements.ts +10 -17
- package/src/services/video.test.ts +4 -4
- 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/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
|
|
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
|
+
|