From 8523dd8158ae96296d2ac59d4b1c33df119041a4 Mon Sep 17 00:00:00 2001 From: Ayush Bhardwaj Date: Fri, 11 Sep 2026 10:26:08 +0200 Subject: [PATCH] feat(filters): add filter model with session storage * Add FilterSettings type with type, sort and favorites-only rules * Validate stored data and fall back to defaults if it is broken * Add useFilters hook that keeps state and session storage in sync * Add tests for model and hook References #7 --- src/controllers/.gitkeep | 0 src/controllers/useFilters.test.ts | 57 ++++++++++++++++++++++ src/controllers/useFilters.ts | 28 +++++++++++ src/models/.gitkeep | 0 src/models/filters.test.ts | 70 +++++++++++++++++++++++++++ src/models/filters.ts | 76 ++++++++++++++++++++++++++++++ 6 files changed, 231 insertions(+) delete mode 100644 src/controllers/.gitkeep create mode 100644 src/controllers/useFilters.test.ts create mode 100644 src/controllers/useFilters.ts delete mode 100644 src/models/.gitkeep create mode 100644 src/models/filters.test.ts create mode 100644 src/models/filters.ts diff --git a/src/controllers/.gitkeep b/src/controllers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/controllers/useFilters.test.ts b/src/controllers/useFilters.test.ts new file mode 100644 index 0000000..c1406e7 --- /dev/null +++ b/src/controllers/useFilters.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { DEFAULT_FILTERS, saveFilters } from "../models/filters"; +import { useFilters } from "./useFilters"; + +describe("useFilters Hook", () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); + + test("starts with defaults when nothing is stored", () => { + const { result } = renderHook(() => useFilters()); + + expect(result.current.filters).toEqual(DEFAULT_FILTERS); + }); + + test("restores filters from sessionStorage", () => { + saveFilters({ type: "electric", sort: "name-asc", onlyFavorites: true }); + + const { result } = renderHook(() => useFilters()); + + expect(result.current.filters.type).toBe("electric"); + expect(result.current.filters.sort).toBe("name-asc"); + expect(result.current.filters.onlyFavorites).toBe(true); + }); + + test("setFilter updates state and storage", () => { + const { result } = renderHook(() => useFilters()); + + act(() => { + result.current.setFilter({ type: "fire" }); + }); + + expect(result.current.filters).toEqual({ + ...DEFAULT_FILTERS, + type: "fire", + }); + expect(JSON.parse(window.sessionStorage.getItem("filters")!)).toEqual( + result.current.filters, + ); + }); + + test("resetFilters goes back to defaults", () => { + saveFilters({ type: "ice", sort: "id-desc", onlyFavorites: true }); + + const { result } = renderHook(() => useFilters()); + + act(() => { + result.current.resetFilters(); + }); + + expect(result.current.filters).toEqual(DEFAULT_FILTERS); + expect(JSON.parse(window.sessionStorage.getItem("filters")!)).toEqual( + DEFAULT_FILTERS, + ); + }); +}); diff --git a/src/controllers/useFilters.ts b/src/controllers/useFilters.ts new file mode 100644 index 0000000..be3ebd8 --- /dev/null +++ b/src/controllers/useFilters.ts @@ -0,0 +1,28 @@ +import { useCallback, useEffect, useState } from "react"; +import { + DEFAULT_FILTERS, + loadFilters, + saveFilters, + type FilterSettings, +} from "../models/filters"; + +export function useFilters() { + // 1. Lazy initialization from sessionStorage + const [filters, setFilters] = useState(loadFilters); + + // 2. Keep storage in sync so the choices survive a reload + useEffect(() => { + saveFilters(filters); + }, [filters]); + + // 3. Handlers passed down to the filter components + const setFilter = useCallback((changes: Partial) => { + setFilters((prev) => ({ ...prev, ...changes })); + }, []); + + const resetFilters = useCallback(() => { + setFilters(DEFAULT_FILTERS); + }, []); + + return { filters, setFilter, resetFilters }; +} diff --git a/src/models/.gitkeep b/src/models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/models/filters.test.ts b/src/models/filters.test.ts new file mode 100644 index 0000000..182d1f8 --- /dev/null +++ b/src/models/filters.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + DEFAULT_FILTERS, + loadFilters, + parseFilters, + saveFilters, +} from "./filters"; + +const mockFilters = { + type: "fire", + sort: "name-desc" as const, + onlyFavorites: true, +}; + +describe("Filters Model", () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("returns defaults when nothing is stored", () => { + expect(parseFilters(null)).toEqual(DEFAULT_FILTERS); + expect(loadFilters()).toEqual(DEFAULT_FILTERS); + }); + + test("returns defaults for invalid data", () => { + expect(parseFilters("{not json")).toEqual(DEFAULT_FILTERS); + expect(parseFilters("42")).toEqual(DEFAULT_FILTERS); + expect(parseFilters("[1,2]")).toEqual(DEFAULT_FILTERS); + }); + + test("keeps valid fields and resets invalid ones", () => { + const raw = JSON.stringify({ + type: "water", + sort: "wrong", + onlyFavorites: "yes", + }); + + expect(parseFilters(raw)).toEqual({ + type: "water", + sort: DEFAULT_FILTERS.sort, + onlyFavorites: DEFAULT_FILTERS.onlyFavorites, + }); + }); + + test("saves and loads filters from sessionStorage", () => { + saveFilters(mockFilters); + + expect(window.sessionStorage.getItem("filters")).toBe( + JSON.stringify(mockFilters), + ); + expect(loadFilters()).toEqual(mockFilters); + }); + + test("does not crash when storage is unavailable", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("blocked"); + }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("blocked"); + }); + + expect(() => saveFilters(mockFilters)).not.toThrow(); + expect(loadFilters()).toEqual(DEFAULT_FILTERS); + }); +}); diff --git a/src/models/filters.ts b/src/models/filters.ts new file mode 100644 index 0000000..97cc107 --- /dev/null +++ b/src/models/filters.ts @@ -0,0 +1,76 @@ +export type SortOption = "id-asc" | "id-desc" | "name-asc" | "name-desc"; + +export type FilterSettings = { + type: string | null; + sort: SortOption; + onlyFavorites: boolean; +}; + +export const SORT_OPTIONS: SortOption[] = [ + "id-asc", + "id-desc", + "name-asc", + "name-desc", +]; + +export const DEFAULT_FILTERS: FilterSettings = { + type: null, + sort: "id-asc", + onlyFavorites: false, +}; + +const STORAGE_KEY = "filters"; + +function isSortOption(value: unknown): value is SortOption { + return SORT_OPTIONS.includes(value as SortOption); +} + +// Validates stored data field by field so a broken value +// only resets that field and not the whole object +export function parseFilters(raw: string | null): FilterSettings { + if (!raw) { + return DEFAULT_FILTERS; + } + + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + return DEFAULT_FILTERS; + } + + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return DEFAULT_FILTERS; + } + + const stored = data as Record; + + return { + type: + typeof stored.type === "string" && stored.type !== "" + ? stored.type + : null, + sort: isSortOption(stored.sort) ? stored.sort : DEFAULT_FILTERS.sort, + onlyFavorites: + typeof stored.onlyFavorites === "boolean" + ? stored.onlyFavorites + : DEFAULT_FILTERS.onlyFavorites, + }; +} + +export function loadFilters(): FilterSettings { + try { + return parseFilters(window.sessionStorage.getItem(STORAGE_KEY)); + } catch (error) { + console.error("Error reading sessionStorage", error); + return DEFAULT_FILTERS; + } +} + +export function saveFilters(filters: FilterSettings) { + try { + window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(filters)); + } catch (error) { + console.error("Error writing sessionStorage", error); + } +}