diff --git a/package.json b/package.json index 5651c2ca8..92056e841 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,13 @@ "@emotion/styled": "^11.14.1", "@hello-pangea/dnd": "^18.0.1", "@mui/icons-material": "^7.3.4", - "@mui/lab": "^7.0.1-beta.18", + "@mui/lab": "^7.0.0-beta.10", "@mui/material": "^7.3.4", - "@mui/system": "^7.3.3", - "@mui/x-data-grid": "^8.14.0", - "@mui/x-date-pickers": "^8.14.0", - "@tanstack/react-query": "^5.90.2", - "@vitejs/plugin-react": "^5.0.4", + "@mui/system": "^7.0.1", + "@mui/x-data-grid": "^8.2.0", + "@mui/x-date-pickers": "^8.2.0", + "@tanstack/react-query": "^5.71.5", + "@vitejs/plugin-react": "^5.0.3", "axios": "^1.12.2", "formik": "^2.4.6", "history": "^5.3.0", diff --git a/src/App.tsx b/src/App.tsx index 3a6970825..9e4319e62 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,24 +1,25 @@ -import Grid from '@mui/material/Grid'; -import { Header, } from 'components'; -import { Notifications } from 'components/Core/Notifications'; -import React from 'react'; +import Grid from "@mui/material/Grid"; +import { Header } from "components"; +import { Notifications } from "components/Core/Notifications"; +import React from "react"; import { WgerRoutes } from "routes"; - +import { PreferencesProvider } from "state/PreferencesContext"; function App() { - return ( - ( - -
- - - - - - + + + +
+ + + + + + + - ) + ); } diff --git a/src/components/Header/SubMenus/PreferenceButton.tsx b/src/components/Header/SubMenus/PreferenceButton.tsx new file mode 100644 index 000000000..c2ad32c2e --- /dev/null +++ b/src/components/Header/SubMenus/PreferenceButton.tsx @@ -0,0 +1,20 @@ +import { IconButton } from "@mui/material"; +import { Link } from "react-router-dom"; +import { Settings } from "@mui/icons-material"; +import { useTranslation } from "react-i18next"; +import { makeLink, WgerLink } from "utils/url"; + +export const PreferenceButton = () => { + const { i18n } = useTranslation(); + + return ( + + + + ); +}; diff --git a/src/components/Header/SubMenus/TrainingSubMenu.tsx b/src/components/Header/SubMenus/TrainingSubMenu.tsx index 5e8125453..30523cbd9 100644 --- a/src/components/Header/SubMenus/TrainingSubMenu.tsx +++ b/src/components/Header/SubMenus/TrainingSubMenu.tsx @@ -1,42 +1,59 @@ import { Button, Menu, MenuItem } from "@mui/material"; import React from "react"; import { useTranslation } from "react-i18next"; -import { Link } from 'react-router-dom'; +import { Link } from "react-router-dom"; +import { usePreferences } from "state/PreferencesContext"; import { makeLink, WgerLink } from "utils/url"; export const TrainingSubMenu = () => { - const { i18n } = useTranslation(); const [anchorElRoutine, setAnchorElRoutine] = React.useState(null); + const { + showRoutineOverview, + showPrivateTemplate, + showPublicTemplate, + showExerciseOverview, + showExerciseContribute, + showCalendar, + } = usePreferences(); + return ( <> - setAnchorElRoutine(null)} - > - - Routine overview - - - Private template overview - - - Public template overview - - - Exercise overview - - - Contribute exercise - - - Calendar - + setAnchorElRoutine(null)}> + {showRoutineOverview && ( + + Routine overview + + )} + {showPrivateTemplate && ( + + Private template overview + + )} + {showPublicTemplate && ( + + Public template overview + + )} + {showExerciseOverview && ( + + Exercise overview + + )} + {showExerciseContribute && ( + + Contribute exercise + + )} + {showCalendar && ( + + Calendar + + )} ); diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index 26186c5f9..dee0fa539 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -3,25 +3,33 @@ import { BodyWeightSubMenu } from "components/Header/SubMenus/BodyWeightSubMenu" import { MeasurementsSubMenu } from "components/Header/SubMenus/MeasurementsSubMenu"; import { NutritionSubMenu } from "components/Header/SubMenus/NutritionSubMenu"; import { TrainingSubMenu } from "components/Header/SubMenus/TrainingSubMenu"; -import React from 'react'; - +import { PreferenceButton } from "./SubMenus/PreferenceButton"; +import { usePreferences } from "state/PreferencesContext"; +import React from "react"; export const Header = () => { + const { showTraining, showBodyWeight, showMeasurements, showNutrition } = usePreferences(); return ( - + wger - - - - - - + {showTraining && } + {showBodyWeight && } + {showMeasurements && } + {showNutrition && } + ); -}; \ No newline at end of file +}; diff --git a/src/pages/Preferences/Preferences.test.tsx b/src/pages/Preferences/Preferences.test.tsx new file mode 100644 index 000000000..63ad20a0d --- /dev/null +++ b/src/pages/Preferences/Preferences.test.tsx @@ -0,0 +1,113 @@ +// Preferences.test.tsx +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { PreferencesProvider } from "state/PreferencesContext"; +import { Preferences } from "."; + +const renderWithProvider = (ui: React.ReactElement) => render({ui}); + +const getSwitchByLabel = (label: string) => { + const listItem = screen.getByText(label).closest("li"); + if (!listItem) throw new Error(`No list item found for label: ${label}`); + const input = listItem.querySelector('input[type="checkbox"]') as HTMLInputElement; + if (!input) throw new Error(`No input found for label: ${label}`); + return input; +}; + +// Using test doubles (mocks/stubs/fakes) +describe("Preferences component", () => { + it("renders all submenu switches with correct default values", () => { + renderWithProvider(); + + expect(getSwitchByLabel("Show Training").checked).toBe(true); + expect(getSwitchByLabel("Show Body Weight").checked).toBe(true); + expect(getSwitchByLabel("Show Measurements").checked).toBe(true); + expect(getSwitchByLabel("Show Nutrition").checked).toBe(true); + + expect(getSwitchByLabel("Routine overview").checked).toBe(true); + expect(getSwitchByLabel("Private template").checked).toBe(true); + expect(getSwitchByLabel("Public template").checked).toBe(true); + expect(getSwitchByLabel("Exercise overview").checked).toBe(true); + expect(getSwitchByLabel("Contribute exercise").checked).toBe(true); + expect(getSwitchByLabel("Calendar").checked).toBe(true); + }); + + it("toggles a main submenu switch", () => { + renderWithProvider(); + const trainingSwitch = getSwitchByLabel("Show Training"); + + fireEvent.click(trainingSwitch); + expect(trainingSwitch.checked).toBe(false); + + fireEvent.click(trainingSwitch); + expect(trainingSwitch.checked).toBe(true); + }); + + it("toggles a training submenu switch", () => { + renderWithProvider(); + const routineSwitch = getSwitchByLabel("Routine overview"); + + fireEvent.click(routineSwitch); + expect(routineSwitch.checked).toBe(false); + + fireEvent.click(routineSwitch); + expect(routineSwitch.checked).toBe(true); + }); + + it("prevents hiding last visible main submenu", () => { + renderWithProvider(); + + const trainingSwitch = getSwitchByLabel("Show Training"); + const bodyWeightSwitch = getSwitchByLabel("Show Body Weight"); + const measurementsSwitch = getSwitchByLabel("Show Measurements"); + const nutritionSwitch = getSwitchByLabel("Show Nutrition"); + + // Hide 3 of 4 menus + fireEvent.click(bodyWeightSwitch); + fireEvent.click(measurementsSwitch); + fireEvent.click(nutritionSwitch); + + const alertMock = jest.spyOn(window, "alert").mockImplementation(() => {}); + fireEvent.click(trainingSwitch); // should be blocked + + expect(trainingSwitch.checked).toBe(true); + expect(alertMock).toHaveBeenCalledWith("At least one item must remain visible."); + alertMock.mockRestore(); + }); + + it("restores training submenu switches when main menu toggled", () => { + renderWithProvider(); + + const trainingSwitch = getSwitchByLabel("Show Training"); + const routineSwitch = getSwitchByLabel("Routine overview"); + + // Turn off a training submenu + fireEvent.click(routineSwitch); + expect(routineSwitch.checked).toBe(false); + + // Turn off main menu + fireEvent.click(trainingSwitch); + expect(trainingSwitch.checked).toBe(false); + expect(routineSwitch.checked).toBe(false); + + // Turn main menu back on, should restore previous submenu state + fireEvent.click(trainingSwitch); + expect(trainingSwitch.checked).toBe(true); + expect(routineSwitch.checked).toBe(false); // restored to previous state + }); +}); + +//Profiling +test("renders all submenu switches with correct default values", () => { + const t0 = performance.now(); + + renderWithProvider(); + + const t1 = performance.now(); + console.log("Render time:", t1 - t0, "ms"); + + // rest of your test assertions + expect(getSwitchByLabel("Show Training")).toBeInTheDocument(); + expect(getSwitchByLabel("Show Training")).toBeChecked(); + expect(getSwitchByLabel("Show Body Weight")).toBeChecked(); +}); diff --git a/src/pages/Preferences/index.tsx b/src/pages/Preferences/index.tsx index 9bdb86ef6..dad98e7e6 100644 --- a/src/pages/Preferences/index.tsx +++ b/src/pages/Preferences/index.tsx @@ -1,9 +1,65 @@ -import React from 'react'; +import { List, ListItem, ListItemText, Switch, Paper, Divider, Typography } from "@mui/material"; +import { WgerContainerRightSidebar } from "components/Core/Widgets/Container"; +import { usePreferences } from "state/PreferencesContext"; +import React from "react"; export const Preferences = () => { + const prefs = usePreferences(); + return ( -
- Preferences Page -
+ + + + Submenus + + {( + [ + ["Show Training", prefs.showTraining, prefs.setShowTraining], + ["Show Body Weight", prefs.showBodyWeight, prefs.setShowBodyWeight], + ["Show Measurements", prefs.showMeasurements, prefs.setShowMeasurements], + ["Show Nutrition", prefs.showNutrition, prefs.setShowNutrition], + ] as [string, boolean, (v: boolean) => void][] + ).map(([label, value, setter]) => ( + setter(e.target.checked)} /> + } + > + + + ))} + + + + Training submenu + + + {( + [ + ["Routine overview", prefs.showRoutineOverview, prefs.setShowRoutineOverview], + ["Private template", prefs.showPrivateTemplate, prefs.setShowPrivateTemplate], + ["Public template", prefs.showPublicTemplate, prefs.setShowPublicTemplate], + ["Exercise overview", prefs.showExerciseOverview, prefs.setShowExerciseOverview], + ["Contribute exercise", prefs.showExerciseContribute, prefs.setShowExerciseContribute], + ["Calendar", prefs.showCalendar, prefs.setShowCalendar], + ] as [string, boolean, (v: boolean) => void][] + ).map(([label, value, setter]) => ( + setter(e.target.checked)} /> + } + > + + + ))} + + + } + /> ); -}; \ No newline at end of file +}; diff --git a/src/state/PreferencesContext.test.tsx b/src/state/PreferencesContext.test.tsx new file mode 100644 index 000000000..59bbcc3ff --- /dev/null +++ b/src/state/PreferencesContext.test.tsx @@ -0,0 +1,87 @@ +// PreferencesContext.test.tsx +import React from "react"; +import { render, screen, act } from "@testing-library/react"; +import { PreferencesProvider, usePreferences } from "./PreferencesContext"; + +// Helper component to consume context +const TestComponent: React.FC = () => { + const prefs = usePreferences(); + return ( +
+ {prefs.showTraining ? "true" : "false"} + + +
+ ); +}; + +describe("PreferencesContext", () => { + it("provides default values", () => { + render( + + + + ); + expect(screen.getByTestId("showTraining").textContent).toBe("true"); + }); + + it("updates values with setter", () => { + render( + + + + ); + + const hideButton = screen.getByText("Hide Training"); + const showButton = screen.getByText("Show Training"); + + act(() => { + hideButton.click(); + }); + expect(screen.getByTestId("showTraining").textContent).toBe("false"); + + act(() => { + showButton.click(); + }); + + expect(screen.getByTestId("showTraining").textContent).toBe("true"); + }); + + it("throws error if usePreferences used outside provider", () => { + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow("usePreferences must be used within PreferencesProvider"); + consoleError.mockRestore(); + }); + + it("toggles submenu correctly when main menu is hidden and restored", () => { + const SubmenuTest: React.FC = () => { + const prefs = usePreferences(); + return ( +
+ {prefs.showRoutineOverview ? "true" : "false"} + + +
+ ); + }; + + render( + + + + ); + + const hideButton = screen.getByText("Hide Training"); + const showButton = screen.getByText("Show Training"); + + act(() => { + hideButton.click(); + }); + expect(screen.getByTestId("routine").textContent).toBe("false"); + + act(() => { + showButton.click(); + }); + expect(screen.getByTestId("routine").textContent).toBe("true"); + }); +}); diff --git a/src/state/PreferencesContext.tsx b/src/state/PreferencesContext.tsx new file mode 100644 index 000000000..60afc6d77 --- /dev/null +++ b/src/state/PreferencesContext.tsx @@ -0,0 +1,181 @@ +import React, { createContext, useContext, useEffect, useState } from "react"; + +type PreferencesContextType = { + // Submenus + showTraining: boolean; + showBodyWeight: boolean; + showMeasurements: boolean; + showNutrition: boolean; + + // Training submenu items + showRoutineOverview: boolean; + showPrivateTemplate: boolean; + showPublicTemplate: boolean; + showExerciseOverview: boolean; + showExerciseContribute: boolean; + showCalendar: boolean; + + // Setters + setShowTraining: (v: boolean) => void; + setShowBodyWeight: (v: boolean) => void; + setShowMeasurements: (v: boolean) => void; + setShowNutrition: (v: boolean) => void; + setShowRoutineOverview: (v: boolean) => void; + setShowPrivateTemplate: (v: boolean) => void; + setShowPublicTemplate: (v: boolean) => void; + setShowExerciseOverview: (v: boolean) => void; + setShowExerciseContribute: (v: boolean) => void; + setShowCalendar: (v: boolean) => void; +}; + +export const PreferencesContext = createContext(undefined); + +export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + // Main submenus + const [showTraining, setShowTraining] = useState(true); + const [lastTraining, setLastTraining] = useState([true, true, true, true, true, true]); + const [showBodyWeight, setShowBodyWeight] = useState(true); + const [showMeasurements, setShowMeasurements] = useState(true); + const [showNutrition, setShowNutrition] = useState(true); + + // Training submenu items + const [showRoutineOverview, setShowRoutineOverview] = useState(true); + const [showPrivateTemplate, setShowPrivateTemplate] = useState(true); + const [showPublicTemplate, setShowPublicTemplate] = useState(true); + const [showExerciseOverview, setShowExerciseOverview] = useState(true); + const [showExerciseContribute, setShowExerciseContribute] = useState(true); + const [showCalendar, setShowCalendar] = useState(true); + + // --- Helpers to prevent disabling all --- + const countVisibleMain = [showTraining, showBodyWeight, showMeasurements, showNutrition].filter(Boolean).length; + const countVisibleTraining = [ + showRoutineOverview, + showPrivateTemplate, + showPublicTemplate, + showExerciseOverview, + showExerciseContribute, + showCalendar, + ].filter(Boolean).length; + + // Wrapper to protect at least one visible item + const safeToggle = (setter: (v: boolean) => void, current: boolean, groupCount: number) => (next: boolean) => { + if (groupCount === 1 && current && !next) { + alert("At least one item must remain visible."); + return; + } + setter(next); + }; + + const safeToggleItem = + (setter: (v: boolean) => void, current: boolean, groupCount: number, groupSubMenu: boolean) => + (next: boolean) => { + if (groupCount === 1 && current && !next) { + alert("At least one item must remain visible."); + return; + } + if (!groupSubMenu && next) { + const updated = [...lastTraining]; + if (setter === setShowRoutineOverview) updated[0] = next; + if (setter === setShowPrivateTemplate) updated[1] = next; + if (setter === setShowPublicTemplate) updated[2] = next; + if (setter === setShowExerciseOverview) updated[3] = next; + if (setter === setShowExerciseContribute) updated[4] = next; + if (setter === setShowCalendar) updated[5] = next; + setter(next); + setLastTraining(updated); + setShowTraining(true); + return; + } + + setter(next); + }; + + useEffect(() => { + if (!showTraining) { + setLastTraining([ + showRoutineOverview, + showPrivateTemplate, + showPublicTemplate, + showExerciseOverview, + showExerciseContribute, + showCalendar, + ]); + setShowRoutineOverview(false); + setShowPrivateTemplate(false); + setShowPublicTemplate(false); + setShowExerciseOverview(false); + setShowExerciseContribute(false); + setShowCalendar(false); + } else { + setShowRoutineOverview(lastTraining[0]); + setShowPrivateTemplate(lastTraining[1]); + setShowPublicTemplate(lastTraining[2]); + setShowExerciseOverview(lastTraining[3]); + setShowExerciseContribute(lastTraining[4]); + setShowCalendar(lastTraining[5]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showTraining]); + + return ( + + {children} + + ); +}; + +export const usePreferences = () => { + const ctx = useContext(PreferencesContext); + if (!ctx) throw new Error("usePreferences must be used within PreferencesProvider"); + return ctx; +}; diff --git a/src/utils/url.test.ts b/src/utils/url.test.ts index d2ce719f5..14958ac27 100644 --- a/src/utils/url.test.ts +++ b/src/utils/url.test.ts @@ -124,4 +124,8 @@ describe("test the makeLink helper", () => { expect(result).toEqual('/de/weight/overview'); }); + test('link to weight overview page', () => { + const result = makeLink(WgerLink.USER_PREFERENCES, 'de',); + expect(result).toEqual('/de/user/preferences'); + }); }); diff --git a/src/utils/url.ts b/src/utils/url.ts index 8da88eed3..f2fccec5b 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -3,14 +3,12 @@ import { IS_PROD, VITE_API_KEY, VITE_API_SERVER } from "config"; import slug from "slug"; interface makeUrlInterface { - id?: number, - server?: string, - objectMethod?: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - query?: { [key: string]: any }, + id?: number; + server?: string; + objectMethod?: string; + query?: object; } - /* * util function that generates a url string from a base url and a query object */ @@ -19,7 +17,7 @@ export function makeUrl(path: string, params?: makeUrlInterface) { // Base data const serverUrl = params.server || VITE_API_SERVER; - const paths = [serverUrl, 'api', 'v2', path]; + const paths = [serverUrl, "api", "v2", path]; // Detail view if (params.id) { @@ -31,7 +29,7 @@ export function makeUrl(path: string, params?: makeUrlInterface) { paths.push(params.objectMethod); } - paths.push(''); + paths.push(""); // Query parameters if (params.query) { @@ -42,13 +40,12 @@ export function makeUrl(path: string, params?: makeUrlInterface) { } } paths.pop(); - paths.push(`?${queryList.join('&')}`); + paths.push(`?${queryList.join("&")}`); } - return paths.join('/'); + return paths.join("/"); } - export enum WgerLink { DASHBOARD, @@ -86,13 +83,14 @@ export enum WgerLink { NUTRITION_PLAN_COPY, NUTRITION_DIARY, + USER_PREFERENCES, + INGREDIENT_DETAIL, - CALENDAR + CALENDAR, } -type UrlParams = { id: number, id2?: number, slug?: string, date?: string }; - +type UrlParams = { id: number; id2?: number; slug?: string; date?: string }; /* * Util function that generates a clickable url @@ -100,8 +98,11 @@ type UrlParams = { id: number, id2?: number, slug?: string, date?: string }; * These URLs need to be kept in sync with the ones used in django */ export function makeLink(link: WgerLink, language?: string, params?: UrlParams): string { + language = language || "en-us"; - language = language?.toLowerCase() || 'en'; + // If the name is in the form of "en-US", remove the country code since + // our django app can't work with that at the moment. + const langShort = language.split("-")[0]; switch (link) { // Workout routines @@ -180,7 +181,9 @@ export function makeLink(link: WgerLink, language?: string, params?: UrlParams): return `/${language}/nutrition/${params!.id}/copy`; case WgerLink.INGREDIENT_DETAIL: - return `/${language}/nutrition/ingredient/${params!.id}/view`; + return `/${langShort}/nutrition/ingredient/${params!.id}/view`; + case WgerLink.USER_PREFERENCES: + return `/${langShort}/user/preferences`; // Dashboard case WgerLink.DASHBOARD: @@ -196,12 +199,12 @@ export function makeLink(link: WgerLink, language?: string, params?: UrlParams): */ function getCookie(name: string) { let cookieValue = null; - if (document.cookie && document.cookie !== '') { - const cookies = document.cookie.split(';'); + if (document.cookie && document.cookie !== "") { + const cookies = document.cookie.split(";"); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); // Does this cookie string begin with the name we want? - if (cookie.substring(0, name.length + 1) === (name + '=')) { + if (cookie.substring(0, name.length + 1) === name + "=") { cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); break; } @@ -218,20 +221,19 @@ function getCookie(name: string) { */ export function makeHeader(token?: string) { token = token || VITE_API_KEY; - const DJANGO_CSRF_COOKIE = 'csrftoken'; + const DJANGO_CSRF_COOKIE = "csrftoken"; - const out: AxiosRequestConfig['headers'] = {}; - out['Content-Type'] = 'application/json'; + const out: AxiosRequestConfig["headers"] = {}; + out["Content-Type"] = "application/json"; if (token) { - out['Authorization'] = `Token ${token}`; + out["Authorization"] = `Token ${token}`; } const csrfCookie = getCookie(DJANGO_CSRF_COOKIE); if (IS_PROD && csrfCookie != undefined) { - out['X-CSRFToken'] = csrfCookie; + out["X-CSRFToken"] = csrfCookie; } return out; } -