diff --git a/backend/src/server.ts b/backend/src/server.ts index 128b26c..1fad6d1 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,7 +1,8 @@ import { ApolloServer } from "@apollo/server"; import { startStandaloneServer } from "@apollo/server/standalone"; -import type { WithId } from "mongodb"; +import type { Document, Filter, WithId } from "mongodb"; import { db } from "./db.js"; + // The shape of a plant document in MongoDB (only the fields we read) interface PlantDoc { commonName: string; @@ -32,26 +33,47 @@ interface GetPlantsArgs { offset: number; } +interface ExplorePlantsArgs { + search?: string; + sunlight?: string; + watering?: string; + careLevel?: string; + sort?: string; + limit?: number; + offset?: number; +} + const plants = db.collection("plants"); const typeDefs = ` type Query{ getPlants(limit: Int = 50, offset: Int = 0): [Plant] + explorePlants( + search: String + sunlight: String + watering: String + careLevel: String + sort: String + limit: Int = 12 + offset: Int = 0 + ): PlantPage! } + type PlantPage { + plants: [Plant!]! + totalCount: Int! + } type Plant{ - id:ID! + id: ID! name: String! - imageURL:String + imageURL: String scientificName: [String] description: String watering: String sunlight: [String] - careLevel: String} - - - + careLevel: String + } `; const toPlant = (doc: WithId): Plant => ({ @@ -65,6 +87,62 @@ const toPlant = (doc: WithId): Plant => ({ careLevel: doc.careLevel, }); +// Escape user input so it can't be interpreted as regex +const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +// Filter values come in as "full_sun", the database has "full sun" +const normalize = (s: string) => escapeRegex(s.replace(/_/g, " ").trim()); + +// Order from low to high. +const SORT_ORDERS = { + watering: ["minimum", "average", "frequent"], + careLevel: ["low", "medium", "high"], +}; + +// Position in the order list, unknown or missing values go last +const rankOf = (order: string[], value: unknown): Document => ({ + $let: { + vars: { i: { $indexOfArray: [order, value] } }, + in: { $cond: [{ $lt: ["$$i", 0] }, 999, "$$i"] }, + }, +}); + +// Builds the aggregation stages that handle sorting +const buildSortStages = (sort?: string): Document[] => { + switch (sort) { + case "name_desc": + return [{ $sort: { commonName: -1, _id: 1 } }]; + case "watering_asc": + return [ + { + $addFields: { + sortRank: rankOf(SORT_ORDERS.watering, { $toLower: "$watering" }), + }, + }, + { $sort: { sortRank: 1, commonName: 1, _id: 1 } }, + ]; + case "careLevel_asc": + // "Moderate" and "Medium" both exist in the data, treat them as equal + return [ + { + $addFields: { + sortRank: rankOf(SORT_ORDERS.careLevel, { + $let: { + vars: { c: { $toLower: { $ifNull: ["$careLevel", ""] } } }, + in: { + $cond: [{ $eq: ["$$c", "moderate"] }, "medium", "$$c"], + }, + }, + }), + }, + }, + { $sort: { sortRank: 1, commonName: 1, _id: 1 } }, + ]; + default: + return [{ $sort: { commonName: 1, _id: 1 } }]; + } +}; + const resolvers = { Query: { getPlants: async ( @@ -94,6 +172,45 @@ const resolvers = { .toArray(); return docs.map(toPlant); }, + + explorePlants: async ( + _parent: unknown, + args: ExplorePlantsArgs, + ): Promise<{ plants: Plant[]; totalCount: number }> => { + const filter: Filter = {}; + + if (args.search?.trim()) { + filter.commonName = { + $regex: escapeRegex(args.search.trim()), + $options: "i", + }; + } + if (args.watering) { + filter.watering = new RegExp(`^${normalize(args.watering)}$`, "i"); + } + if (args.careLevel) { + const level = normalize(args.careLevel); + // "Medium" and "Moderate" both exist in the data and mean the same + filter.careLevel = new RegExp( + level === "medium" ? "^(medium|moderate)$" : `^${level}$`, + "i", + ); + } + + const [docs, totalCount] = await Promise.all([ + plants + .aggregate>([ + { $match: filter }, + ...buildSortStages(args.sort), + { $skip: args.offset }, + { $limit: args.limit }, + ]) + .toArray(), + plants.countDocuments(filter), + ]); + + return { plants: docs.map(toPlant), totalCount }; + }, }, }; diff --git a/frontend/package.json b/frontend/package.json index 99f851f..f42a96a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-i18next": "^17.0.14", + "react-icons": "^5.7.0", "react-router-dom": "^7.18.4" }, "devDependencies": { diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index b2e9aa0..298efa9 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -40,7 +40,18 @@ "hardCare": "high care" }, "ExplorePlants": { + "title": "Explore Plants", "search": "Search for plants", + "loading": "Loading plants...", + "error": "Could not load plants. Please try again later.", + "filters": "Filters", + "sort": "Sort", + "name_asc": "Name (A-Z)", + "name_desc": "Name (Z-A)", + "watering_asc": "Watering (low-high)", + "careLevel_asc": "Care (easy-hard)", + "reset": "Reset", + "noResults": "No results found", "watering": "Watering needs", "all": "All", "minimum": "Minimum", @@ -50,14 +61,13 @@ "full_sun": "Full sun", "part_sun": "Part sun", "part_shade": "Part shade", - "location": "Location", - "indoor": "Indoor", - "outdoor": "Outdoor", - "edibility": "Edibility", - "edible": "Edible", - "poison": "Poisonous", - "reset": "Reset", - "noResults": "No results found" + "careLevel": "Care level", + "medium": "Medium", + "high": "High", + "pagination": "Page navigation", + "pageOf": "Page {{page}} of {{totalPages}}", + "previous": "Previous", + "next": "Next" }, "myMatchesPage": { "guide": "These are your matches:" diff --git a/frontend/src/i18n/locales/no/translation.json b/frontend/src/i18n/locales/no/translation.json index f93bd34..3e5ab94 100644 --- a/frontend/src/i18n/locales/no/translation.json +++ b/frontend/src/i18n/locales/no/translation.json @@ -40,7 +40,18 @@ "hardCare": "krever mye stell" }, "ExplorePlants": { + "title": "Utforsk planter", "search": "Søk etter planter", + "loading": "Laster planter...", + "error": "Kunne ikke hente planter. Prøv igjen senere.", + "filters": "Filtre", + "sort": "Sorter", + "name_asc": "Navn (A-Z)", + "name_desc": "Navn (Z-A)", + "watering_asc": "Vanning (lite-mye)", + "careLevel_asc": "Stell (lett-krevende)", + "reset": "Nullstill", + "noResults": "Ingen resultater funnet", "watering": "Vanningsbehov", "all": "Alle", "minimum": "Sjeldent", @@ -50,14 +61,13 @@ "full_sun": "Full sol", "part_sun": "Delvis sol", "part_shade": "Delvis skygge", - "location": "Plassering", - "indoor": "Innendørs", - "outdoor": "Utendørs", - "edibility": "Spiselighet", - "edible": "Spiselig", - "poison": "Giftig", - "reset": "Nullstill", - "noResults": "Ingen resultater funnet" + "careLevel": "Stell", + "medium": "Middels", + "high": "Høy", + "pagination": "Sidenavigasjon", + "pageOf": "Side {{page}} av {{totalPages}}", + "previous": "Forrige", + "next": "Neste" }, "myMatchesPage": { "guide": "Dette er dine matcher:" diff --git a/frontend/src/pages/explorePlants/ExplorePlants.css b/frontend/src/pages/explorePlants/ExplorePlants.css index 8a74a7b..6d06402 100644 --- a/frontend/src/pages/explorePlants/ExplorePlants.css +++ b/frontend/src/pages/explorePlants/ExplorePlants.css @@ -11,6 +11,7 @@ gap: 30px; font-family: Georgia, serif; + --card-scale: 0.8; } .visually-hidden { @@ -93,45 +94,137 @@ box-shadow: 0 0 0 2px var(--dark-green); } +.explore-results { + width: 100%; + box-sizing: border-box; + padding: 40px; + border-radius: 16px; + background-color: var(--light-pink); +} + .plant-grid { list-style: none; margin: 0; padding: 0; display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 20px; + grid-template-columns: repeat( + auto-fill, + minmax(calc(390px * var(--card-scale)), 1fr) + ); + gap: 30px 20px; } -.explore-plant-card-placeholder { - aspect-ratio: 3 / 4; - border-radius: 12px; - overflow: hidden; - box-sizing: border-box; - border: 3px solid var(--border-soft); +.explore-plant-item { + width: calc(390px * var(--card-scale)); + height: calc(620px * var(--card-scale)); + justify-self: center; } -.explore-plant-card-placeholder:focus-within { - border-color: var(--dark-green); +.explore-plant-item .plant-card-flipper { + width: 390px; + height: 620px; + transform: scale(var(--card-scale)); + transform-origin: top left; } -.plant-card-button { - width: 100%; - height: 100%; - padding: 1rem; - text-align: center; - background: none; - border: none; - outline: none; +.pagination { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 1rem; + margin: 1.5rem 0; +} + +.pagination button { + padding: 0.5rem 1rem; + border: 1px solid var(--border-soft); + border-radius: 8px; + background-color: var(--light-green); + color: var(--text-dark); font: inherit; - color: inherit; cursor: pointer; } -.plant-card-info { +.pagination button:hover:not(:disabled) { + background-color: var(--dark-green); + color: var(--text-light); +} + +.pagination button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--dark-green); +} + +.pagination button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.sort-menu { + position: relative; + align-self: flex-end; +} + +.sort-button { display: flex; - flex-direction: column; - gap: 0.75rem; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid var(--border-soft); + border-radius: 8px; + background-color: var(--light-green); + color: var(--text-dark); + font-size: 1.4rem; + cursor: pointer; +} + +.sort-button:hover { + background-color: var(--dark-green); + color: var(--text-light); +} + +.sort-button:focus-visible, +.sort-option:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--dark-green); +} + +.sort-options { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 10; + min-width: 220px; + margin: 0; + padding: 0.25rem; + list-style: none; + background-color: var(--light-background); + border: 1px solid var(--border-soft); + border-radius: 8px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12); +} + +.sort-option { + width: 100%; + padding: 0.5rem 0.75rem; + border: none; + border-radius: 6px; + background: none; + color: var(--text-dark); + font: inherit; text-align: left; + cursor: pointer; +} + +.sort-option:hover { + background-color: var(--light-green); +} + +.sort-option--active { + background-color: var(--light-green); + font-weight: 700; } @media (max-width: 767px) { @@ -142,17 +235,24 @@ gap: 20px; } + .explore-results { + padding: 20px 12px; + border-radius: 12px; + } + .plant-grid { - grid-template-columns: repeat(2, 1fr); - gap: 15px; + grid-template-columns: 1fr; + gap: 20px; } - .plant-card-button { - padding: 0.5rem; + .explore-plant-item { + width: min(280px, 62vw); + height: 502px; } - .plant-card-info { - font-size: 13px; - gap: 0.4rem; + .explore-plant-item .plant-card-flipper { + width: 100%; + height: 100%; + transform: none; } } diff --git a/frontend/src/pages/explorePlants/ExplorePlants.tsx b/frontend/src/pages/explorePlants/ExplorePlants.tsx index abd3706..dbcd700 100644 --- a/frontend/src/pages/explorePlants/ExplorePlants.tsx +++ b/frontend/src/pages/explorePlants/ExplorePlants.tsx @@ -1,57 +1,74 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { BiSortAlt2 } from "react-icons/bi"; import { useTranslation } from "react-i18next"; +import { useQuery } from "@apollo/client/react"; import { useDebounce } from "../../hooks/useDebounce"; import { useSessionState } from "../../hooks/useSessionState"; +import { + EXPLORE_PLANTS, + type ExplorePlantsData, +} from "./explorePlantsQuery.ts"; +import PlantCard from "../../components/plantCard/PlantCard.tsx"; import "./ExplorePlants.css"; -// prettier-ignore -const plants = [ - { id: 1, name: "Hydrangea", watering: "average", sunlight: "part_shade", indoor: false, edible: false }, - { id: 2, name: "Orchid", watering: "minimum", sunlight: "part_sun", indoor: true, edible: false }, - { id: 3, name: "Fig Tree", watering: "average", sunlight: "full_sun", indoor: true, edible: true }, - { id: 4, name: "Monstera", watering: "average", sunlight: "part_shade", indoor: true, edible: false }, - { id: 5, name: "Peony", watering: "frequent", sunlight: "full_sun", indoor: false, edible: false }, - { id: 6, name: "Lily", watering: "average", sunlight: "part_sun", indoor: true, edible: false }, - { id: 7, name: "Rose", watering: "frequent", sunlight: "full_sun", indoor: false, edible: false }, - { id: 8, name: "Tulip", watering: "minimum", sunlight: "full_sun", indoor: false, edible: false }, - { id: 9, name: "Daisy", watering: "average", sunlight: "full_sun", indoor: false, edible: true }, - { id: 10, name: "Sunflower", watering: "average", sunlight: "full_sun", indoor: false, edible: true }, - { id: 11, name: "Lavender", watering: "minimum", sunlight: "full_sun", indoor: true, edible: true }, - { id: 12, name: "Jasmine", watering: "average", sunlight: "part_sun", indoor: true, edible: false }, - { id: 13, name: "Marigold", watering: "average", sunlight: "full_sun", indoor: false, edible: true }, - { id: 14, name: "Daffodil", watering: "average", sunlight: "part_sun", indoor: false, edible: false }, - { id: 15, name: "Carnation", watering: "average", sunlight: "full_sun", indoor: false, edible: true }, -]; +const PAGE_SIZE = 12; + +const SORT_OPTIONS = ["name_asc", "name_desc", "watering_asc", "careLevel_asc"]; export default function ExplorePlants() { const { t } = useTranslation(); const [search, setSearch] = useSessionState("ExplorePlants.search"); const debouncedSearch = useDebounce(search, 800); - const [openId, setOpenId] = useState(null); const [watering, setWatering] = useSessionState("ExplorePlants.watering"); const [sunlight, setSunlight] = useSessionState("ExplorePlants.sunlight"); - const [location, setLocation] = useSessionState("ExplorePlants.location"); - const [edibility, setEdibility] = useSessionState("ExplorePlants.edibility"); - - const filteredPlants = plants.filter( - (plant) => - plant.name.toLowerCase().includes(debouncedSearch.toLowerCase()) && - (watering === "" || plant.watering === watering) && - (sunlight === "" || plant.sunlight === sunlight) && - (location === "" || - (location === "indoor" && plant.indoor) || - (location === "outdoor" && !plant.indoor)) && - (edibility === "" || - (edibility === "edible" && plant.edible) || - (edibility === "poison" && !plant.edible)), - ); + const [careLevel, setCareLevel] = useSessionState("ExplorePlants.careLevel"); + const [sort, setSort] = useSessionState("ExplorePlants.sort"); + const [page, setPage] = useState(1); + const [sortOpen, setSortOpen] = useState(false); + const sortRef = useRef(null); + const currentSort = sort || "name_asc"; + + // Close the sort menu on outside click or Escape + useEffect(() => { + if (!sortOpen) return; + + const handleClick = (e: MouseEvent) => { + if (sortRef.current && !sortRef.current.contains(e.target as Node)) { + setSortOpen(false); + } + }; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setSortOpen(false); + }; + + document.addEventListener("mousedown", handleClick); + document.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("mousedown", handleClick); + document.removeEventListener("keydown", handleKey); + }; + }, [sortOpen]); + + const { data, loading, error } = useQuery(EXPLORE_PLANTS, { + variables: { + search: debouncedSearch || null, + watering: watering || null, + sunlight: sunlight || null, + careLevel: careLevel || null, + sort: sort || "name_asc", + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + }, + }); + + const plants = data?.explorePlants.plants ?? []; + const totalCount = data?.explorePlants.totalCount ?? 0; + const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE)); return (
-

