diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index b18e8f7d..338c6097 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -140,6 +140,10 @@ "languageFilterAll": "All languages", "filterVegan": "Vegan", "filterVegetarian": "Vegetarian", + "filterNutriscore": "Nutri-Score filter", + "filterNutriscoreOff": "Off", + "filterNutriscoreNoFilter": "No filter", + "filterNutriscoreOrBetter": "{{grade}} or better", "macronutrient": "Macronutrient", "percentEnergy": "Percent of energy", "gPerBodyKg": "g per body-kg", diff --git a/src/components/Nutrition/widgets/IngredientAutcompleter.tsx b/src/components/Nutrition/widgets/IngredientAutcompleter.tsx index 3b4a3098..5e0561d2 100644 --- a/src/components/Nutrition/widgets/IngredientAutcompleter.tsx +++ b/src/components/Nutrition/widgets/IngredientAutcompleter.tsx @@ -18,9 +18,11 @@ import { MenuItem, Popover, Select, + Slider, Stack, Switch, TextField, + Typography, } from "@mui/material"; import { Ingredient } from "components/Nutrition/models/Ingredient"; import { NutriScoreBadge } from "components/Nutrition/widgets/NutriScoreBadge"; @@ -29,6 +31,7 @@ import * as React from 'react'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from "react-i18next"; import { IngredientLanguageFilter, searchIngredient } from "services"; +import { NUTRI_SCORES, NutriScoreValue } from "types"; import { LANGUAGE_SHORT_ENGLISH } from "utils/consts"; type IngredientAutocompleterProps = { @@ -39,6 +42,18 @@ type IngredientAutocompleterProps = { export const STORAGE_KEY_LANGUAGE_FILTER = "wger.ingredientSearch.languageFilter"; export const STORAGE_KEY_VEGAN = "wger.ingredientSearch.filterVegan"; export const STORAGE_KEY_VEGETARIAN = "wger.ingredientSearch.filterVegetarian"; +export const STORAGE_KEY_NUTRISCORE_MAX = "wger.ingredientSearch.filterNutriscoreMax"; + +const NUTRISCORE_OFF_INDEX = 0; + +const isNutriScoreValue = (value: string | null): value is NutriScoreValue => + value !== null && (NUTRI_SCORES as readonly string[]).includes(value); + +const sliderIndexToNutriscore = (index: number): NutriScoreValue | null => + index === NUTRISCORE_OFF_INDEX ? null : NUTRI_SCORES[index - 1]; + +const nutriscoreToSliderIndex = (value: NutriScoreValue | null): number => + value === null ? NUTRISCORE_OFF_INDEX : NUTRI_SCORES.indexOf(value) + 1; export function IngredientAutocompleter({ callback, initialIngredient }: IngredientAutocompleterProps) { const initialData = initialIngredient ?? null; @@ -57,6 +72,10 @@ export function IngredientAutocompleter({ callback, initialIngredient }: Ingredi const [filterVegetarian, setFilterVegetarian] = useState(() => { return localStorage.getItem(STORAGE_KEY_VEGETARIAN) === "true"; }); + const [nutriscoreMax, setNutriscoreMax] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY_NUTRISCORE_MAX); + return isNutriScoreValue(stored) ? stored : null; + }); const [filtersAnchorEl, setFiltersAnchorEl] = useState(null); const [value, setValue] = useState(initialData); const [inputValue, setInputValue] = useState(""); @@ -80,6 +99,14 @@ export function IngredientAutocompleter({ callback, initialIngredient }: Ingredi localStorage.setItem(STORAGE_KEY_VEGETARIAN, String(filterVegetarian)); }, [filterVegetarian]); + useEffect(() => { + if (nutriscoreMax === null) { + localStorage.removeItem(STORAGE_KEY_NUTRISCORE_MAX); + } else { + localStorage.setItem(STORAGE_KEY_NUTRISCORE_MAX, nutriscoreMax); + } + }, [nutriscoreMax]); + const languageOptions = useMemo(() => { const options: Array<{ value: IngredientLanguageFilter; label: string }> = [ { @@ -107,16 +134,16 @@ export function IngredientAutocompleter({ callback, initialIngredient }: Ingredi () => debounce( (request: string) => - searchIngredient( - request, - i18n.language, + searchIngredient(request, { + languageCode: i18n.language, languageFilter, - filterVegan || undefined, - filterVegetarian || undefined, - ).then((res) => setOptions(res)), + isVegan: filterVegan || undefined, + isVegetarian: filterVegetarian || undefined, + nutriscoreMax: nutriscoreMax ?? undefined, + }).then((res) => setOptions(res)), 200 ), - [i18n.language, languageFilter, filterVegan, filterVegetarian] + [i18n.language, languageFilter, filterVegan, filterVegetarian, nutriscoreMax] ); useEffect(() => { @@ -297,6 +324,39 @@ export function IngredientAutocompleter({ callback, initialIngredient }: Ingredi label={t("nutrition.filterVegetarian")} /> + + + + {t("nutrition.filterNutriscore")} + + + {nutriscoreMax === null + ? t("nutrition.filterNutriscoreNoFilter") + : t("nutrition.filterNutriscoreOrBetter", { grade: nutriscoreMax.toUpperCase() })} + + + setNutriscoreMax(sliderIndexToNutriscore(value as number)) + } + getAriaValueText={(value) => + value === NUTRISCORE_OFF_INDEX + ? t("nutrition.filterNutriscoreNoFilter") + : t("nutrition.filterNutriscoreOrBetter", { grade: NUTRI_SCORES[value - 1].toUpperCase() }) + } + step={1} + min={0} + max={NUTRI_SCORES.length} + marks={[ + { value: NUTRISCORE_OFF_INDEX, label: t("nutrition.filterNutriscoreOff") }, + ...NUTRI_SCORES.map((score, index) => ({ + value: index + 1, + label: score.toUpperCase(), + })), + ]} + /> + diff --git a/src/components/Nutrition/widgets/IngredientAutocompleter.test.tsx b/src/components/Nutrition/widgets/IngredientAutocompleter.test.tsx index 20619221..d187dddb 100644 --- a/src/components/Nutrition/widgets/IngredientAutocompleter.test.tsx +++ b/src/components/Nutrition/widgets/IngredientAutocompleter.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { IngredientAutocompleter, STORAGE_KEY_LANGUAGE_FILTER, + STORAGE_KEY_NUTRISCORE_MAX, STORAGE_KEY_VEGAN, STORAGE_KEY_VEGETARIAN } from 'components/Nutrition/widgets/IngredientAutcompleter'; @@ -16,6 +17,7 @@ describe("Test the IngredientAutocompleter component", () => { // Arrange const mockCallback = jest.fn(); beforeEach(() => { + localStorage.clear(); (searchIngredient as jest.Mock).mockImplementation(() => Promise.resolve([TEST_INGREDIENT_1, TEST_INGREDIENT_2])); }); @@ -186,4 +188,112 @@ describe("Test the IngredientAutocompleter component", () => { expect(screen.getByText('nutrition.languageFilterAll')).toBeInTheDocument(); localStorage.clear(); }); + + test('nutriscore slider defaults to Off and is always visible in the popover', async () => { + // Arrange + const user = userEvent.setup(); + render(); + + // Act + await user.click(screen.getByLabelText('Toggle filters')); + + // Assert — slider rendered with Off position (index 0) on first load + const slider = screen.getByRole('slider', { name: 'nutrition.filterNutriscore' }); + expect(slider).toBeInTheDocument(); + expect(slider).toHaveAttribute('aria-valuetext', 'nutrition.filterNutriscoreNoFilter'); + expect(slider).toHaveAttribute('aria-valuenow', '0'); + }); + + test('moving the slider persists the selected grade to local storage', async () => { + // Arrange + const user = userEvent.setup(); + const setItemSpy = jest.spyOn(Storage.prototype, 'setItem'); + render(); + + // Act + await user.click(screen.getByLabelText('Toggle filters')); + const slider = screen.getByRole('slider', { name: 'nutrition.filterNutriscore' }); + slider.focus(); + await user.keyboard('{ArrowRight}'); + + // Assert — index 1 -> 'a' + expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY_NUTRISCORE_MAX, 'a'); + + setItemSpy.mockRestore(); + }); + + test('moving the slider back to Off removes the stored grade', async () => { + // Arrange + const user = userEvent.setup(); + localStorage.setItem(STORAGE_KEY_NUTRISCORE_MAX, 'a'); + const removeItemSpy = jest.spyOn(Storage.prototype, 'removeItem'); + render(); + + // Act + await user.click(screen.getByLabelText('Toggle filters')); + const slider = screen.getByRole('slider', { name: 'nutrition.filterNutriscore' }); + slider.focus(); + await user.keyboard('{ArrowLeft}'); + + // Assert + expect(removeItemSpy).toHaveBeenCalledWith(STORAGE_KEY_NUTRISCORE_MAX); + + removeItemSpy.mockRestore(); + }); + + test('nutriscore filter state is loaded from local storage', async () => { + // Arrange + const user = userEvent.setup(); + localStorage.setItem(STORAGE_KEY_NUTRISCORE_MAX, 'b'); + render(); + + // Act + await user.click(screen.getByLabelText('Toggle filters')); + + // Assert + expect(screen.getByRole('slider', { name: 'nutrition.filterNutriscore' })).toHaveAttribute('aria-valuetext', 'nutrition.filterNutriscoreOrBetter'); + }); + + test('searchIngredient is called with nutriscoreMax when a grade is selected', async () => { + // Arrange + const user = userEvent.setup(); + localStorage.setItem(STORAGE_KEY_NUTRISCORE_MAX, 'b'); + render(); + const autocomplete = screen.getByTestId('autocomplete'); + const input = within(autocomplete).getByRole('combobox'); + + // Act + await user.click(autocomplete); + await user.type(input, 'Yog'); + await act(async () => { + await new Promise((r) => setTimeout(r, 250)); + }); + + // Assert + expect(searchIngredient).toHaveBeenCalledWith( + 'Yog', + expect.objectContaining({ nutriscoreMax: 'b' }), + ); + }); + + test('nutriscoreMax is omitted when the slider is at Off', async () => { + // Arrange + const user = userEvent.setup(); + render(); + const autocomplete = screen.getByTestId('autocomplete'); + const input = within(autocomplete).getByRole('combobox'); + + // Act + await user.click(autocomplete); + await user.type(input, 'Yog'); + await act(async () => { + await new Promise((r) => setTimeout(r, 250)); + }); + + // Assert + expect(searchIngredient).toHaveBeenCalledWith( + 'Yog', + expect.objectContaining({ nutriscoreMax: undefined }), + ); + }); }); diff --git a/src/services/ingredient.ts b/src/services/ingredient.ts index 88e756dc..ffdb416b 100644 --- a/src/services/ingredient.ts +++ b/src/services/ingredient.ts @@ -1,13 +1,22 @@ import axios from 'axios'; import { Ingredient } from "components/Nutrition/models/Ingredient"; import { memoize } from "lodash"; -import { ApiIngredientType } from 'types'; +import { ApiIngredientType, NutriScoreValue } from 'types'; import { API_RESULTS_PAGE_SIZE, ApiPath, LANGUAGE_SHORT_ENGLISH } from "utils/consts"; import { fetchPaginated } from "utils/requests"; import { makeHeader, makeUrl } from "utils/url"; export type IngredientLanguageFilter = "current" | "current_english" | "all"; +export interface IngredientSearchFilters { + languageCode: string; + languageFilter?: IngredientLanguageFilter; + isVegan?: boolean; + isVegetarian?: boolean; + /** Worst acceptable Nutri-Score grade; sent as `nutriscore__lte`. */ + nutriscoreMax?: NutriScoreValue; +} + /* * Memoized version of getIngredient. This caches results in memory for the duration @@ -47,11 +56,16 @@ export const getIngredients = async (ids: number[]): Promise => { export const searchIngredient = async ( name: string, - languageCode: string, - languageFilter: IngredientLanguageFilter = "current_english", - isVegan?: boolean, - isVegetarian?: boolean, + filters: IngredientSearchFilters, ): Promise => { + const { + languageCode, + languageFilter = "current_english", + isVegan, + isVegetarian, + nutriscoreMax, + } = filters; + const languages = languageFilter === "all" ? null : [languageCode]; if (languages && languageFilter === "current_english" && languageCode !== LANGUAGE_SHORT_ENGLISH) { languages.push(LANGUAGE_SHORT_ENGLISH); @@ -70,9 +84,12 @@ export const searchIngredient = async ( if (isVegetarian !== undefined) { query['is_vegetarian'] = String(isVegetarian); } + if (nutriscoreMax !== undefined) { + query['nutriscore__lte'] = nutriscoreMax; + } const url = makeUrl(ApiPath.INGREDIENTINFO_PATH, { query }); const { data } = await axios.get(url, { headers: makeHeader() },); return data.results.map((entry: ApiIngredientType) => Ingredient.fromJson(entry)); -}; \ No newline at end of file +};