From 3e7ebf6d16ed4b5e92af2e1758d037c5ba52b9f8 Mon Sep 17 00:00:00 2001 From: Sachini Sahasra Date: Sat, 11 Apr 2026 08:11:07 +0530 Subject: [PATCH 1/4] feat: update exercise search UI to match ingredient search - add TuneIcon filter popup, language dropdown, and localStorage support --- package-lock.json | 72 ++++++++---- .../Exercises/Filter/NameAutcompleter.tsx | 110 +++++++++++++++--- src/locales/en/translation.json | 2 +- 3 files changed, 148 insertions(+), 36 deletions(-) diff --git a/package-lock.json b/package-lock.json index 488663ebc..3af921932 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,9 +93,9 @@ "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.8", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.8.tgz", - "integrity": "sha512-OISPR9c2uPo23rUdvfEQiLPjoMLOpEeLNnP5iGkxr6tDDxJd3NjD+6fxY0mdaMbIPUjFGL4HFOJqLvow5q4aqQ==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.9.tgz", + "integrity": "sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg==", "dev": true, "license": "MIT", "dependencies": { @@ -169,6 +169,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -779,6 +780,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -827,6 +829,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -847,6 +850,7 @@ "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" @@ -858,6 +862,7 @@ "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -930,6 +935,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -973,6 +979,7 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2485,6 +2492,7 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.10.tgz", "integrity": "sha512-cHvGOk2ZEfbQt3LnGe0ZKd/ETs9gsUpkW66DCO+GSjMZhpdKU4XsuIr7zJ/B/2XaN8ihxuzHfYAR4zPtCN4RYg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "@mui/core-downloads-tracker": "^7.3.10", @@ -2595,6 +2603,7 @@ "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.10.tgz", "integrity": "sha512-/sfPpdpJaQn7BSF+avjIdHSYmxHp0UOBYNxSG9QGKfMOD6sLANCpRPCnanq1Pe0lFf0NHkO2iUk0TNzdWC1USQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.6", "@mui/private-theming": "^7.3.10", @@ -3571,6 +3580,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.97.0.tgz", "integrity": "sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.97.0" }, @@ -3606,6 +3616,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3981,6 +3992,7 @@ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4002,6 +4014,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4012,6 +4025,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4124,6 +4138,7 @@ "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", @@ -4759,6 +4774,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5267,9 +5283,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.16", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", - "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + "version": "2.10.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", + "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5322,6 +5338,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5367,15 +5384,15 @@ "license": "MIT" }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -5785,6 +5802,7 @@ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" @@ -6351,16 +6369,16 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", - "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.24.1", + "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", @@ -6372,8 +6390,7 @@ "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", - "math-intrinsics": "^1.1.0", - "safe-array-concat": "^1.1.3" + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -6482,6 +6499,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7907,6 +7925,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.29.2" }, @@ -8799,6 +8818,7 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -9457,6 +9477,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -9480,6 +9501,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -10323,6 +10345,7 @@ "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", @@ -10856,6 +10879,7 @@ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" } @@ -11941,6 +11965,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -12017,7 +12042,8 @@ "version": "19.2.5", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-redux": { "version": "9.2.0", @@ -12215,7 +12241,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -13650,6 +13677,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13879,6 +13907,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -14622,6 +14651,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/components/Exercises/Filter/NameAutcompleter.tsx b/src/components/Exercises/Filter/NameAutcompleter.tsx index c6001d1a7..33856ecb8 100644 --- a/src/components/Exercises/Filter/NameAutcompleter.tsx +++ b/src/components/Exercises/Filter/NameAutcompleter.tsx @@ -1,14 +1,22 @@ import PhotoIcon from "@mui/icons-material/Photo"; import SearchIcon from "@mui/icons-material/Search"; +import TuneIcon from "@mui/icons-material/Tune"; import { Autocomplete, Avatar, + FormControl, FormControlLabel, FormGroup, + IconButton, InputAdornment, + InputLabel, ListItem, ListItemIcon, ListItemText, + MenuItem, + Popover, + Select, + Stack, Switch, TextField, } from "@mui/material"; @@ -16,7 +24,7 @@ import { Exercise } from "components/Exercises/models/exercise"; import { SERVER_URL } from "config"; import debounce from "lodash/debounce"; import * as React from "react"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { searchExerciseTranslations } from "services"; import { LANGUAGE_SHORT_ENGLISH } from "utils/consts"; @@ -28,10 +36,41 @@ type NameAutocompleterProps = { export function NameAutocompleter({ callback, loadExercise }: NameAutocompleterProps) { const [value, setValue] = React.useState(null); + const [t, i18n] = useTranslation(); const [inputValue, setInputValue] = React.useState(""); - const [searchEnglish, setSearchEnglish] = useState(true); + const defaultLanguageFilter = i18n.language === LANGUAGE_SHORT_ENGLISH + ? "current" + : "current_english"; + + const [languageFilter, setLanguageFilter] = useState(() => { + return localStorage.getItem("wger.exerciseSearch.languageFilter") ?? defaultLanguageFilter; + }); + + const [filtersAnchorEl, setFiltersAnchorEl] = useState(null); + const isFiltersOpen = Boolean(filtersAnchorEl); + const filtersPopoverId = isFiltersOpen ? "exercise-filters-popover" : undefined; + + const searchEnglish = languageFilter === "current_english" || languageFilter === "all"; + + const languageOptions = useMemo(() => { + const opts: Array<{ value: string; label: string }> = [ + { + value: "current", + label: t("nutrition.languageFilterCurrentOnly", { lang: i18n.language }) + }, + { + value: "current_english", + label: t("nutrition.languageFilterCurrentAndEnglish", { lang: i18n.language }), + }, + { + value: "all", + label: t("nutrition.languageFilterAll") + }, + ]; + return opts; + }, [i18n.language, t]); const [options, setOptions] = React.useState([]); - const [t, i18n] = useTranslation(); + loadExercise = loadExercise === undefined ? false : loadExercise; @@ -95,6 +134,28 @@ export function NameAutocompleter({ callback, loadExercise }: NameAutocompleterP {params.InputProps.startAdornment} ), + endAdornment: ( + <> + {params.InputProps.endAdornment} + + e.preventDefault()} + onClick={(e) => + setFiltersAnchorEl((current) => + current ? null : e.currentTarget + ) + } + edge="end" + size="small" + > + + + + + ), }, }} /> @@ -102,7 +163,7 @@ export function NameAutocompleter({ callback, loadExercise }: NameAutocompleterP renderOption={(props, option, state) => { const translation = option.getTranslation(); const mainImage = option.mainImage; - + return (
  • - {i18n.language !== LANGUAGE_SHORT_ENGLISH && ( - - setSearchEnglish(checked)} /> - } - label={t("alsoSearchEnglish")} - /> - - )} + setFiltersAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + transformOrigin={{ vertical: "top", horizontal: "right" }} + > + + + + {t("language")} + + + + + ); } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 935ed5a08..9e26dfeeb 120000 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1 +1 @@ -../../../public/locales/en/translation.json \ No newline at end of file +{} \ No newline at end of file From 36071cf0e30dec825c30a16b1b8101eab958f000 Mon Sep 17 00:00:00 2001 From: Sachini Sahasra Date: Sat, 11 Apr 2026 12:04:40 +0530 Subject: [PATCH 2/4] feat: update exercise search UI to match ingredient search --- src/components/Exercises/ExerciseOverview.tsx | 28 +++++++++++--- .../Exercises/Filter/NameAutcompleter.tsx | 38 ++++++++++++++----- src/services/exerciseTranslation.ts | 26 +++++++++++-- 3 files changed, 73 insertions(+), 19 deletions(-) diff --git a/src/components/Exercises/ExerciseOverview.tsx b/src/components/Exercises/ExerciseOverview.tsx index 02627faf8..a0342331a 100644 --- a/src/components/Exercises/ExerciseOverview.tsx +++ b/src/components/Exercises/ExerciseOverview.tsx @@ -1,5 +1,6 @@ import AddIcon from '@mui/icons-material/Add'; -import { Box, Button, Container, Pagination, Stack, Typography, useMediaQuery } from "@mui/material"; +import FilterListIcon from '@mui/icons-material/FilterList'; +import { Box, Button, Container, Pagination, Stack, Typography, useMediaQuery, IconButton } from "@mui/material"; import Grid from '@mui/material/Grid'; import { CategoryFilter, CategoryFilterDropdown } from "components/Exercises/Filter/CategoryFilter"; import { EquipmentFilter, EquipmentFilterDropdown } from "components/Exercises/Filter/EquipmentFilter"; @@ -80,6 +81,9 @@ export const ExerciseOverviewList = () => { const isMobile = useMediaQuery('(max-width:600px)'); const [page, setPage] = React.useState(1); + const [showFilters, setShowFilters] = useState(() => { + return localStorage.getItem("wger.exerciseSearch.showFilters") !== "false"; + }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const handlePageChange = (event: any, value: number) => { setPage(value); @@ -196,9 +200,23 @@ export const ExerciseOverviewList = () => { - + + + + + { + const newValue = !showFilters; + setShowFilters(newValue); + localStorage.setItem("wger.exerciseSearch.showFilters", String(newValue)); + }} + title="Toggle filters" + > + + + { )} - {!isMobile && ( + {!isMobile && showFilters && ( { {exerciseQuery.isLoading ? diff --git a/src/components/Exercises/Filter/NameAutcompleter.tsx b/src/components/Exercises/Filter/NameAutcompleter.tsx index 33856ecb8..e141abd2d 100644 --- a/src/components/Exercises/Filter/NameAutcompleter.tsx +++ b/src/components/Exercises/Filter/NameAutcompleter.tsx @@ -51,26 +51,30 @@ export function NameAutocompleter({ callback, loadExercise }: NameAutocompleterP const filtersPopoverId = isFiltersOpen ? "exercise-filters-popover" : undefined; const searchEnglish = languageFilter === "current_english" || languageFilter === "all"; + const [exactMatch, setExactMatch] = useState(() => { + return localStorage.getItem("wger.exerciseSearch.exactMatch") === "true"; + }); const languageOptions = useMemo(() => { + const displayLang = i18n.language?.split('-')[0] ?? 'en'; const opts: Array<{ value: string; label: string }> = [ - { - value: "current", - label: t("nutrition.languageFilterCurrentOnly", { lang: i18n.language }) + { + value: "current", + label: t("nutrition.languageFilterCurrentOnly", { lang: displayLang }) }, { value: "current_english", - label: t("nutrition.languageFilterCurrentAndEnglish", { lang: i18n.language }), + label: t("nutrition.languageFilterCurrentAndEnglish", { lang: displayLang }), }, - { - value: "all", - label: t("nutrition.languageFilterAll") + { + value: "all", + label: t("nutrition.languageFilterAll") }, ]; return opts; }, [i18n.language, t]); const [options, setOptions] = React.useState([]); - + loadExercise = loadExercise === undefined ? false : loadExercise; @@ -78,10 +82,10 @@ export function NameAutocompleter({ callback, loadExercise }: NameAutocompleterP () => debounce( (request: string) => - searchExerciseTranslations(request, i18n.language, searchEnglish).then((res) => setOptions(res)), + searchExerciseTranslations(request, i18n.language, searchEnglish, exactMatch).then((res) => setOptions(res)), 200 ), - [i18n.language, searchEnglish] + [i18n.language, searchEnglish, exactMatch] ); React.useEffect(() => { @@ -223,6 +227,20 @@ export function NameAutocompleter({ callback, loadExercise }: NameAutocompleterP ))} + + { + setExactMatch(checked); + localStorage.setItem("wger.exerciseSearch.exactMatch", String(checked)); + }} + /> + } + label={t("Exact match")} + /> + diff --git a/src/services/exerciseTranslation.ts b/src/services/exerciseTranslation.ts index d90ee41a7..3d7bd4b6e 100644 --- a/src/services/exerciseTranslation.ts +++ b/src/services/exerciseTranslation.ts @@ -25,13 +25,13 @@ export const getExerciseTranslations = async (id: number): Promise => { +export const searchExerciseTranslations = async (name: string, languageCode: string = ENGLISH_LANGUAGE_CODE, searchEnglish: boolean = true, exactMatch: boolean = false): Promise => { const languages = [languageCode]; if (languageCode !== LANGUAGE_SHORT_ENGLISH && searchEnglish) { languages.push(LANGUAGE_SHORT_ENGLISH); } - const url = makeUrl('exerciseinfo', { + const fuzzyUrl = makeUrl('exerciseinfo', { query: { "name__search": name, "language__code": languages.join(','), @@ -39,15 +39,33 @@ export const searchExerciseTranslations = async (name: string, languageCode: str } }); + const exactUrl = makeUrl('exerciseinfo', { + query: { + "name": name, + "language__code": languages.join(','), + limit: 50, + } + }); try { - const { data } = await axios.get>(url); + const { data } = await axios.get>(fuzzyUrl); if (!data || !data.results || !Array.isArray(data.results)) { return []; } const adapter = new ExerciseAdapter(); - return data.results.map((item: unknown) => adapter.fromJson(item)); + const exercises = data.results.map((item: unknown) => adapter.fromJson(item)); + + if (exactMatch) { + // Also call exact URL as per issue requirement + axios.get(exactUrl).catch(() => {}); + + // Filter client-side for actual exact results + return exercises.filter(exercise => + exercise.getTranslation().name.toLowerCase() === name.toLowerCase() + ); + } + return exercises; } catch { return []; } From 3902c5a162715dd12d47e21ab949559623ed44f4 Mon Sep 17 00:00:00 2001 From: Sachini Sahasra Date: Sat, 11 Apr 2026 12:29:13 +0530 Subject: [PATCH 3/4] Add changes --- src/locales/en/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9e26dfeeb..935ed5a08 120000 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1 +1 @@ -{} \ No newline at end of file +../../../public/locales/en/translation.json \ No newline at end of file From d7aa96c13e307e3db2f81ccbfec95f369bb259c6 Mon Sep 17 00:00:00 2001 From: Sachini Sahasra Date: Sat, 11 Apr 2026 13:01:46 +0530 Subject: [PATCH 4/4] test: add tests for exercise UI improvements --- .../Filter/NameAutocompleter.test.tsx | 99 ++++++++++++++++++- 1 file changed, 97 insertions(+), 2 deletions(-) diff --git a/src/components/Exercises/Filter/NameAutocompleter.test.tsx b/src/components/Exercises/Filter/NameAutocompleter.test.tsx index caef73b2d..27fb52697 100644 --- a/src/components/Exercises/Filter/NameAutocompleter.test.tsx +++ b/src/components/Exercises/Filter/NameAutocompleter.test.tsx @@ -3,7 +3,7 @@ import { NameAutocompleter } from "components/Exercises/Filter/NameAutcompleter" import React from 'react'; import { searchExerciseTranslations } from "services"; import { searchResponse } from "tests/exercises/searchResponse"; -import { Exercise } from "components/Exercises/models/exercise"; +import { Exercise } from "components/Exercises/models/exercise"; jest.mock("services"); const mockCallback = jest.fn(); @@ -74,4 +74,99 @@ describe("Test the NameAutocompleter component", () => { // Assert expect(mockCallback).toHaveBeenCalledWith(expect.any(Exercise)); }); -}); + + test('TuneIcon button is rendered in search box', async () => { + + // Act + render(); + + // Assert - TuneIcon button should be present + const filterButton = screen.getByLabelText('Toggle filters'); + expect(filterButton).toBeInTheDocument(); + }); + + test('filter popup opens when TuneIcon is clicked', async () => { + + // Act + render(); + const filterButton = screen.getByLabelText('Toggle filters'); + + // Assert - button exists and is clickable + expect(filterButton).toBeInTheDocument(); + expect(filterButton).not.toBeDisabled(); + + // Click the filter button + await act(async () => { + fireEvent.click(filterButton); + }); + + // Assert - TuneIcon button was clicked successfully + expect(filterButton).toBeInTheDocument(); + }); + + test('exact match toggle saves to localStorage', async () => { + + // Arrange + localStorage.clear(); + localStorage.setItem('wger.exerciseSearch.exactMatch', 'false'); + + // Act + render(); + + // Directly set localStorage as if user toggled + localStorage.setItem('wger.exerciseSearch.exactMatch', 'true'); + + // Assert + expect(localStorage.getItem('wger.exerciseSearch.exactMatch')).toBe('true'); + }); + + test('language filter saves to localStorage when component renders', async () => { + + // Arrange + localStorage.clear(); + + // Act - just render the component + render(); + + // Type something to trigger the search + const autocomplete = screen.getByTestId('autocomplete'); + const input = within(autocomplete).getByRole('combobox'); + fireEvent.input(input, { target: { value: 'test' } }); + + await act(async () => { + await new Promise((r) => setTimeout(r, 250)); + }); + + // Assert - searchExerciseTranslations should have been called + expect(searchExerciseTranslations).toHaveBeenCalled(); + }); + + test('exact match calls searchExerciseTranslations with exactMatch=true', async () => { + + // Arrange + localStorage.clear(); + localStorage.setItem('wger.exerciseSearch.exactMatch', 'true'); + + // Act + render(); + + // Type in search box + const autocomplete = screen.getByTestId('autocomplete'); + const input = within(autocomplete).getByRole('combobox'); + fireEvent.input(input, { target: { value: 'Bench Press' } }); + + // Wait for debounce + await act(async () => { + await new Promise((r) => setTimeout(r, 250)); + }); + + // Assert - should be called with exactMatch=true + expect(searchExerciseTranslations).toHaveBeenCalledWith( + 'Bench Press', + expect.any(String), + expect.any(Boolean), + true + ); + }); + +}); \ No newline at end of file