- {t("explore.title", "Explore plants")} -

+

{t("explore.title")}

setSearch(e.target.value)} + onChange={(e) => { + setSearch(e.target.value); + setPage(1); + }} />
- {t("ExplorePlants.filters", "Filters")} + {t("ExplorePlants.filters")} - +
@@ -134,65 +186,62 @@ export default function ExplorePlants() { setSearch(""); setWatering(""); setSunlight(""); - setLocation(""); - setEdibility(""); + setCareLevel(""); + setSort(""); + setPage(1); }} > {t("ExplorePlants.reset")} - {filteredPlants.length === 0 &&

{t("ExplorePlants.noResults")}

} +
+ {loading &&

{t("ExplorePlants.loading")}

} + {error &&

{t("ExplorePlants.error")}

} + {!loading && !error && plants.length === 0 && ( +

{t("ExplorePlants.noResults")}

+ )} -
    - {filteredPlants.map((plant) => { - const isOpen = openId === plant.id; - return ( -
  • - +
      + {plants.map((plant) => ( +
    • +
    • - ); - })} -
    + ))} +
+ + {totalCount > 0 && ( + + )} +
); } diff --git a/frontend/src/pages/explorePlants/explorePlantsQuery.ts b/frontend/src/pages/explorePlants/explorePlantsQuery.ts new file mode 100644 index 0000000..e050975 --- /dev/null +++ b/frontend/src/pages/explorePlants/explorePlantsQuery.ts @@ -0,0 +1,53 @@ +import { gql } from "@apollo/client"; + +// Query used by the ExplorePlants page. Search, filtering, sorting and pagination +// all happen in the backend, so they apply to the whole dataset. +export const EXPLORE_PLANTS = gql` + query ExplorePlants( + $search: String + $sunlight: String + $watering: String + $careLevel: String + $sort: String + $limit: Int + $offset: Int + ) { + explorePlants( + search: $search + sunlight: $sunlight + watering: $watering + careLevel: $careLevel + sort: $sort + limit: $limit + offset: $offset + ) { + totalCount + plants { + id + name + imageURL + scientificName + description + watering + sunlight + careLevel + } + } + } +`; + +export type ExplorePlantsData = { + explorePlants: { + totalCount: number; + plants: { + id: string; + name: string | null; + imageURL: string | null; + scientificName: string[] | null; + description: string | null; + watering: string | null; + sunlight: string[] | null; + careLevel: string | null; + }[]; + }; +}; diff --git a/frontend/src/tests/ExplorePage.test.tsx b/frontend/src/tests/ExplorePage.test.tsx index 6d0eec8..38b4226 100644 --- a/frontend/src/tests/ExplorePage.test.tsx +++ b/frontend/src/tests/ExplorePage.test.tsx @@ -1,104 +1,471 @@ -import { render, screen, within } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { MockedProvider } from "@apollo/client/testing/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import ExplorePlants from "../pages/explorePlants/ExplorePlants"; +import { EXPLORE_PLANTS } from "../pages/explorePlants/explorePlantsQuery"; -// mock i18next to return the key as the translation +// Mock i18next: return the key as the translation, and include page numbers for the page counter vi.mock("react-i18next", () => ({ useTranslation: () => ({ - t: (key: string) => key, + t: ( + key: string, + options?: { page?: number; totalPages?: number } | string, + ) => + typeof options === "object" + ? `${key} ${options.page}/${options.totalPages}` + : key, }), })); -// set up the page and user interactions for each test -function setup() { +// PlantCard has its own tests, so it is replaced by a simple stand-in here +vi.mock("../components/plantCard/PlantCard", () => ({ + default: ({ name }: { name: string | null }) => ( +
{name}
+ ), +})); + +type Variables = { + search: string | null; + watering: string | null; + sunlight: string | null; + careLevel: string | null; + sort: string; + limit: number; + offset: number; +}; + +// The variables the page sends on first load +const DEFAULT_VARIABLES: Variables = { + search: null, + watering: null, + sunlight: null, + careLevel: null, + sort: "name_asc", + limit: 12, + offset: 0, +}; + +// Made-up plants, named " " so tests can tell result sets apart +function makePlants(count: number, start = 1, prefix = "Testplante") { + return Array.from({ length: count }, (_, i) => ({ + __typename: "Plant", + id: `${prefix}-${start + i}`, + name: `${prefix} ${start + i}`, + imageURL: null, + scientificName: null, + description: null, + watering: "Average", + sunlight: ["full sun"], + careLevel: "Medium", + })); +} + +// A mocked backend response for one exact set of query variables. +// `result` is a spy, so tests can check whether the request was sent. +function mockResponse( + variables: Partial, + plants = makePlants(3), + totalCount = plants.length, +) { + return { + request: { + query: EXPLORE_PLANTS, + variables: { ...DEFAULT_VARIABLES, ...variables }, + }, + result: vi.fn(() => ({ + data: { + explorePlants: { __typename: "PlantPage", totalCount, plants }, + }, + })), + }; +} + +type Mocks = ComponentProps["mocks"]; + +// Render the page with mocked backend responses. A request with variables +// that have no mock fails, so each test only passes if the right variables are sent. +function setup(mocks: Mocks) { const user = userEvent.setup({ delay: null }); - render(); - return user; + const view = render( + + + , + ); + return { user, ...view }; } -// get all plant cards displayed on the page -const cards = () => screen.getAllByRole("listitem"); +const cards = () => screen.queryAllByTestId("plant-card"); +const cardNames = () => cards().map((card) => card.textContent); + +// Move past the 800 ms search debounce +async function advanceDebounce() { + await act(async () => { + vi.advanceTimersByTime(800); + }); +} describe("ExplorePlants", () => { - // clear stored filter and search values before each test, - // and use fake timers to control the search debounce beforeEach(() => { sessionStorage.clear(); - // shouldAdvanceTime keeps userEvent from hanging while fake timers are on + // shouldAdvanceTime keeps userEvent and findBy* from hanging with fake timers vi.useFakeTimers({ shouldAdvanceTime: true }); }); - // restore real timers after each test + // Restore real timers after each test afterEach(() => { vi.useRealTimers(); }); - // test that all plants are displayed when no filters are selected - it("displays all plants when no filters are selected", () => { - setup(); - expect(cards()).toHaveLength(15); - expect( - screen.getByRole("button", { name: "Monstera" }), - ).toBeInTheDocument(); + // What the user sees while the query is loading, when it fails, and when + // it returns no plants (the page navigation is hidden when there are no results). + describe("loading, errors and empty results", () => { + it("shows a loading message and then one card per plant", async () => { + setup([mockResponse({})]); + + expect(screen.getByText("ExplorePlants.loading")).toBeInTheDocument(); + expect(await screen.findAllByTestId("plant-card")).toHaveLength(3); + expect( + screen.queryByText("ExplorePlants.loading"), + ).not.toBeInTheDocument(); + expect(cardNames()).toEqual([ + "Testplante 1", + "Testplante 2", + "Testplante 3", + ]); + }); + + it("shows an error message when the query fails", async () => { + setup([ + { + request: { query: EXPLORE_PLANTS, variables: DEFAULT_VARIABLES }, + error: new Error("Network error"), + }, + ]); + + expect( + await screen.findByText("ExplorePlants.error"), + ).toBeInTheDocument(); + expect(cards()).toHaveLength(0); + }); + + it("shows a message and no page navigation when nothing matches", async () => { + setup([mockResponse({}, [])]); + + expect( + await screen.findByText("ExplorePlants.noResults"), + ).toBeInTheDocument(); + expect(cards()).toHaveLength(0); + expect(screen.queryByRole("navigation")).not.toBeInTheDocument(); + }); }); - // test that clicking a plant card displays its information - it("opens and closes plant card information", async () => { - const user = setup(); - - const monstera = screen.getByRole("button", { name: "Monstera" }); - - // --- Closed state --- - expect(monstera).toHaveAttribute("aria-pressed", "false"); - expect( - within(monstera).queryByText("ExplorePlants.average"), - ).not.toBeInTheDocument(); - - // --- Open the card --- - await user.click(monstera); - - // the card now reports that it is pressed (open) - expect(monstera).toHaveAttribute("aria-pressed", "true"); - - expect( - within(monstera).getByText("ExplorePlants.average"), - ).toBeInTheDocument(); - expect( - within(monstera).getByText("ExplorePlants.part_shade"), - ).toBeInTheDocument(); - expect( - within(monstera).getByText("ExplorePlants.poison"), - ).toBeInTheDocument(); - expect( - within(monstera).getByText("ExplorePlants.indoor"), - ).toBeInTheDocument(); - - // --- Close the card by clicking it again --- - await user.click(monstera); - - expect(monstera).toHaveAttribute("aria-pressed", "false"); - expect( - within(monstera).queryByText("ExplorePlants.average"), - ).not.toBeInTheDocument(); + // Search, filters and sorting are done by the backend, so these tests check + // that the user's choices are sent as the right query variables. Each mock + // only matches one exact set of variables, so a wrong request makes the test fail. + // Also covers the debounce on the search field and the sort menu behaviour. + describe("search, filters and sorting", () => { + it("sends the search text only after the debounce delay", async () => { + const initial = mockResponse({}); + const search = mockResponse({ search: "rose" }, makePlants(2, 1, "Rose")); + const { user } = setup([initial, search]); + await screen.findAllByTestId("plant-card"); + + await user.type(screen.getByRole("searchbox"), "rose"); + + // Nothing is sent while the user is still typing + expect(search.result).not.toHaveBeenCalled(); + + await advanceDebounce(); + + await waitFor(() => expect(cardNames()).toEqual(["Rose 1", "Rose 2"])); + // One single request for "rose", none for "r", "ro" or "ros" + expect(search.result).toHaveBeenCalledTimes(1); + expect(screen.queryByText("ExplorePlants.error")).not.toBeInTheDocument(); + }); + + it.each<[keyof Variables, string, string]>([ + ["watering", "ExplorePlants.watering", "minimum"], + ["sunlight", "ExplorePlants.sunlight", "part_shade"], + ["careLevel", "ExplorePlants.careLevel", "high"], + ])( + "sends the selected %s filter in the query", + async (variable, label, value) => { + const filtered = mockResponse( + { [variable]: value } as Partial, + makePlants(2, 1, "Filtrert"), + ); + const { user } = setup([mockResponse({}), filtered]); + await screen.findAllByTestId("plant-card"); + + await user.selectOptions(screen.getByLabelText(label), value); + + expect(await screen.findByText("Filtrert 1")).toBeInTheDocument(); + expect(filtered.result).toHaveBeenCalledTimes(1); + }, + ); + + it("sends the chosen sort order and closes the sort menu", async () => { + const sorted = mockResponse( + { sort: "watering_asc" }, + makePlants(2, 1, "Sortert"), + ); + const { user } = setup([mockResponse({}), sorted]); + await screen.findAllByTestId("plant-card"); + + const sortButton = screen.getByRole("button", { + name: "ExplorePlants.sort", + }); + expect(sortButton).toHaveAttribute("aria-expanded", "false"); + + await user.click(sortButton); + expect(sortButton).toHaveAttribute("aria-expanded", "true"); + // The default sort order is marked as the active choice + expect( + screen.getByRole("button", { name: "ExplorePlants.name_asc" }), + ).toHaveAttribute("aria-pressed", "true"); + + await user.click( + screen.getByRole("button", { name: "ExplorePlants.watering_asc" }), + ); + + expect(await screen.findByText("Sortert 1")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "ExplorePlants.watering_asc" }), + ).not.toBeInTheDocument(); + }); + + it("closes the sort menu with Escape and with a click outside", async () => { + const { user } = setup([mockResponse({})]); + await screen.findAllByTestId("plant-card"); + + const sortButton = screen.getByRole("button", { + name: "ExplorePlants.sort", + }); + const option = () => + screen.queryByRole("button", { name: "ExplorePlants.name_desc" }); + + await user.click(sortButton); + expect(option()).toBeInTheDocument(); + await user.keyboard("{Escape}"); + expect(option()).not.toBeInTheDocument(); + + await user.click(sortButton); + expect(option()).toBeInTheDocument(); + await user.click(document.body); + expect(option()).not.toBeInTheDocument(); + }); }); - // --- Moves to backend (the resolver/database decides which plants match) --- - it.todo("filters plants by search"); // -> backend (incl. case-insensitive) - it.todo("filters plants by watering needs"); // -> backend - it.todo("filters plants by sunlight requirements"); // -> backend - it.todo("filters plants by location"); // -> backend - it.todo("filters plants by edibility"); // -> backend - it.todo("combines multiple filters"); // -> backend - it.todo("returns an empty list when nothing matches"); // -> backend - - // --- Stays in frontend, written after Apollo (MockedProvider) --- - it.todo("sends the search text in the query after the debounce delay"); - it.todo("sends the selected filter values in the query"); - it.todo("resets the search and filters"); - it.todo("shows a loading state"); - it.todo("shows an error message when the query fails"); - it.todo("shows a message when the result is empty"); - - // Snapshot tests ? + // Page-based navigation: the page counter, the previous/next buttons (disabled + // at the ends), and that the page starts over at 1 whenever the search text or + // a filter changes. Without that reset, the user could end up on an empty page. + describe("pagination", () => { + it("moves between pages and disables the buttons at the ends", async () => { + const { user } = setup([ + mockResponse({}, makePlants(12), 30), + mockResponse({ offset: 12 }, makePlants(12, 13), 30), + mockResponse({ offset: 24 }, makePlants(6, 25), 30), + ]); + const previous = () => + screen.getByRole("button", { name: "ExplorePlants.previous" }); + const next = () => + screen.getByRole("button", { name: "ExplorePlants.next" }); + + // 30 plants with 12 per page gives 3 pages + expect( + await screen.findByText("ExplorePlants.pageOf 1/3"), + ).toBeInTheDocument(); + expect(previous()).toBeDisabled(); + expect(next()).toBeEnabled(); + + await user.click(next()); + expect(await screen.findByText("Testplante 13")).toBeInTheDocument(); + expect(screen.getByText("ExplorePlants.pageOf 2/3")).toBeInTheDocument(); + + await user.click(next()); + expect(await screen.findByText("Testplante 25")).toBeInTheDocument(); + expect(cards()).toHaveLength(6); + expect(screen.getByText("ExplorePlants.pageOf 3/3")).toBeInTheDocument(); + expect(next()).toBeDisabled(); + + await user.click(previous()); + expect(await screen.findByText("Testplante 13")).toBeInTheDocument(); + expect(screen.getByText("ExplorePlants.pageOf 2/3")).toBeInTheDocument(); + }); + + it("goes back to page 1 when a filter changes", async () => { + const filtered = mockResponse( + { careLevel: "high" }, + makePlants(5, 1, "Høy"), + ); + const { user } = setup([ + mockResponse({}, makePlants(12), 30), + mockResponse({ offset: 12 }, makePlants(12, 13), 30), + filtered, + ]); + await screen.findByText("ExplorePlants.pageOf 1/3"); + + await user.click( + screen.getByRole("button", { name: "ExplorePlants.next" }), + ); + expect( + await screen.findByText("ExplorePlants.pageOf 2/3"), + ).toBeInTheDocument(); + + await user.selectOptions( + screen.getByLabelText("ExplorePlants.careLevel"), + "high", + ); + + // The request has offset 0, not 12, and the page counter starts over + expect(await screen.findByText("Høy 1")).toBeInTheDocument(); + expect(filtered.result).toHaveBeenCalledTimes(1); + expect(screen.getByText("ExplorePlants.pageOf 1/1")).toBeInTheDocument(); + }); + + it("goes back to page 1 when the search text changes", async () => { + const search = mockResponse({ search: "rose" }, makePlants(2, 1, "Rose")); + const { user } = setup([ + mockResponse({}, makePlants(12), 30), + mockResponse({ offset: 12 }, makePlants(12, 13), 30), + search, + ]); + await screen.findByText("ExplorePlants.pageOf 1/3"); + await user.click( + screen.getByRole("button", { name: "ExplorePlants.next" }), + ); + await screen.findByText("ExplorePlants.pageOf 2/3"); + + await user.type(screen.getByRole("searchbox"), "rose"); + + // The page is reset immediately, before the debounced request is sent + expect( + await screen.findByText("ExplorePlants.pageOf 1/3"), + ).toBeInTheDocument(); + expect(search.result).not.toHaveBeenCalled(); + + await advanceDebounce(); + await waitFor(() => expect(cardNames()).toEqual(["Rose 1", "Rose 2"])); + }); + }); + + // The reset button clears the search and all filters, and the filters are + // remembered (in sessionStorage) when the user leaves the page and comes back. + describe("reset and session", () => { + it("clears the search and all filters when reset is clicked", async () => { + const { user } = setup([ + mockResponse({}), + mockResponse({ watering: "minimum" }, makePlants(2, 1, "Filtrert")), + mockResponse( + { watering: "minimum", careLevel: "high" }, + makePlants(1, 1, "Filtrert to"), + ), + ]); + await screen.findAllByTestId("plant-card"); + + await user.type(screen.getByRole("searchbox"), "ro"); + await user.selectOptions( + screen.getByLabelText("ExplorePlants.watering"), + "minimum", + ); + await screen.findByText("Filtrert 1"); + await user.selectOptions( + screen.getByLabelText("ExplorePlants.careLevel"), + "high", + ); + await screen.findByText("Filtrert to 1"); + + await user.click( + screen.getByRole("button", { name: "ExplorePlants.reset" }), + ); + + expect(screen.getByRole("searchbox")).toHaveValue(""); + expect(screen.getByLabelText("ExplorePlants.watering")).toHaveValue(""); + expect(screen.getByLabelText("ExplorePlants.careLevel")).toHaveValue(""); + expect(await screen.findByText("Testplante 1")).toBeInTheDocument(); + }); + + it("remembers the filters when the page is opened again in the same session", async () => { + const filtered = () => + mockResponse({ watering: "minimum" }, makePlants(2, 1, "Filtrert")); + const first = setup([mockResponse({}), filtered()]); + await screen.findAllByTestId("plant-card"); + await first.user.selectOptions( + screen.getByLabelText("ExplorePlants.watering"), + "minimum", + ); + await screen.findByText("Filtrert 1"); + first.unmount(); + + // A fresh render in the same session starts with the saved filter + setup([filtered()]); + + expect(await screen.findByText("Filtrert 1")).toBeInTheDocument(); + expect(screen.getByLabelText("ExplorePlants.watering")).toHaveValue( + "minimum", + ); + }); + }); + + // Accessibility: the page has a search landmark, a heading, a labelled + // page navigation, and every input and filter has an accessible name. + describe("accessibility", () => { + it("has a search landmark, a heading and labelled controls", async () => { + setup([mockResponse({}, makePlants(12), 30)]); + await screen.findAllByTestId("plant-card"); + + expect(screen.getByRole("search")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { level: 1, name: "explore.title" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("searchbox", { name: "ExplorePlants.search" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("navigation", { name: "ExplorePlants.pagination" }), + ).toBeInTheDocument(); + expect( + screen.getByLabelText("ExplorePlants.watering"), + ).toBeInTheDocument(); + expect( + screen.getByLabelText("ExplorePlants.sunlight"), + ).toBeInTheDocument(); + expect( + screen.getByLabelText("ExplorePlants.careLevel"), + ).toBeInTheDocument(); + }); + }); + + // Snapshot tests: compare the rendered page with a saved snapshot, so + // unintended changes to the markup (labels, roles, structure) are caught. + // The snapshots are saved in src/tests/__snapshots__ and must be committed. + // If the markup is changed on purpose, update them with `npx vitest run -u`. + describe("snapshots", () => { + it("matches the snapshot of the page with results and page navigation", async () => { + const { container } = setup([mockResponse({}, makePlants(12), 30)]); + await screen.findAllByTestId("plant-card"); + + expect(container).toMatchSnapshot(); + }); + + it("matches the snapshot of the page with no results", async () => { + const { container } = setup([mockResponse({}, [])]); + await screen.findByText("ExplorePlants.noResults"); + + expect(container).toMatchSnapshot(); + }); + + it("matches the snapshot of the page with the sort menu open", async () => { + const { container, user } = setup([mockResponse({})]); + await screen.findAllByTestId("plant-card"); + + await user.click( + screen.getByRole("button", { name: "ExplorePlants.sort" }), + ); + + expect(container).toMatchSnapshot(); + }); + }); }); diff --git a/frontend/src/tests/__snapshots__/ExplorePage.test.tsx.snap b/frontend/src/tests/__snapshots__/ExplorePage.test.tsx.snap new file mode 100644 index 0000000..63f475c --- /dev/null +++ b/frontend/src/tests/__snapshots__/ExplorePage.test.tsx.snap @@ -0,0 +1,698 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ExplorePlants > snapshots > matches the snapshot of the page with no results 1`] = ` +
+
+

+ explore.title +

+ + +
+

+ ExplorePlants.noResults +

+
    +
+
+
+`; + +exports[`ExplorePlants > snapshots > matches the snapshot of the page with results and page navigation 1`] = ` +
+
+

+ explore.title +

+ + +
+
    +
  • +
    + Testplante 1 +
    +
  • +
  • +
    + Testplante 2 +
    +
  • +
  • +
    + Testplante 3 +
    +
  • +
  • +
    + Testplante 4 +
    +
  • +
  • +
    + Testplante 5 +
    +
  • +
  • +
    + Testplante 6 +
    +
  • +
  • +
    + Testplante 7 +
    +
  • +
  • +
    + Testplante 8 +
    +
  • +
  • +
    + Testplante 9 +
    +
  • +
  • +
    + Testplante 10 +
    +
  • +
  • +
    + Testplante 11 +
    +
  • +
  • +
    + Testplante 12 +
    +
  • +
+ +
+
+
+`; + +exports[`ExplorePlants > snapshots > matches the snapshot of the page with the sort menu open 1`] = ` +
+
+

+ explore.title +

+ + +
+
    +
  • +
    + Testplante 1 +
    +
  • +
  • +
    + Testplante 2 +
    +
  • +
  • +
    + Testplante 3 +
    +
  • +
+ +
+
+
+`;